├── .cursor └── rules │ ├── commit-rule.mdc │ └── test-rule.mdc ├── .eslintrc.cjs ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── custom.md │ └── feature_request.md ├── .gitignore ├── .hintrc ├── .nycrc ├── .storybook ├── main.ts ├── preview.ts └── vitest.setup.ts ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── RELEASE.md ├── SECURITY.md ├── cypress.config.ts ├── cypress ├── e2e │ ├── home.cy.ts │ └── typing.cy.ts └── support │ ├── commands.ts │ ├── component.ts │ └── e2e.ts ├── index.html ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public ├── apple-touch-icon-180x180.png ├── cover.png ├── favicon.ico ├── logo.png ├── maskable-icon-512x512.png ├── preview.gif ├── pwa-192x192.png ├── pwa-512x512.png └── pwa-64x64.png ├── src ├── App.tsx ├── components │ ├── CodeTypingTest.tsx │ ├── CommandPalette.tsx │ ├── ControlsDisplay.tsx │ ├── Footer.tsx │ ├── GlobalStats.tsx │ ├── LanguageSwitcher.tsx │ ├── MistakeAlert.tsx │ ├── MobileInput.tsx │ ├── ModeToggle.stories.tsx │ ├── ModeToggle.tsx │ ├── Navbar.stories.tsx │ ├── Navbar.tsx │ ├── NonEnTyping.tsx │ ├── NotFound.tsx │ ├── Result.test.tsx │ ├── Result.tsx │ ├── SEO.tsx │ ├── StatsDisplay.tsx │ ├── TextDisplay.tsx │ ├── Timer.test.tsx │ ├── Timer.tsx │ ├── TypingTest.stories.tsx │ ├── TypingTest.test.tsx │ └── TypingTest.tsx ├── hooks │ ├── useKeyboardHandler.ts │ ├── useSpeech.ts │ └── useTypingTest.ts ├── i18n │ ├── i18n.ts │ └── locales │ │ ├── ar.json │ │ ├── bn.json │ │ ├── de.json │ │ ├── en.json │ │ ├── es.json │ │ ├── fr.json │ │ ├── hi.json │ │ ├── ja.json │ │ ├── ko.json │ │ ├── ru.json │ │ └── zh.json ├── index.css ├── lib │ ├── commands.ts │ ├── compare.ts │ ├── constants.ts │ ├── generateRandomWords.ts │ ├── topics │ │ ├── biology.ts │ │ ├── chemistry.ts │ │ ├── english-writting.ts │ │ ├── index.ts │ │ └── physics.ts │ ├── utils.ts │ └── words.ts ├── main.tsx ├── routeTree.gen.ts ├── routes │ ├── __root.tsx │ ├── about.tsx │ ├── code.tsx │ ├── custom-text.tsx │ ├── guide.tsx │ ├── index.tsx │ ├── practice.tsx │ ├── privacy.tsx │ ├── saved-text.tsx │ ├── stats.tsx │ └── terms.tsx ├── store │ ├── __mocks__ │ │ └── themeStore.ts │ ├── errorStatsStore.ts │ ├── sentenceStore.ts │ └── themeStore.ts ├── stories │ ├── Button.stories.ts │ ├── Button.tsx │ ├── Configure.mdx │ ├── Header.stories.ts │ ├── Header.tsx │ ├── Page.stories.ts │ ├── Page.tsx │ ├── assets │ │ ├── accessibility.png │ │ ├── accessibility.svg │ │ ├── addon-library.png │ │ ├── assets.png │ │ ├── avif-test-image.avif │ │ ├── context.png │ │ ├── discord.svg │ │ ├── docs.png │ │ ├── figma-plugin.png │ │ ├── github.svg │ │ ├── share.png │ │ ├── styling.png │ │ ├── testing.png │ │ ├── theming.png │ │ ├── tutorials.svg │ │ └── youtube.svg │ ├── button.css │ ├── header.css │ └── page.css ├── test │ └── setup.ts ├── types │ └── json.d.ts └── vite-env.d.ts ├── tailwind.config.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── vitest.workspace.ts /.cursor/rules/commit-rule.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | # Commit Message Rules 7 | 8 | ## Format 9 | - Use emoji at the start of commit message 10 | - Keep commit message short (3-5 words) 11 | - Use conventional commit types 12 | 13 | ## Structure 14 | ``` 15 | type: brief description 16 | ``` 17 | 18 | ## Commit Types 19 | - feat: New feature 20 | - fix: Bug fix 21 | - docs: Documentation changes 22 | - style: Code style changes (formatting, etc) 23 | - refactor: Code refactoring 24 | - test: Adding or modifying tests 25 | - chore: Maintenance tasks 26 | 27 | ## Examples 28 | - ✨ feat: add user authentication 29 | - 🐞 fix: resolve login error 30 | - 📚 docs: update readme 31 | - 🎨 style: format code 32 | - 🔨 refactor: optimize database queries 33 | - 🧪 test: add unit tests 34 | - ⚙️ chore: update dependencies -------------------------------------------------------------------------------- /.cursor/rules/test-rule.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: false 5 | --- 6 | # Test File Guidelines 7 | 8 | ## File Structure 9 | - Test files should be co-located with the files they test 10 | - Use `.test.ts` or `.spec.ts` extension for test files 11 | - Group related tests using `describe` blocks 12 | - Use clear, descriptive test names that explain the behavior being tested 13 | 14 | ## Test Organization 15 | ```typescript 16 | describe('ComponentName', () => { 17 | // Setup 18 | beforeEach(() => { 19 | // Common setup 20 | }); 21 | 22 | // Happy path tests 23 | describe('when operation succeeds', () => { 24 | it('should do something specific', () => { 25 | // Test implementation 26 | }); 27 | }); 28 | 29 | // Error cases 30 | describe('when operation fails', () => { 31 | it('should handle error appropriately', () => { 32 | // Test implementation 33 | }); 34 | }); 35 | }); 36 | ``` 37 | 38 | ## Best Practices 39 | 1. **Test Isolation** 40 | - Each test should be independent 41 | - Use `beforeEach` instead of `beforeAll` when possible 42 | - Clean up after each test using `afterEach` 43 | 44 | 2. **Assertions** 45 | - One assertion per test when possible 46 | - Use descriptive assertion messages 47 | - Prefer specific assertions over generic ones 48 | 49 | 3. **Async Testing** 50 | - Use async/await for asynchronous tests 51 | - Handle promises properly 52 | - Test both success and error cases 53 | 54 | 4. **Mocking** 55 | - Mock external dependencies 56 | - Reset mocks between tests 57 | - Use meaningful mock implementations 58 | 59 | 5. **Test Coverage** 60 | - Aim for high coverage of critical paths 61 | - Test edge cases and error conditions 62 | - Document any intentional coverage gaps 63 | 64 | ## Anti-patterns to Avoid 65 | - ❌ Tests that depend on each other 66 | - ❌ Tests without clear assertions 67 | - ❌ Tests that are too long or complex 68 | - ❌ Tests that don't clean up after themselves 69 | - ❌ Tests that don't handle async operations properly 70 | 71 | ## Example Good Test 72 | ```typescript 73 | describe('UserService', () => { 74 | let userService: UserService; 75 | let mockApi: jest.Mocked; 76 | 77 | beforeEach(() => { 78 | mockApi = { 79 | getUser: jest.fn(), 80 | updateUser: jest.fn() 81 | }; 82 | userService = new UserService(mockApi); 83 | }); 84 | 85 | afterEach(() => { 86 | jest.clearAllMocks(); 87 | }); 88 | 89 | describe('getUserProfile', () => { 90 | it('should return user profile when API call succeeds', async () => { 91 | // Arrange 92 | const mockUser = { id: 1, name: 'Test User' }; 93 | mockApi.getUser.mockResolvedValue(mockUser); 94 | 95 | // Act 96 | const result = await userService.getUserProfile(1); 97 | 98 | // Assert 99 | expect(result).toEqual(mockUser); 100 | expect(mockApi.getUser).toHaveBeenCalledWith(1); 101 | }); 102 | 103 | it('should throw error when API call fails', async () => { 104 | // Arrange 105 | const error = new Error('API Error'); 106 | mockApi.getUser.mockRejectedValue(error); 107 | 108 | // Act & Assert 109 | await expect(userService.getUserProfile(1)).rejects.toThrow('API Error'); 110 | }); 111 | }); 112 | }); 113 | ``` 114 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:react-hooks/recommended', 'plugin:storybook/recommended'], 5 | ignorePatterns: ['dist', '.eslintrc.cjs'], 6 | parser: '@typescript-eslint/parser', 7 | plugins: ['react-refresh'], 8 | rules: { 9 | 'react-refresh/only-export-components': [ 10 | 'warn', 11 | { allowConstantExport: true }, 12 | ], 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.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 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | *storybook.log 27 | 28 | # Coverage directory used by tools like istanbul 29 | coverage 30 | *.lcov 31 | .nyc_output 32 | 33 | # Cypress 34 | cypress/screenshots/ 35 | cypress/videos/ 36 | cypress/downloads/ 37 | -------------------------------------------------------------------------------- /.hintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "development" 4 | ], 5 | "hints": { 6 | "compat-api/html": [ 7 | "default", 8 | { 9 | "ignore": [ 10 | "meta[name=theme-color]" 11 | ] 12 | } 13 | ], 14 | "axe/name-role-value": [ 15 | "default", 16 | { 17 | "button-name": "off" 18 | } 19 | ], 20 | "axe/forms": [ 21 | "default", 22 | { 23 | "select-name": "off" 24 | } 25 | ] 26 | } 27 | } -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@istanbuljs/nyc-config-typescript", 3 | "all": true, 4 | "include": [ 5 | "src/**/*.{ts,tsx}" 6 | ], 7 | "exclude": [ 8 | "src/**/*.test.{ts,tsx}", 9 | "src/**/*.spec.{ts,tsx}", 10 | "src/**/*.cy.{ts,tsx}", 11 | "src/**/*.stories.{ts,tsx}" 12 | ], 13 | "reporter": [ 14 | "html", 15 | "text", 16 | "text-summary", 17 | "lcov" 18 | ], 19 | "report-dir": "coverage" 20 | } -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/react-vite'; 2 | 3 | const config: StorybookConfig = { 4 | "stories": [ 5 | "../src/**/*.mdx", 6 | "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)" 7 | ], 8 | "addons": [ 9 | "@storybook/addon-links", 10 | "@storybook/addon-essentials", 11 | "@storybook/addon-onboarding", 12 | "@storybook/addon-interactions", 13 | "@storybook/addon-a11y", 14 | "@storybook/addon-themes", 15 | "@chromatic-com/storybook", 16 | "@storybook/experimental-addon-test" 17 | ], 18 | "framework": { 19 | "name": "@storybook/react-vite", 20 | "options": {} 21 | }, 22 | "docs": { 23 | "autodocs": "tag" 24 | }, 25 | "core": { 26 | "disableTelemetry": true 27 | }, 28 | "viteFinal": async (config) => { 29 | return { 30 | ...config, 31 | define: { 32 | ...config.define, 33 | global: 'globalThis', 34 | }, 35 | }; 36 | }, 37 | }; 38 | 39 | export default config; -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from '@storybook/react' 2 | import '../src/index.css' 3 | 4 | const preview: Preview = { 5 | parameters: { 6 | controls: { 7 | matchers: { 8 | color: /(background|color)$/i, 9 | date: /Date$/i, 10 | }, 11 | }, 12 | darkMode: { 13 | current: 'light', 14 | darkClass: 'dark', 15 | lightClass: 'light', 16 | stylePreview: true, 17 | }, 18 | }, 19 | }; 20 | 21 | export default preview; -------------------------------------------------------------------------------- /.storybook/vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll } from 'vitest'; 2 | import { setProjectAnnotations } from '@storybook/react'; 3 | import * as projectAnnotations from './preview'; 4 | 5 | // This is an important step to apply the right configuration when testing your stories. 6 | // More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations 7 | const project = setProjectAnnotations([projectAnnotations]); 8 | 9 | beforeAll(project.beforeAll); -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | ashsajal@yahoo.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | Contributions are welcome! If you would like to contribute to this project, please follow these steps: 4 | 5 | 1. Fork the repository by clicking the "Fork" button on the GitHub repository page. 6 | 7 | 2. Clone your forked repository to your local machine: 8 | git clone https://github.com/your-username/typing-app.git 9 | 10 | 11 | 3. Create a new branch for your feature or bug fix: 12 | git checkout -b feature/your-feature-name 13 | 14 | 4. Make your changes and add them to your branch. 15 | 16 | 5. Commit your changes: 17 | git commit -m "Add your commit message here" 18 | 19 | 20 | 6. Push your changes to your forked repository: 21 | git push origin feature/your-feature-name 22 | 23 | 24 | 7. Open a pull request on the main repository's GitHub page. Provide a clear description of your changes and their purpose. 25 | 26 | Please ensure that your code follows the established code style and guidelines. Also, make sure to test your changes thoroughly before submitting a pull request. 27 | 28 | ## License 29 | 30 | This project is licensed under the MIT License. You are free to use, modify, and distribute the code as per the terms of the license. 31 | 32 | Feel free to explore the code, make improvements, and contribute to the Typing Practice App. We appreciate your contributions! 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ashfiquzzaman Sajal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Typing Practice App 2 | 3 | ![Typing Test Preview](./public/preview.gif) 4 | 5 | The Typing Practice App is a web application built with React.js that allows users to practice and improve their typing skills. This app provides a simple and intuitive interface for users to test their typing speed and accuracy. 6 | 7 | ## Features 8 | 9 | - Real-time typing feedback 10 | - Error tracking and statistics 11 | - Multiple test modes 12 | - Responsive design for all devices 13 | - Dark mode support 14 | - Keyboard shortcuts for better workflow 15 | 16 | ## Getting Started 17 | 18 | ### Prerequisites 19 | 20 | To run the Typing Practice App locally, you need to have PNPM installed. If PNPM is not already installed on your system, you can install it by following the instructions at [https://pnpm.io/installation](https://pnpm.io/installation). 21 | 22 | ### Installation 23 | 24 | Follow these steps to get the app up and running on your local machine: 25 | 26 | 1. Clone the repository: 27 | git clone https://github.com/ashsajal1/typing-app.git 28 | 29 | 2. Navigate to the project directory: 30 | cd typing-app 31 | 32 | 33 | 3. Install the dependencies using PNPM: 34 | pnpm install 35 | 36 | 37 | 4. Start the development server: 38 | pnpm dev 39 | 40 | 5. Open your browser and visit `http://localhost:3000` to access the Typing Practice App. 41 | 42 | ## Contributing 43 | 44 | Contributions are welcome! If you would like to contribute to this project, please follow these steps: 45 | 46 | 1. Fork the repository by clicking the "Fork" button on the GitHub repository page. 47 | 48 | 2. Clone your forked repository to your local machine: 49 | git clone https://github.com/your-username/typing-app.git 50 | 51 | 52 | 3. Create a new branch for your feature or bug fix: 53 | git checkout -b feature/your-feature-name 54 | 55 | 4. Make your changes and add them to your branch. 56 | 57 | 5. Commit your changes: 58 | git commit -m "Add your commit message here" 59 | 60 | 61 | 6. Push your changes to your forked repository: 62 | git push origin feature/your-feature-name 63 | 64 | 65 | 7. Open a pull request on the main repository's GitHub page. Provide a clear description of your changes and their purpose. 66 | 67 | Please ensure that your code follows the established code style and guidelines. Also, make sure to test your changes thoroughly before submitting a pull request. 68 | 69 | ## License 70 | 71 | This project is licensed under the MIT License. You are free to use, modify, and distribute the code as per the terms of the license. 72 | 73 | Feel free to explore the code, make improvements, and contribute to the Typing Practice App. We appreciate your contributions! -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release Checklist 2 | 3 | ## Version Comparison (1.3.2 → 1.4.2) 4 | ### Major Changes 5 | - Added PWA support 6 | - Implemented new practice topics 7 | - Enhanced UI with keyboard-inspired design 8 | - Added comprehensive testing setup 9 | 10 | ### Minor Changes 11 | - Improved typing accuracy calculation 12 | - Enhanced responsive design 13 | - Added more practice durations 14 | - Implemented better error handling 15 | 16 | ### Bug Fixes 17 | - Fixed timer synchronization issues 18 | - Resolved PWA update problems 19 | - Fixed horizontal scrolling issues 20 | - Improved accessibility 21 | 22 | ## Pre-release Tasks 23 | 1. [ ] Run all tests: `pnpm test` 24 | 2. [ ] Check linting: `pnpm lint` 25 | 3. [ ] Update documentation 26 | 4. [ ] Review and update README.md 27 | 5. [ ] Check all features are working 28 | 6. [ ] Test PWA functionality 29 | 7. [ ] Verify responsive design 30 | 8. [ ] Check accessibility 31 | 9. [ ] Verify test coverage numbers 32 | 10. [ ] Measure PWA bundle size 33 | 11. [ ] Run performance benchmarks 34 | 35 | ## Release Steps 36 | 1. [ ] Run release preparation: `pnpm release:prepare` 37 | 2. [ ] Update version: `pnpm release:version [patch|minor|major]` 38 | 3. [ ] Build for production: `pnpm release:deploy` 39 | 4. [ ] Deploy to hosting platform 40 | 5. [ ] Create GitHub release 41 | 6. [ ] Update changelog 42 | 43 | ## Post-release Tasks 44 | 1. [ ] Monitor for issues 45 | 2. [ ] Gather user feedback 46 | 3. [ ] Plan next release 47 | 48 | ## Deployment Options 49 | 1. **GitHub Pages** 50 | - Free hosting 51 | - Easy integration with GitHub 52 | - Good for static sites 53 | 54 | 2. **Vercel** 55 | - Free tier available 56 | - Great for React applications 57 | - Automatic deployments 58 | 59 | 3. **Netlify** 60 | - Free tier available 61 | - Easy deployment 62 | - Good for static sites and PWAs 63 | 64 | ## Monetization Options 65 | 1. **Open Source** 66 | - Keep it free and open source 67 | - Accept donations/sponsorships 68 | - Build community 69 | 70 | 2. **Premium Features** 71 | - Advanced statistics 72 | - More practice topics 73 | - Custom themes 74 | - Offline mode 75 | 76 | 3. **Enterprise Version** 77 | - Team features 78 | - Progress tracking 79 | - Custom content management 80 | - API access 81 | 82 | ## Project Value 83 | - Modern tech stack 84 | - Clean, responsive UI 85 | - PWA support 86 | - Multiple practice topics 87 | - Customizable settings 88 | - Testing coverage 89 | - Documentation 90 | - Active maintenance 91 | 92 | ## Release Notes Template 93 | ```markdown 94 | # Typing Test v1.4.2 Release Notes 95 | 96 | ## Overview 97 | This release introduces significant improvements to the typing practice application, focusing on offline capabilities, enhanced user experience, and robust testing infrastructure. 98 | 99 | ## 🚀 New Features 100 | - **PWA Support**: Added Progressive Web App capabilities for offline practice 101 | - **New Practice Topics**: 102 | - Physics (⚛️) 103 | - Programming (💻) 104 | - Literature (📚) 105 | - History (📜) 106 | - Science (🔬) 107 | - **Keyboard-Inspired UI**: Implemented floating keyboard elements for enhanced visual experience 108 | - **Comprehensive Testing**: Added Vitest and Cypress for robust test coverage 109 | 110 | ## 🔧 Improvements 111 | - **Typing Accuracy**: Enhanced calculation algorithm for more precise results 112 | - **Responsive Design**: Optimized layout for all screen sizes 113 | - **Practice Duration**: Added flexible timing options (30s, 1m, 2m, unlimited) 114 | - **Error Handling**: Implemented better error feedback and recovery 115 | 116 | ## 🐛 Bug Fixes 117 | - Fixed timer synchronization issues during practice sessions 118 | - Resolved PWA update and cache management problems 119 | - Addressed horizontal scrolling issues on mobile devices 120 | - Improved accessibility for screen readers 121 | 122 | ## 📦 Technical Updates 123 | - Updated to React 18.3.1 124 | - Integrated Storybook for component documentation 125 | - Enhanced test coverage (verify actual numbers) 126 | - Optimized build process with Vite 127 | - Migrated to pnpm for better dependency management 128 | 129 | ## 🔄 Migration Notes 130 | - No breaking changes from v1.3.2 131 | - Automatic PWA update for existing users 132 | - Improved performance on all devices 133 | 134 | ## 📋 System Requirements 135 | - Node.js >= 18.0.0 136 | - Modern web browser with PWA support 137 | - Storage: ~25MB for PWA installation (verify actual size) 138 | 139 | ## 🔍 Known Issues 140 | - PWA updates may require manual refresh on some devices 141 | - Performance impact of floating animations varies by device (verify with benchmarks) 142 | 143 | ## 📚 Documentation 144 | - Updated API documentation 145 | - New component documentation in Storybook 146 | - Improved README with setup instructions 147 | 148 | ## 🙏 Acknowledgments 149 | - Thanks to all contributors 150 | - Special thanks to the testing team 151 | - Community feedback and bug reports 152 | 153 | ## 🔜 Roadmap 154 | - Multiplayer mode (planned for v1.5.0) 155 | - Custom theme support 156 | - Advanced statistics dashboard 157 | - API for third-party integration 158 | ``` 159 | 160 | ## Package Manager Commands 161 | ```bash 162 | # Install dependencies 163 | pnpm install 164 | 165 | # Development 166 | pnpm dev 167 | 168 | # Testing 169 | pnpm test 170 | pnpm test:coverage 171 | pnpm test:ui 172 | 173 | # Building 174 | pnpm build 175 | pnpm preview 176 | 177 | # PWA 178 | pnpm generate-pwa-assets 179 | 180 | # Release 181 | pnpm release:prepare 182 | pnpm release:version 183 | pnpm release:deploy 184 | ``` 185 | 186 | ## Verification Checklist 187 | - [ ] Run `pnpm test:coverage` to verify test coverage numbers 188 | - [ ] Build PWA and measure actual bundle size 189 | - [ ] Run performance benchmarks on various devices 190 | - [ ] Verify all features work in offline mode 191 | - [ ] Test PWA update mechanism 192 | - [ ] Check responsive design on multiple devices 193 | - [ ] Verify accessibility compliance 194 | - [ ] Test all practice topics 195 | - [ ] Verify timer functionality 196 | - [ ] Check error handling scenarios -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | React > 18.0 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | 16.x | :white_check_mark: | 10 | 11 | ## Reporting a Vulnerability 12 | 13 | Report vulnerability at ashsajal@yahoo.com 14 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress' 2 | import codeCoverageTask from '@cypress/code-coverage/task' 3 | 4 | export default defineConfig({ 5 | e2e: { 6 | baseUrl: 'http://localhost:5173', 7 | supportFile: 'cypress/support/e2e.ts', 8 | specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}', 9 | video: false, 10 | screenshotOnRunFailure: true, 11 | setupNodeEvents(on, config) { 12 | codeCoverageTask(on, config) 13 | return config 14 | }, 15 | }, 16 | component: { 17 | devServer: { 18 | framework: 'react', 19 | bundler: 'vite', 20 | }, 21 | supportFile: 'cypress/support/component.ts', 22 | specPattern: 'cypress/component/**/*.cy.{js,jsx,ts,tsx}', 23 | }, 24 | }) -------------------------------------------------------------------------------- /cypress/e2e/home.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Home Page', () => { 2 | beforeEach(() => { 3 | cy.visit('/') 4 | }) 5 | 6 | it('should load the home page', () => { 7 | cy.get('body').should('be.visible') 8 | }) 9 | 10 | // Add more tests based on your application's functionality 11 | }) -------------------------------------------------------------------------------- /cypress/e2e/typing.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Typing App', () => { 2 | beforeEach(() => { 3 | // Set viewport to desktop size 4 | cy.viewport(1280, 720) 5 | cy.visit('/') 6 | }) 7 | 8 | describe('Basic Page Loading', () => { 9 | it('should load the home page with all essential elements', () => { 10 | cy.get('body').should('be.visible') 11 | cy.get('h1').should('contain', 'Typing Practice') 12 | cy.get('select').should('exist') // Topic selection 13 | cy.get('select').eq(1).should('exist') // Time selection 14 | cy.get('button').contains('Start Practice').should('exist') 15 | }) 16 | }) 17 | 18 | describe('Practice Session', () => { 19 | it('should start practice when clicking start button', () => { 20 | // Select topic and time 21 | cy.get('select').first().select('physics') 22 | cy.get('select').eq(1).select('60') 23 | 24 | // Click start practice button 25 | cy.get('button').contains('Start Practice').click() 26 | 27 | // Verify we're on the practice page 28 | cy.url().should('include', '/practice') 29 | 30 | // Verify practice elements are present 31 | cy.get('textarea').should('exist') 32 | cy.get('button').contains('Reset').should('exist') 33 | cy.get('button').contains('Finish').should('exist') 34 | }) 35 | 36 | it('should show start typing message before typing begins', () => { 37 | // Navigate to practice page 38 | cy.get('select').first().select('physics') 39 | cy.get('select').eq(1).select('60') 40 | cy.get('button').contains('Start Practice').click() 41 | 42 | // Verify start message is shown 43 | cy.contains('Start typing to begin the test').should('be.visible') 44 | cy.contains('Press any key to start').should('be.visible') 45 | }) 46 | 47 | it('should start timer when typing begins', () => { 48 | // Navigate to practice page 49 | cy.get('select').first().select('physics') 50 | cy.get('select').eq(1).select('60') 51 | cy.get('button').contains('Start Practice').click() 52 | 53 | // Start typing 54 | cy.get('textarea').type('a') 55 | 56 | // Verify timer has started 57 | cy.get('progress').should('exist') 58 | }) 59 | }) 60 | 61 | describe('Typing Functionality', () => { 62 | beforeEach(() => { 63 | // Navigate to practice page 64 | cy.get('select').first().select('physics') 65 | cy.get('select').eq(1).select('60') 66 | cy.get('button').contains('Start Practice').click() 67 | cy.get('textarea').type('a') // Start the test 68 | }) 69 | 70 | it('should allow typing and show correct input', () => { 71 | const testText = 'Hello World' 72 | cy.get('textarea').type(testText) 73 | cy.get('textarea').should('have.value', testText) 74 | }) 75 | 76 | it('should show typing stats', () => { 77 | cy.get('textarea').type('Hello World') 78 | cy.contains('WPM').should('exist') 79 | cy.contains('Accuracy').should('exist') 80 | }) 81 | 82 | it('should handle keyboard shortcuts', () => { 83 | // Test Escape key for reset 84 | cy.get('textarea').type('Hello') 85 | cy.get('body').type('{esc}') 86 | cy.get('textarea').should('have.value', '') 87 | 88 | // Test Enter key for finish 89 | cy.get('textarea').type('Hello World') 90 | cy.get('body').type('{enter}') 91 | cy.contains('Your Results').should('be.visible') 92 | }) 93 | }) 94 | 95 | describe('Game Controls', () => { 96 | beforeEach(() => { 97 | // Navigate to practice page 98 | cy.get('select').first().select('physics') 99 | cy.get('select').eq(1).select('60') 100 | cy.get('button').contains('Start Practice').click() 101 | cy.get('textarea').type('a') // Start the test 102 | }) 103 | 104 | it('should reset the game when clicking reset button', () => { 105 | cy.get('textarea').type('Hello') 106 | cy.get('button').contains('Reset').click() 107 | cy.get('textarea').should('have.value', '') 108 | }) 109 | 110 | it('should finish the test when clicking finish button', () => { 111 | cy.get('textarea').type('Hello World') 112 | cy.get('button').contains('Finish').click() 113 | cy.contains('Your Results').should('be.visible') 114 | }) 115 | }) 116 | 117 | describe('UI Elements and Interactions', () => { 118 | it('should show different themes when theme toggle is clicked', () => { 119 | cy.get('[data-testid="theme-toggle"]').click() 120 | cy.get('body').should('have.class', 'dark-theme') 121 | 122 | cy.get('[data-testid="theme-toggle"]').click() 123 | cy.get('body').should('have.class', 'light-theme') 124 | }) 125 | 126 | it('should show settings panel when settings button is clicked', () => { 127 | cy.get('[data-testid="settings-button"]').click() 128 | cy.get('[data-testid="settings-panel"]').should('be.visible') 129 | 130 | // Test settings options 131 | cy.get('[data-testid="difficulty-select"]').should('exist') 132 | cy.get('[data-testid="time-select"]').should('exist') 133 | }) 134 | }) 135 | 136 | describe('Error Handling', () => { 137 | it('should handle network errors gracefully', () => { 138 | cy.intercept('GET', '/api/text', { 139 | statusCode: 500, 140 | body: { error: 'Server error' } 141 | }) 142 | 143 | cy.visit('/') 144 | cy.get('[data-testid="error-message"]').should('be.visible') 145 | }) 146 | 147 | it('should show appropriate message when no text is available', () => { 148 | cy.intercept('GET', '/api/text', { 149 | statusCode: 404, 150 | body: { error: 'No text available' } 151 | }) 152 | 153 | cy.visit('/') 154 | cy.get('[data-testid="no-text-message"]').should('be.visible') 155 | }) 156 | }) 157 | }) -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add('login', (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 26 | // 27 | // declare global { 28 | // namespace Cypress { 29 | // interface Chainable { 30 | // login(email: string, password: string): Chainable 31 | // drag(subject: string, options?: Partial): Chainable 32 | // dismiss(subject: string, options?: Partial): Chainable 33 | // visit(originalFn: CommandOriginalFn, url: string, options?: Partial): Chainable 34 | // } 35 | // } 36 | // } -------------------------------------------------------------------------------- /cypress/support/component.ts: -------------------------------------------------------------------------------- 1 | import '@cypress/code-coverage/support' 2 | import './commands' 3 | 4 | // Alternatively you can use CommonJS syntax: 5 | // require('./commands') -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | import '@cypress/code-coverage/support' 2 | import './commands' 3 | 4 | // Alternatively you can use CommonJS syntax: 5 | // require('./commands') -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Typing Practice App 7 | 8 | 13 | 14 | 15 | 16 | 17 | 21 | 25 | 26 | 27 | 28 | 29 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 44 | 45 | 46 | 47 |
48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typing-test", 3 | "private": true, 4 | "version": "1.7.4", 5 | "type": "module", 6 | "description": "A modern typing practice application to improve typing speed and accuracy with various topics and customizable settings", 7 | "keywords": [ 8 | "typing", 9 | "practice", 10 | "speed-test", 11 | "keyboard", 12 | "react", 13 | "typescript", 14 | "pwa" 15 | ], 16 | "author": { 17 | "name": "Sajal", 18 | "url": "https://github.com/ashsajal1" 19 | }, 20 | "license": "MIT", 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/ashsajal1/typing-app" 24 | }, 25 | "bugs": { 26 | "url": "https://github.com/ashsajal1/typing-app/issues" 27 | }, 28 | "homepage": "https://github.com/ashsajal1/typing-app#readme", 29 | "engines": { 30 | "node": ">=18.0.0" 31 | }, 32 | "scripts": { 33 | "dev": "vite", 34 | "build": "tsc && vite build", 35 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 36 | "preview": "vite preview", 37 | "generate-pwa-assets": "pwa-assets-generator --preset minimal public/logo.png", 38 | "storybook": "storybook dev -p 6006", 39 | "build-storybook": "storybook build", 40 | "test": "vitest", 41 | "test:ui": "vitest --ui", 42 | "test:coverage": "vitest run --coverage", 43 | "test:watch": "vitest watch", 44 | "cypress:open": "cypress open", 45 | "cypress:run": "cypress run", 46 | "cypress:component": "cypress run --component", 47 | "cypress:e2e": "cypress run --e2e", 48 | "cypress:coverage": "cypress run --env coverage=true", 49 | "release:prepare": "npm run test && npm run lint && npm run build", 50 | "release:version": "npm version", 51 | "release:deploy": "npm run build && npm run generate-pwa-assets" 52 | }, 53 | "dependencies": { 54 | "@hookform/resolvers": "^3.10.0", 55 | "@tanstack/react-router": "^1.97.1", 56 | "@types/react-syntax-highlighter": "^15.5.13", 57 | "chart.js": "^4.4.9", 58 | "framer-motion": "^12.15.0", 59 | "franc": "^6.2.0", 60 | "i18next": "^25.2.1", 61 | "i18next-browser-languagedetector": "^8.1.0", 62 | "lucide-react": "^0.390.0", 63 | "react": "^18.3.1", 64 | "react-chartjs-2": "^5.3.0", 65 | "react-dom": "^18.3.1", 66 | "react-helmet-async": "^2.0.5", 67 | "react-hook-form": "^7.54.2", 68 | "react-i18next": "^15.5.2", 69 | "react-syntax-highlighter": "^15.6.1", 70 | "zod": "^3.24.1", 71 | "zustand": "^4.5.6" 72 | }, 73 | "devDependencies": { 74 | "@chromatic-com/storybook": "^3", 75 | "@cypress/code-coverage": "^3.14.3", 76 | "@storybook/addon-essentials": "^8.6.14", 77 | "@storybook/addon-onboarding": "^8.6.14", 78 | "@storybook/blocks": "^8.6.14", 79 | "@storybook/experimental-addon-test": "^8.6.14", 80 | "@storybook/react": "^8.6.14", 81 | "@storybook/react-vite": "^8.6.14", 82 | "@storybook/test": "^8.6.14", 83 | "@tanstack/router-devtools": "^1.97.1", 84 | "@tanstack/router-vite-plugin": "^1.97.1", 85 | "@testing-library/jest-dom": "^6.6.3", 86 | "@testing-library/react": "^16.3.0", 87 | "@testing-library/user-event": "^14.6.1", 88 | "@types/node": "^20.17.14", 89 | "@types/react": "^18.3.18", 90 | "@types/react-dom": "^18.3.5", 91 | "@typescript-eslint/eslint-plugin": "^6.21.0", 92 | "@typescript-eslint/parser": "^6.21.0", 93 | "@vite-pwa/assets-generator": "^0.2.6", 94 | "@vitejs/plugin-react": "^4.3.4", 95 | "@vitest/browser": "^3.1.4", 96 | "@vitest/coverage-v8": "^3.1.4", 97 | "@vitest/ui": "^3.1.4", 98 | "autoprefixer": "^10.4.20", 99 | "cypress": "^14.4.0", 100 | "daisyui": "^4.12.23", 101 | "eslint": "^8.57.1", 102 | "eslint-plugin-react-hooks": "^4.6.2", 103 | "eslint-plugin-react-refresh": "^0.4.18", 104 | "eslint-plugin-storybook": "^0.12.0", 105 | "istanbul-lib-coverage": "^3.2.2", 106 | "jsdom": "^26.1.0", 107 | "nyc": "^17.1.0", 108 | "playwright": "^1.52.0", 109 | "postcss": "^8.5.1", 110 | "storybook": "^8.6.14", 111 | "tailwindcss": "^3.4.17", 112 | "typescript": "^5.7.3", 113 | "vite": "^5.4.11", 114 | "vite-plugin-pwa": "^0.20.5", 115 | "vitest": "^3.1.4" 116 | } 117 | } -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashsajal1/typing-app/bb465576eabd160b2412a633d108ac3943397d79/public/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /public/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashsajal1/typing-app/bb465576eabd160b2412a633d108ac3943397d79/public/cover.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashsajal1/typing-app/bb465576eabd160b2412a633d108ac3943397d79/public/favicon.ico -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashsajal1/typing-app/bb465576eabd160b2412a633d108ac3943397d79/public/logo.png -------------------------------------------------------------------------------- /public/maskable-icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashsajal1/typing-app/bb465576eabd160b2412a633d108ac3943397d79/public/maskable-icon-512x512.png -------------------------------------------------------------------------------- /public/preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashsajal1/typing-app/bb465576eabd160b2412a633d108ac3943397d79/public/preview.gif -------------------------------------------------------------------------------- /public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashsajal1/typing-app/bb465576eabd160b2412a633d108ac3943397d79/public/pwa-192x192.png -------------------------------------------------------------------------------- /public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashsajal1/typing-app/bb465576eabd160b2412a633d108ac3943397d79/public/pwa-512x512.png -------------------------------------------------------------------------------- /public/pwa-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashsajal1/typing-app/bb465576eabd160b2412a633d108ac3943397d79/public/pwa-64x64.png -------------------------------------------------------------------------------- /src/components/CommandPalette.tsx: -------------------------------------------------------------------------------- 1 | import { Command } from "../lib/commands"; 2 | 3 | interface CommandPaletteProps { 4 | showCommandPalette: boolean; 5 | commandSearch: string; 6 | onCommandSearchChange: (value: string) => void; 7 | onCommandSelect: (command: Command) => void; 8 | commands: Command[]; 9 | } 10 | 11 | export default function CommandPalette({ 12 | showCommandPalette, 13 | commandSearch, 14 | onCommandSearchChange, 15 | onCommandSelect, 16 | commands, 17 | }: CommandPaletteProps) { 18 | if (!showCommandPalette) return null; 19 | 20 | const filteredCommands = commands.filter(cmd => 21 | cmd.name.toLowerCase().includes(commandSearch.toLowerCase()) || 22 | cmd.description.toLowerCase().includes(commandSearch.toLowerCase()) 23 | ); 24 | 25 | return ( 26 |
27 |
28 |
29 | onCommandSearchChange(e.target.value)} 35 | autoFocus 36 | /> 37 | ⌘K 38 |
39 |
40 | {filteredCommands.map((cmd) => ( 41 | 54 | ))} 55 |
56 |
57 |
58 | ); 59 | } -------------------------------------------------------------------------------- /src/components/ControlsDisplay.tsx: -------------------------------------------------------------------------------- 1 | interface ControlsDisplayProps { 2 | showHighErrorChars: boolean; 3 | toggleHighErrorChars: () => void; 4 | onReset: () => void; 5 | onSubmit: () => void; 6 | } 7 | 8 | export default function ControlsDisplay({ 9 | showHighErrorChars, 10 | toggleHighErrorChars, 11 | onReset, 12 | onSubmit, 13 | }: ControlsDisplayProps) { 14 | return ( 15 |
16 |
17 | 26 |
27 |
28 | 38 | 39 | 49 |
50 |
51 | ); 52 | } -------------------------------------------------------------------------------- /src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { TypeIcon, Github, Twitter } from "lucide-react" 2 | import { Link } from "@tanstack/react-router" 3 | 4 | export default function Footer() { 5 | return ( 6 | <> 7 |
8 |
9 | {/* Brand Section */} 10 |
11 |
12 | 13 |
14 |

Typing Practice

15 |

Improve your typing skills

16 |
17 |
18 |
19 | 20 | {/* Practice Links */} 21 |
22 |
Practice
23 |
24 | Home 25 | Practice 26 | Code Practice 27 | Custom Text 28 |
29 |
30 | 31 | {/* Resources Links */} 32 |
33 |
Resources
34 |
35 | Typing Guide 36 | Statistics 37 | Saved Texts 38 | About 39 |
40 |
41 | 42 | {/* Legal & Connect Links */} 43 |
44 |
45 |
46 |
Legal
47 |
48 | Privacy Policy 49 | Terms of Service 50 |
51 |
52 |
53 |
Connect
54 | 74 |
75 |
76 |
77 |
78 |
79 | 80 |
81 |
82 |

Copyright © {new Date().getFullYear()} Typing Practice - All rights reserved

83 |
84 |
85 | 86 | ) 87 | } 88 | -------------------------------------------------------------------------------- /src/components/LanguageSwitcher.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from 'react-i18next'; 2 | import { Globe } from 'lucide-react'; 3 | 4 | const languages = [ 5 | { code: 'en', name: 'English' }, 6 | { code: 'es', name: 'Español' }, 7 | { code: 'fr', name: 'Français' }, 8 | { code: 'de', name: 'Deutsch' }, 9 | { code: 'ja', name: '日本語' }, 10 | { code: 'zh', name: '中文' }, 11 | { code: 'ko', name: '한국어' }, 12 | { code: 'ru', name: 'Русский' }, 13 | { code: 'ar', name: 'العربية' }, 14 | { code: 'hi', name: 'हिन्दी' }, 15 | { code: 'bn', name: 'বাংলা' } 16 | ]; 17 | 18 | const LanguageSwitcher = () => { 19 | const { i18n } = useTranslation(); 20 | 21 | const changeLanguage = (langCode: string) => { 22 | i18n.changeLanguage(langCode); 23 | }; 24 | 25 | const currentLanguage = languages.find(lang => lang.code === i18n.language) || languages[0]; 26 | 27 | return ( 28 |
29 |
30 | 31 | {currentLanguage.name} 32 |
33 |
    34 | {languages.map((lang) => ( 35 |
  • 36 | 47 |
  • 48 | ))} 49 |
50 |
51 | ); 52 | }; 53 | 54 | export default LanguageSwitcher; -------------------------------------------------------------------------------- /src/components/MistakeAlert.tsx: -------------------------------------------------------------------------------- 1 | interface MistakeAlertProps { 2 | show: boolean; 3 | incorrectNewlinePosition: number; 4 | } 5 | 6 | export default function MistakeAlert({ show, incorrectNewlinePosition }: MistakeAlertProps) { 7 | if (!show) return null; 8 | 9 | return ( 10 |
11 | {incorrectNewlinePosition !== -1 12 | ? "Press Backspace to remove the incorrect newline" 13 | : "Fix the mistake before continuing!"} 14 |
15 | ); 16 | } -------------------------------------------------------------------------------- /src/components/MobileInput.tsx: -------------------------------------------------------------------------------- 1 | interface MobileInputProps { 2 | userInput: string; 3 | onChange: (e: React.ChangeEvent) => void; 4 | onKeyDown: (e: React.KeyboardEvent) => void; 5 | } 6 | 7 | export default function MobileInput({ 8 | userInput, 9 | onChange, 10 | onKeyDown 11 | }: MobileInputProps) { 12 | return ( 13 |
14 |
15 | 35 |
36 | Type in the input field above 37 |
38 |
39 |
40 | ); 41 | } -------------------------------------------------------------------------------- /src/components/ModeToggle.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import ModeToggle from './ModeToggle'; 3 | import useMockThemeStore from '../store/__mocks__/themeStore'; 4 | import useStore from '../store/themeStore'; 5 | 6 | type StoreType = typeof useStore; 7 | 8 | declare global { 9 | interface Window { 10 | __MOCK_STORE__: StoreType; 11 | } 12 | } 13 | 14 | // Create a decorator to provide store context 15 | const StoreDecorator = (Story: React.ComponentType) => { 16 | window.__MOCK_STORE__ = useMockThemeStore as StoreType; 17 | return ; 18 | }; 19 | 20 | const meta = { 21 | title: 'Components/ModeToggle', 22 | component: ModeToggle, 23 | parameters: { 24 | layout: 'centered', 25 | }, 26 | tags: ['autodocs'], 27 | decorators: [StoreDecorator], 28 | argTypes: { 29 | onClick: { action: 'clicked' }, 30 | }, 31 | } satisfies Meta; 32 | 33 | export default meta; 34 | type Story = StoryObj; 35 | 36 | // Default story (light mode) 37 | export const LightMode: Story = { 38 | args: {}, 39 | }; 40 | 41 | // Dark mode story 42 | export const DarkMode: Story = { 43 | args: {}, 44 | parameters: { 45 | darkMode: { 46 | current: 'dark', 47 | }, 48 | }, 49 | }; 50 | 51 | // Interactive story 52 | export const Interactive: Story = { 53 | args: {}, 54 | play: async ({ canvasElement }) => { 55 | const button = canvasElement.querySelector('button'); 56 | if (button) { 57 | // Simulate a click to toggle the mode 58 | button.click(); 59 | } 60 | }, 61 | }; -------------------------------------------------------------------------------- /src/components/ModeToggle.tsx: -------------------------------------------------------------------------------- 1 | import useThemeStore, { themes } from '../store/themeStore'; 2 | import { MoonIcon, SunIcon, PaletteIcon } from 'lucide-react'; 3 | 4 | type ThemeStore = typeof useThemeStore; 5 | 6 | declare global { 7 | interface Window { 8 | __MOCK_STORE__: ThemeStore; 9 | } 10 | } 11 | 12 | const ModeToggle = () => { 13 | // Use mock store in Storybook, real store otherwise 14 | const store = window.__MOCK_STORE__ || useThemeStore; 15 | const { currentTheme, setTheme, toggleTheme } = store((state) => ({ 16 | currentTheme: state.currentTheme, 17 | setTheme: state.setTheme, 18 | toggleTheme: state.toggleTheme, 19 | })); 20 | 21 | return ( 22 |
23 | 30 | 31 |
32 | 35 |
    36 | {themes.map((theme) => ( 37 |
  • 38 | 50 |
  • 51 | ))} 52 |
53 |
54 |
55 | ); 56 | }; 57 | 58 | export default ModeToggle; 59 | -------------------------------------------------------------------------------- /src/components/Navbar.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import Navbar from './Navbar'; 3 | 4 | // Create a decorator that provides a mock router context 5 | const RouterDecorator = (Story: React.ComponentType) => ( 6 |
7 | 8 |
9 | ); 10 | 11 | const meta = { 12 | title: 'Components/Navbar', 13 | component: Navbar, 14 | parameters: { 15 | layout: 'fullscreen', 16 | }, 17 | tags: ['autodocs'], 18 | decorators: [RouterDecorator], 19 | } satisfies Meta; 20 | 21 | export default meta; 22 | type Story = StoryObj; 23 | 24 | // Default story 25 | export const Default: Story = { 26 | args: {}, 27 | }; 28 | 29 | // Story with dark mode 30 | export const DarkMode: Story = { 31 | args: {}, 32 | parameters: { 33 | darkMode: { 34 | current: 'dark', 35 | }, 36 | }, 37 | }; -------------------------------------------------------------------------------- /src/components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@tanstack/react-router"; 2 | import ModeToggle from "./ModeToggle"; 3 | import { Bookmark, BarChart2, Keyboard } from "lucide-react"; 4 | import { useTranslation } from 'react-i18next'; 5 | import LanguageSwitcher from './LanguageSwitcher'; 6 | 7 | export default function Navbar() { 8 | const { t } = useTranslation(); 9 | 10 | return ( 11 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/components/NonEnTyping.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect } from 'react'; 2 | import StatsDisplay from './StatsDisplay'; 3 | import ControlsDisplay from './ControlsDisplay'; 4 | import { franc } from 'franc'; 5 | 6 | interface NonEnTypingProps { 7 | text: string; 8 | } 9 | 10 | const NonEnTyping: React.FC = ({ text }) => { 11 | const [userInput, setUserInput] = useState(''); 12 | const [isStarted, setIsStarted] = useState(false); 13 | const [timer, setTimer] = useState(0); 14 | const [score, setScore] = useState(0); 15 | const [totalWords, setTotalWords] = useState(0); 16 | const [currentWordIndex, setCurrentWordIndex] = useState(0); 17 | const [isSubmitted, setIsSubmitted] = useState(false); 18 | const [wpm, setWpm] = useState(0); 19 | const [accuracy, setAccuracy] = useState(100); 20 | const [wordStatus, setWordStatus] = useState<{ [key: number]: boolean }>({}); 21 | const textRef = useRef(null); 22 | const timerRef = useRef(); 23 | 24 | // Detect language and form words accordingly 25 | const lang = franc(text || ''); 26 | const words = lang === 'ben' ? text.split(" ") : text.split(""); 27 | 28 | // Timer effect 29 | useEffect(() => { 30 | if (isStarted && !isSubmitted) { 31 | timerRef.current = setInterval(() => { 32 | setTimer(prev => prev + 1); 33 | }, 1000); 34 | } 35 | return () => { 36 | if (timerRef.current) { 37 | clearInterval(timerRef.current); 38 | } 39 | }; 40 | }, [isStarted, isSubmitted]); 41 | 42 | // WPM calculation effect 43 | useEffect(() => { 44 | if (timer > 0 && totalWords > 0) { 45 | const minutes = timer / 60; 46 | const newWpm = Math.round((score / minutes) * 5); // Multiply by 5 for standard WPM calculation 47 | setWpm(newWpm); 48 | } 49 | }, [timer, score, totalWords]); 50 | 51 | const handleInputChange = (e: React.ChangeEvent) => { 52 | if (!isStarted) { 53 | setIsStarted(true); 54 | } 55 | const newInput = e.target.value; 56 | setUserInput(newInput); 57 | 58 | // Check if input ends with a space 59 | if (newInput.endsWith(' ')) { 60 | const wordToCheck = newInput.trim(); 61 | if (wordToCheck) { 62 | checkWord(wordToCheck); 63 | } 64 | } 65 | }; 66 | 67 | const handleKeyDown = (e: React.KeyboardEvent) => { 68 | // Prevent default space behavior 69 | if (e.key === ' ') { 70 | e.preventDefault(); 71 | const wordToCheck = userInput.trim(); 72 | if (wordToCheck) { 73 | checkWord(wordToCheck); 74 | } 75 | } 76 | }; 77 | 78 | const checkWord = (wordToCheck: string) => { 79 | const currentWord = words[currentWordIndex]; 80 | const isCorrect = wordToCheck === currentWord; 81 | 82 | // Update word status 83 | setWordStatus(prev => ({ 84 | ...prev, 85 | [currentWordIndex]: isCorrect 86 | })); 87 | 88 | if (isCorrect) { 89 | setScore(prev => prev + 1); 90 | } 91 | 92 | setTotalWords(prev => prev + 1); 93 | setUserInput(''); 94 | setCurrentWordIndex(prev => prev + 1); 95 | 96 | // Calculate accuracy 97 | const newAccuracy = Math.round((score + (isCorrect ? 1 : 0)) / (totalWords + 1) * 100); 98 | setAccuracy(newAccuracy); 99 | 100 | // Scroll to next word 101 | if (textRef.current) { 102 | const wordElement = textRef.current.children[currentWordIndex + 1] as HTMLElement; 103 | if (wordElement) { 104 | wordElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); 105 | } 106 | } 107 | 108 | // Check if test is complete 109 | if (currentWordIndex + 1 >= words.length) { 110 | setIsSubmitted(true); 111 | if (timerRef.current) { 112 | clearInterval(timerRef.current); 113 | } 114 | } 115 | }; 116 | 117 | const getWordClass = (index: number) => { 118 | if (index < currentWordIndex) { 119 | return wordStatus[index] ? 'text-green-500' : 'text-red-500'; 120 | } 121 | if (index === currentWordIndex) { 122 | return 'bg-gray-200'; 123 | } 124 | return 'text-gray-400'; 125 | }; 126 | 127 | const handleSubmit = () => { 128 | setIsSubmitted(true); 129 | if (timerRef.current) { 130 | clearInterval(timerRef.current); 131 | } 132 | }; 133 | 134 | const handleReset = () => { 135 | window.location.reload(); 136 | }; 137 | 138 | if (isSubmitted) { 139 | return ( 140 |
141 |
142 |
143 |
Words Completed
144 |
{currentWordIndex}
145 |
Out of {words.length} words
146 |
147 | 148 |
149 |
WPM
150 |
{wpm}
151 |
152 | 153 |
154 |
Accuracy
155 |
{accuracy}%
156 |
157 |
158 |
159 | ); 160 | } 161 | 162 | return ( 163 |
164 | 169 | 170 |
174 | {words.map((word, index) => ( 175 | 179 | {word} 180 | 181 | ))} 182 |
183 | 184 |
185 | 194 |
195 | 196 | 202 | 203 | {}} 206 | onReset={handleReset} 207 | onSubmit={handleSubmit} 208 | /> 209 |
210 | ); 211 | } 212 | 213 | export default NonEnTyping; 214 | -------------------------------------------------------------------------------- /src/components/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@tanstack/react-router"; 2 | import { ArrowLeft, HomeIcon } from "lucide-react"; 3 | 4 | export default function NotFound() { 5 | return ( 6 |
7 |
8 |

404

9 |

The page you're looking for doesn't exist!

10 | 11 |
12 | 16 | 17 | 21 |
22 |
23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/components/Result.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import { describe, it, expect } from 'vitest'; 3 | import Result from './Result'; 4 | 5 | // Helper function to create test props 6 | const createTestProps = (accuracy: number, wpm: number, wpmHistory: number[] = []) => ({ 7 | accuracy, 8 | wpm, 9 | wpmHistory, 10 | currentErrorMap: new Map(), 11 | currentTotalErrors: 0 12 | }); 13 | 14 | describe('Result', () => { 15 | // Test different performance levels 16 | describe('performance levels', () => { 17 | it('shows excellent status for high performance', () => { 18 | render(); 19 | expect(screen.getByText('Excellent!')).toBeInTheDocument(); 20 | expect(screen.getByText("You're a typing master!")).toBeInTheDocument(); 21 | }); 22 | 23 | it('shows great job status for good performance', () => { 24 | render(); 25 | expect(screen.getByText('Great Job!')).toBeInTheDocument(); 26 | expect(screen.getByText("You're getting really good at this!")).toBeInTheDocument(); 27 | }); 28 | 29 | it('shows good effort status for average performance', () => { 30 | render(); 31 | expect(screen.getByText('Good Effort!')).toBeInTheDocument(); 32 | expect(screen.getByText('Nice work, keep practicing!')).toBeInTheDocument(); 33 | }); 34 | 35 | it('shows keep practicing status for low performance', () => { 36 | render(); 37 | expect(screen.getByText('Keep Practicing!')).toBeInTheDocument(); 38 | expect(screen.getByText("You'll improve with more practice!")).toBeInTheDocument(); 39 | }); 40 | }); 41 | 42 | // Test stats display 43 | describe('stats display', () => { 44 | it('displays correct accuracy', () => { 45 | render(); 46 | expect(screen.getByText('85%')).toBeInTheDocument(); 47 | }); 48 | 49 | it('displays correct WPM', () => { 50 | render(); 51 | expect(screen.getByText('50')).toBeInTheDocument(); 52 | }); 53 | 54 | it('calculates and displays error rate', () => { 55 | render(); 56 | expect(screen.getByText('15%')).toBeInTheDocument(); // 100 - 85 57 | }); 58 | 59 | it('calculates and displays words per error', () => { 60 | render(); 61 | expect(screen.getByText('2.0')).toBeInTheDocument(); // 40 / 20 62 | }); 63 | }); 64 | 65 | // Test star rating 66 | describe('star rating', () => { 67 | it('displays correct number of stars for high performance', () => { 68 | // accuracy + wpm = 95 + 65 = 160, 160/30 = 5.33, floor = 5 69 | render(); 70 | const filledStars = document.querySelectorAll('.text-yellow-300'); 71 | expect(filledStars.length).toBe(5); 72 | }); 73 | 74 | it('displays correct number of stars for medium performance', () => { 75 | // accuracy + wpm = 80 + 40 = 120, 120/30 = 4 76 | render(); 77 | const filledStars = document.querySelectorAll('.text-yellow-300'); 78 | expect(filledStars.length).toBe(4); 79 | }); 80 | 81 | it('displays correct number of stars for low performance', () => { 82 | // accuracy + wpm = 60 + 30 = 90, 90/30 = 3 83 | render(); 84 | const filledStars = document.querySelectorAll('.text-yellow-300'); 85 | expect(filledStars.length).toBe(3); 86 | }); 87 | 88 | it('displays minimum of 1 star', () => { 89 | // accuracy + wpm = 20 + 10 = 30, 30/30 = 1 90 | render(); 91 | const filledStars = document.querySelectorAll('.text-yellow-300'); 92 | expect(filledStars.length).toBe(1); 93 | }); 94 | }); 95 | 96 | // Test tips display 97 | describe('tips display', () => { 98 | it('shows appropriate tips for high performance', () => { 99 | render(); 100 | expect(screen.getByText('Try increasing your speed while maintaining accuracy')).toBeInTheDocument(); 101 | }); 102 | 103 | it('shows appropriate tips for low performance', () => { 104 | render(); 105 | expect(screen.getByText('Start with shorter texts')).toBeInTheDocument(); 106 | }); 107 | }); 108 | 109 | // Test chart rendering 110 | describe('WPM chart', () => { 111 | it('renders chart with correct data', () => { 112 | const wpmHistory = [40, 45, 50]; 113 | render(); 114 | expect(screen.getByText('WPM Progress')).toBeInTheDocument(); 115 | }); 116 | 117 | it('handles empty WPM history', () => { 118 | render(); 119 | expect(screen.getByText('WPM Progress')).toBeInTheDocument(); 120 | }); 121 | }); 122 | 123 | // Test action buttons 124 | describe('action buttons', () => { 125 | it('renders try again button', () => { 126 | render(); 127 | expect(screen.getByText('Try Again')).toBeInTheDocument(); 128 | }); 129 | 130 | it('renders restart test button', () => { 131 | render(); 132 | expect(screen.getByText('Restart Test')).toBeInTheDocument(); 133 | }); 134 | }); 135 | }); -------------------------------------------------------------------------------- /src/components/SEO.tsx: -------------------------------------------------------------------------------- 1 | import { Helmet } from 'react-helmet-async'; 2 | 3 | interface SEOProps { 4 | title: string; 5 | description: string; 6 | keywords?: string[]; 7 | ogImage?: string; 8 | ogType?: 'website' | 'article'; 9 | twitterCard?: 'summary' | 'summary_large_image'; 10 | } 11 | 12 | export function SEO({ 13 | title, 14 | description, 15 | keywords = [], 16 | ogImage = '/logo.png', 17 | ogType = 'website', 18 | twitterCard = 'summary' 19 | }: SEOProps) { 20 | const siteTitle = 'Typing Test'; 21 | const fullTitle = `${title} | ${siteTitle}`; 22 | const defaultKeywords = ['typing', 'practice', 'speed test', 'keyboard', 'typing practice']; 23 | const allKeywords = [...new Set([...defaultKeywords, ...keywords])].join(', '); 24 | 25 | return ( 26 | 27 | {/* Basic Meta Tags */} 28 | {fullTitle} 29 | 30 | 31 | 32 | {/* Open Graph Meta Tags */} 33 | 34 | 35 | 36 | 37 | 38 | 39 | {/* Twitter Meta Tags */} 40 | 41 | 42 | 43 | 44 | 45 | {/* Additional Meta Tags */} 46 | 47 | 48 | 49 | 50 | ); 51 | } -------------------------------------------------------------------------------- /src/components/StatsDisplay.tsx: -------------------------------------------------------------------------------- 1 | interface StatsDisplayProps { 2 | timer: number; 3 | eclipsedTime: number; 4 | accuracy: number; 5 | wpm: number; 6 | } 7 | 8 | export default function StatsDisplay({ 9 | timer, 10 | eclipsedTime, 11 | accuracy, 12 | wpm, 13 | }: StatsDisplayProps) { 14 | return ( 15 |
16 |
17 |
18 | 19 | 20 | 21 |
22 |
Time
23 |
{timer}s
24 | {eclipsedTime !== Infinity &&
of {eclipsedTime}s total
} 25 |
26 | 27 |
28 |
29 | 30 | 31 | 32 |
33 |
Accuracy
34 |
{accuracy}%
35 |
36 | 37 |
38 |
39 | 40 | 41 | 42 |
43 |
WPM
44 |
{wpm}
45 |
Words per minute
46 |
47 |
48 | ); 49 | } -------------------------------------------------------------------------------- /src/components/Timer.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import { describe, it, expect } from 'vitest'; 3 | import Timer from './Timer'; 4 | 5 | describe('Timer', () => { 6 | it('renders with correct time value', () => { 7 | const testTime = 42; 8 | render(); 9 | 10 | // Check if the time is displayed correctly 11 | expect(screen.getByText(testTime.toString())).toBeInTheDocument(); 12 | }); 13 | 14 | it('renders with clock icon', () => { 15 | render(); 16 | 17 | // Check if the clock icon is present 18 | const clockIcon = document.querySelector('svg'); 19 | expect(clockIcon).toBeInTheDocument(); 20 | expect(clockIcon).toHaveClass('h-4 w-4'); 21 | }); 22 | 23 | it('has correct styling classes', () => { 24 | render(); 25 | 26 | // Get the main container 27 | const container = screen.getByText('0').parentElement; 28 | 29 | // Check if it has the correct classes 30 | expect(container).toHaveClass( 31 | 'rounded', 32 | 'w-full', 33 | 'border', 34 | 'border-success', 35 | 'p-2', 36 | 'flex', 37 | 'items-center', 38 | 'gap-2', 39 | 'justify-start', 40 | 'text-success' 41 | ); 42 | }); 43 | 44 | it('renders with zero time', () => { 45 | render(); 46 | expect(screen.getByText('0')).toBeInTheDocument(); 47 | }); 48 | 49 | it('renders with large time value', () => { 50 | const largeTime = 999999; 51 | render(); 52 | expect(screen.getByText(largeTime.toString())).toBeInTheDocument(); 53 | }); 54 | }); -------------------------------------------------------------------------------- /src/components/Timer.tsx: -------------------------------------------------------------------------------- 1 | import { ClockIcon } from "lucide-react" 2 | 3 | export default function Timer({ time }: { time: number }) { 4 | return ( 5 |
6 | 7 | {time} 8 |
9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /src/components/TypingTest.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import TypingTest from './TypingTest'; 3 | 4 | const meta = { 5 | title: 'Components/TypingTest', 6 | component: TypingTest, 7 | parameters: { 8 | layout: 'centered', 9 | }, 10 | tags: ['autodocs'], 11 | argTypes: { 12 | text: { 13 | control: 'text', 14 | description: 'The text to type', 15 | }, 16 | eclipsedTime: { 17 | control: 'number', 18 | description: 'Time limit in seconds (Infinity for no limit)', 19 | }, 20 | }, 21 | } satisfies Meta; 22 | 23 | export default meta; 24 | type Story = StoryObj; 25 | 26 | // Basic story with a simple text 27 | export const Default: Story = { 28 | args: { 29 | text: 'The quick brown fox jumps over the lazy dog', 30 | eclipsedTime: Infinity, 31 | }, 32 | }; 33 | 34 | // Story with a time limit 35 | export const WithTimeLimit: Story = { 36 | args: { 37 | text: 'The quick brown fox jumps over the lazy dog', 38 | eclipsedTime: 30, 39 | }, 40 | }; 41 | 42 | // Story with text containing translations 43 | export const WithTranslations: Story = { 44 | args: { 45 | text: 'Hello [world](mundo) [how](como) are [you](tu)', 46 | eclipsedTime: Infinity, 47 | }, 48 | }; 49 | 50 | // Story with a longer text 51 | export const LongText: Story = { 52 | args: { 53 | text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.', 54 | eclipsedTime: Infinity, 55 | }, 56 | }; -------------------------------------------------------------------------------- /src/components/TypingTest.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, act } from '@testing-library/react'; 2 | import { describe, it, expect, vi, beforeEach } from 'vitest'; 3 | import userEvent from '@testing-library/user-event'; 4 | import TypingTest from './TypingTest'; 5 | 6 | describe('TypingTest', () => { 7 | const mockText = 'Hello world'; 8 | const mockEclipsedTime = 60; 9 | 10 | beforeEach(() => { 11 | // Reset any mocks or state before each test 12 | vi.useFakeTimers(); 13 | }); 14 | 15 | it('renders initial state correctly', () => { 16 | render(); 17 | 18 | // Check if the initial message is displayed 19 | expect(screen.getByText('Start typing to begin the test')).toBeInTheDocument(); 20 | 21 | // Check if the stats are initialized 22 | expect(screen.getByText('0s')).toBeInTheDocument(); 23 | expect(screen.getByText('0%')).toBeInTheDocument(); 24 | expect(screen.getByText('0')).toBeInTheDocument(); 25 | }); 26 | 27 | it('starts the test when user types', async () => { 28 | const user = userEvent.setup(); 29 | render(); 30 | 31 | // Type the first character 32 | await user.type(screen.getByPlaceholderText('Start typing...'), 'H'); 33 | 34 | // Check if the test has started 35 | expect(screen.queryByText('Start typing to begin the test')).not.toBeInTheDocument(); 36 | 37 | // Check if the timer has started 38 | act(() => { 39 | vi.advanceTimersByTime(1000); 40 | }); 41 | expect(screen.getByText('1s')).toBeInTheDocument(); 42 | }); 43 | 44 | it('handles correct typing', async () => { 45 | const user = userEvent.setup(); 46 | render(); 47 | 48 | // Type the correct text 49 | await user.type(screen.getByPlaceholderText('Start typing...'), 'Hello'); 50 | 51 | // Check if the characters are marked as correct 52 | const correctChars = screen.getAllByText(/[H|e|l|o]/); 53 | correctChars.forEach(char => { 54 | expect(char).toHaveClass('text-green-500'); 55 | }); 56 | }); 57 | 58 | it('handles incorrect typing', async () => { 59 | const user = userEvent.setup(); 60 | render(); 61 | 62 | // Type an incorrect character 63 | await user.type(screen.getByPlaceholderText('Start typing...'), 'X'); 64 | 65 | // Check if the character is marked as incorrect 66 | const incorrectChar = screen.getByText('X'); 67 | expect(incorrectChar).toHaveClass('text-red-500'); 68 | 69 | // Check if the mistake alert is shown 70 | expect(screen.getByText('Fix the mistake before continuing!')).toBeInTheDocument(); 71 | }); 72 | 73 | it('calculates WPM and accuracy correctly', async () => { 74 | const user = userEvent.setup(); 75 | render(); 76 | 77 | // Type the text 78 | await user.type(screen.getByPlaceholderText('Start typing...'), 'Hello world'); 79 | 80 | // Advance timer to calculate WPM 81 | act(() => { 82 | vi.advanceTimersByTime(60000); // 1 minute 83 | }); 84 | 85 | // Check if WPM is calculated (should be 2 words per minute) 86 | expect(screen.getByText('2')).toBeInTheDocument(); 87 | }); 88 | 89 | it('handles test completion', async () => { 90 | const user = userEvent.setup(); 91 | render(); 92 | 93 | // Type the complete text 94 | await user.type(screen.getByPlaceholderText('Start typing...'), 'Hello world'); 95 | 96 | // Click finish button 97 | await user.click(screen.getByText('Finish')); 98 | 99 | // Check if the Result component is rendered 100 | expect(screen.getByText('Words per minute')).toBeInTheDocument(); 101 | }); 102 | 103 | it('handles reset functionality', async () => { 104 | const user = userEvent.setup(); 105 | render(); 106 | 107 | // Type some text 108 | await user.type(screen.getByPlaceholderText('Start typing...'), 'Hello'); 109 | 110 | // Click reset button 111 | await user.click(screen.getByText('Reset')); 112 | 113 | // Check if the component is reset 114 | expect(screen.getByText('Start typing to begin the test')).toBeInTheDocument(); 115 | expect(screen.getByText('0s')).toBeInTheDocument(); 116 | }); 117 | 118 | it('handles keyboard shortcuts', async () => { 119 | const user = userEvent.setup(); 120 | render(); 121 | 122 | // Test Escape key 123 | await user.keyboard('{Escape}'); 124 | expect(screen.getByText('Start typing to begin the test')).toBeInTheDocument(); 125 | 126 | // Test Enter key after typing 127 | await user.type(screen.getByPlaceholderText('Start typing...'), 'Hello world'); 128 | await user.keyboard('{Enter}'); 129 | expect(screen.getByText('Words per minute')).toBeInTheDocument(); 130 | }); 131 | 132 | it('handles mobile input correctly', () => { 133 | // Mock window.innerWidth for mobile 134 | Object.defineProperty(window, 'innerWidth', { 135 | writable: true, 136 | configurable: true, 137 | value: 500, 138 | }); 139 | 140 | // Trigger resize event 141 | window.dispatchEvent(new Event('resize')); 142 | 143 | render(); 144 | 145 | // Check if mobile input is rendered 146 | expect(screen.getByText('Type in the input field above')).toBeInTheDocument(); 147 | }); 148 | }); -------------------------------------------------------------------------------- /src/components/TypingTest.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import Result from "./Result"; 3 | import TextDisplay from "./TextDisplay"; 4 | import StatsDisplay from "./StatsDisplay"; 5 | import ControlsDisplay from "./ControlsDisplay"; 6 | import CommandPalette from "./CommandPalette"; 7 | import MobileInput from "./MobileInput"; 8 | import MistakeAlert from "./MistakeAlert"; 9 | import { useTypingTest } from "../hooks/useTypingTest"; 10 | import { useKeyboardHandler } from "../hooks/useKeyboardHandler"; 11 | import { createCommands } from "../lib/commands"; 12 | 13 | // Add this at the top of the file, after the imports 14 | const typingAnimation = ` 15 | @keyframes typing { 16 | 0% { 17 | transform: scale(1); 18 | opacity: 0.5; 19 | } 20 | 50% { 21 | transform: scale(1.1); 22 | opacity: 1; 23 | } 24 | 100% { 25 | transform: scale(1); 26 | opacity: 0.5; 27 | } 28 | } 29 | 30 | .animate-typing { 31 | animation: typing 0.3s ease-in-out; 32 | } 33 | `; 34 | 35 | // Add this right after the typingAnimation constant 36 | const style = document.createElement('style'); 37 | style.textContent = typingAnimation; 38 | document.head.appendChild(style); 39 | 40 | export default function TypingTest({ 41 | text, 42 | eclipsedTime, 43 | }: { 44 | text: string; 45 | eclipsedTime?: number; 46 | }) { 47 | const [isMobile, setIsMobile] = useState(false); 48 | const [showCommandPalette, setShowCommandPalette] = useState(false); 49 | const [commandSearch, setCommandSearch] = useState(""); 50 | 51 | const { 52 | userInput, 53 | setUserInput, 54 | timer, 55 | isStarted, 56 | setIsStarted, 57 | accuracy, 58 | wpm, 59 | wpmHistory, 60 | isSubmitted, 61 | parsedText, 62 | mistakes, 63 | setMistakes, 64 | setTotalKeystrokes, 65 | hasMistake, 66 | setHasMistake, 67 | showMistakeAlert, 68 | setShowMistakeAlert, 69 | lastCorrectPosition, 70 | setLastCorrectPosition, 71 | lastTypedPosition, 72 | setLastTypedPosition, 73 | currentErrorMap, 74 | setCurrentErrorMap, 75 | incorrectNewlinePosition, 76 | setIncorrectNewlinePosition, 77 | showHighErrorChars, 78 | toggleHighErrorChars, 79 | highErrorChars, 80 | handleSubmit, 81 | resetTest, 82 | addError 83 | } = useTypingTest(text, eclipsedTime || Infinity); 84 | 85 | // Check if user is on mobile 86 | useEffect(() => { 87 | const checkMobile = () => { 88 | setIsMobile(window.innerWidth <= 768); 89 | }; 90 | checkMobile(); 91 | window.addEventListener('resize', checkMobile); 92 | return () => window.removeEventListener('resize', checkMobile); 93 | }, []); 94 | 95 | const handleKeyDown = useKeyboardHandler({ 96 | isMobile, 97 | isStarted, 98 | setIsStarted, 99 | showCommandPalette, 100 | setShowCommandPalette, 101 | setCommandSearch, 102 | handleSubmit, 103 | parsedText, 104 | hasMistake, 105 | userInput, 106 | lastCorrectPosition, 107 | incorrectNewlinePosition, 108 | setUserInput, 109 | setMistakes, 110 | setHasMistake, 111 | setShowMistakeAlert, 112 | setIncorrectNewlinePosition, 113 | setLastCorrectPosition, 114 | setLastTypedPosition, 115 | setTotalKeystrokes, 116 | setCurrentErrorMap, 117 | addError, 118 | resetTest 119 | }); 120 | 121 | const handleInputChange = (e: React.ChangeEvent) => { 122 | if (!isStarted) { 123 | setIsStarted(true); 124 | } 125 | setUserInput(e.target.value); 126 | }; 127 | 128 | const handleInputKeyDown = (e: React.KeyboardEvent) => { 129 | handleKeyDown(e as unknown as KeyboardEvent); 130 | }; 131 | 132 | if (isSubmitted) { 133 | return ( 134 |
135 | 142 |
143 | ); 144 | } 145 | 146 | return ( 147 |
148 | 152 | 153 | { 158 | cmd.action(); 159 | setShowCommandPalette(false); 160 | setCommandSearch(""); 161 | }} 162 | commands={createCommands(handleSubmit)} 163 | /> 164 | 165 | {eclipsedTime !== Infinity && ( 166 | 171 | )} 172 | 173 | {/* Mobile input field */} 174 | {isMobile && !isSubmitted && ( 175 | 180 | )} 181 | 182 |
183 | 192 | 202 |
203 | 204 | 210 | 211 | window.location.reload()} 215 | onSubmit={handleSubmit} 216 | /> 217 |
218 | ); 219 | } 220 | -------------------------------------------------------------------------------- /src/hooks/useSpeech.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react'; 2 | import { franc } from 'franc'; 3 | 4 | interface UseSpeechOptions { 5 | rate?: number; 6 | pitch?: number; 7 | volume?: number; 8 | } 9 | 10 | // Map franc language codes to speech synthesis language codes 11 | const languageMap: Record = { 12 | 'cmn': 'zh-CN', // Chinese 13 | 'jpn': 'ja-JP', // Japanese 14 | 'kor': 'ko-KR', // Korean 15 | 'ara': 'ar-SA', // Arabic 16 | 'rus': 'ru-RU', // Russian 17 | 'eng': 'en-US', // English 18 | 'fra': 'fr-FR', // French 19 | 'deu': 'de-DE', // German 20 | 'spa': 'es-ES', // Spanish 21 | 'ita': 'it-IT', // Italian 22 | 'por': 'pt-BR', // Portuguese 23 | 'hin': 'hi-IN', // Hindi 24 | 'ben': 'bn-BD', // Bengali (Bangladesh) 25 | 'bng': 'bn-BD', // Alternative code for Bengali 26 | 'bcl': 'bn-BD', // Bicolano (sometimes used for Bengali) 27 | 'tur': 'tr-TR', // Turkish 28 | 'nld': 'nl-NL', // Dutch 29 | 'pol': 'pl-PL', // Polish 30 | 'ukr': 'uk-UA', // Ukrainian 31 | 'heb': 'he-IL', // Hebrew 32 | 'swe': 'sv-SE', // Swedish 33 | 'nor': 'nb-NO', // Norwegian 34 | }; 35 | 36 | export const useSpeech = (options: UseSpeechOptions = {}) => { 37 | const [isSpeaking, setIsSpeaking] = useState(false); 38 | const [isPaused, setIsPaused] = useState(false); 39 | const [currentWord, setCurrentWord] = useState(''); 40 | 41 | const { rate = 1, pitch = 1, volume = 1 } = options; 42 | 43 | // Function to detect language from text using franc 44 | const detectLanguage = (text: string): string => { 45 | const detectedLang = franc(text, { minLength: 1 }); 46 | return languageMap[detectedLang] || 'en-US'; 47 | }; 48 | 49 | const speak = useCallback((text: string) => { 50 | if (!window.speechSynthesis) { 51 | console.error('Speech synthesis not supported'); 52 | return; 53 | } 54 | 55 | // Cancel any ongoing speech 56 | window.speechSynthesis.cancel(); 57 | 58 | const words = text.split(/\s+/); 59 | let currentIndex = 0; 60 | 61 | const speakNextWord = () => { 62 | if (currentIndex >= words.length) { 63 | setIsSpeaking(false); 64 | setCurrentWord(''); 65 | return; 66 | } 67 | 68 | const word = words[currentIndex]; 69 | setCurrentWord(word); 70 | 71 | const utterance = new SpeechSynthesisUtterance(word); 72 | const lang = detectLanguage(word); 73 | 74 | utterance.lang = lang; 75 | utterance.rate = rate; 76 | utterance.pitch = pitch; 77 | utterance.volume = volume; 78 | 79 | utterance.onend = () => { 80 | currentIndex++; 81 | if (!isPaused) { 82 | speakNextWord(); 83 | } 84 | }; 85 | 86 | window.speechSynthesis.speak(utterance); 87 | }; 88 | 89 | setIsSpeaking(true); 90 | setIsPaused(false); 91 | speakNextWord(); 92 | }, [rate, pitch, volume, isPaused]); 93 | 94 | const pause = useCallback(() => { 95 | if (window.speechSynthesis) { 96 | window.speechSynthesis.pause(); 97 | setIsPaused(true); 98 | } 99 | }, []); 100 | 101 | const resume = useCallback(() => { 102 | if (window.speechSynthesis) { 103 | window.speechSynthesis.resume(); 104 | setIsPaused(false); 105 | } 106 | }, []); 107 | 108 | const stop = useCallback(() => { 109 | if (window.speechSynthesis) { 110 | window.speechSynthesis.cancel(); 111 | setIsSpeaking(false); 112 | setIsPaused(false); 113 | setCurrentWord(''); 114 | } 115 | }, []); 116 | 117 | return { 118 | speak, 119 | pause, 120 | resume, 121 | stop, 122 | isSpeaking, 123 | isPaused, 124 | currentWord, 125 | }; 126 | }; -------------------------------------------------------------------------------- /src/hooks/useTypingTest.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, useEffect } from 'react'; 2 | import { useErrorStatsStore } from '../store/errorStatsStore'; 3 | 4 | interface TextWithTranslation { 5 | text: string; 6 | translation?: string; 7 | } 8 | 9 | export function useTypingTest(text: string, eclipsedTime?: number) { 10 | const [userInput, setUserInput] = useState(""); 11 | const [timer, setTimer] = useState(0); 12 | const [isStarted, setIsStarted] = useState(false); 13 | const [accuracy, setAccuracy] = useState(0); 14 | const [wpm, setWpm] = useState(0); 15 | const [wpmHistory, setWpmHistory] = useState([]); 16 | const [isSubmitted, setIsSubmitted] = useState(false); 17 | const [reload, setReload] = useState(false); 18 | const [textToPractice, setTextToPractice] = useState(text); 19 | const [parsedText, setParsedText] = useState([]); 20 | const [mistakes, setMistakes] = useState(0); 21 | const [totalKeystrokes, setTotalKeystrokes] = useState(0); 22 | const [hasMistake, setHasMistake] = useState(false); 23 | const [showMistakeAlert, setShowMistakeAlert] = useState(false); 24 | const [lastCorrectPosition, setLastCorrectPosition] = useState(-1); 25 | const [lastTypedPosition, setLastTypedPosition] = useState(-1); 26 | const [currentErrorMap, setCurrentErrorMap] = useState>(new Map()); 27 | const [incorrectNewlinePosition, setIncorrectNewlinePosition] = useState(-1); 28 | const { addError, getHighErrorChars, showHighErrorChars, toggleHighErrorChars } = useErrorStatsStore(); 29 | const highErrorChars = getHighErrorChars(); 30 | 31 | // Parse text with translations 32 | const parseTextWithTranslations = useCallback((text: string): TextWithTranslation[] => { 33 | const regex = /\[([^\]]+)\]\(([^)]+)\)/g; 34 | const parts: TextWithTranslation[] = []; 35 | let lastIndex = 0; 36 | let match; 37 | 38 | while ((match = regex.exec(text)) !== null) { 39 | if (match.index > lastIndex) { 40 | parts.push({ text: text.slice(lastIndex, match.index) }); 41 | } 42 | parts.push({ 43 | text: match[1], 44 | translation: match[2] 45 | }); 46 | lastIndex = match.index + match[0].length; 47 | } 48 | 49 | if (lastIndex < text.length) { 50 | parts.push({ text: text.slice(lastIndex) }); 51 | } 52 | 53 | return parts; 54 | }, []); 55 | 56 | useEffect(() => { 57 | const parsed = parseTextWithTranslations(textToPractice); 58 | setParsedText(parsed); 59 | }, [textToPractice, parseTextWithTranslations]); 60 | 61 | const handleSubmit = useCallback(() => { 62 | if (!isStarted) { 63 | setIsStarted(true); 64 | return; 65 | } 66 | 67 | const wordPerMinute = Math.round( 68 | userInput.split(" ").length / (timer / 60) 69 | ); 70 | setWpm(Number.isFinite(wordPerMinute) ? wordPerMinute : 0); 71 | 72 | const finalAccuracy = totalKeystrokes > 0 ? 73 | Math.round(((totalKeystrokes - mistakes) / totalKeystrokes) * 100) : 0; 74 | setAccuracy(Number.isFinite(finalAccuracy) ? finalAccuracy : 0); 75 | 76 | setIsSubmitted(true); 77 | }, [isStarted, timer, userInput, mistakes, totalKeystrokes]); 78 | 79 | useEffect(() => { 80 | if (reload) { 81 | setTextToPractice(textToPractice + " " + text); 82 | setReload(false); 83 | } 84 | }, [reload, textToPractice, text]); 85 | 86 | useEffect(() => { 87 | const isReload = userInput.length === textToPractice.length; 88 | if (isReload) { 89 | setReload(true); 90 | } 91 | }, [reload, textToPractice.length, userInput.length]); 92 | 93 | useEffect(() => { 94 | if (isStarted && !isSubmitted) { 95 | const interval = setInterval(() => { 96 | setTimer((prevTimer) => prevTimer + 1); 97 | }, 1000); 98 | 99 | return () => clearInterval(interval); 100 | } 101 | }, [isStarted, isSubmitted]); 102 | 103 | useEffect(() => { 104 | if (isStarted && !isSubmitted) { 105 | const wordPerMinute = Math.round( 106 | userInput.split(" ").length / (timer / 60) 107 | ); 108 | const currentWpm = Number.isFinite(wordPerMinute) ? wordPerMinute : 0; 109 | setWpm(currentWpm); 110 | 111 | setWpmHistory(prev => [...prev, currentWpm]); 112 | 113 | const accuracy = totalKeystrokes > 0 ? 114 | Math.round(((totalKeystrokes - mistakes) / totalKeystrokes) * 100) : 0; 115 | setAccuracy(Number.isFinite(accuracy) ? accuracy : 0); 116 | } 117 | }, [textToPractice, timer, userInput, isStarted, isSubmitted, mistakes, totalKeystrokes]); 118 | 119 | useEffect(() => { 120 | if (!(eclipsedTime === 0) && eclipsedTime && timer === eclipsedTime) { 121 | handleSubmit(); 122 | setIsSubmitted(true); 123 | } 124 | }, [eclipsedTime, handleSubmit, timer]); 125 | 126 | const resetTest = useCallback(() => { 127 | setUserInput(""); 128 | setTimer(0); 129 | setIsStarted(false); 130 | setAccuracy(0); 131 | setWpm(0); 132 | setIsSubmitted(false); 133 | setReload(false); 134 | setTextToPractice(text); 135 | setMistakes(0); 136 | setTotalKeystrokes(0); 137 | setHasMistake(false); 138 | setShowMistakeAlert(false); 139 | setLastCorrectPosition(-1); 140 | setLastTypedPosition(-1); 141 | setIncorrectNewlinePosition(-1); 142 | }, [text]); 143 | 144 | return { 145 | userInput, 146 | setUserInput, 147 | timer, 148 | isStarted, 149 | setIsStarted, 150 | accuracy, 151 | wpm, 152 | wpmHistory, 153 | isSubmitted, 154 | parsedText, 155 | mistakes, 156 | setMistakes, 157 | totalKeystrokes, 158 | setTotalKeystrokes, 159 | hasMistake, 160 | setHasMistake, 161 | showMistakeAlert, 162 | setShowMistakeAlert, 163 | lastCorrectPosition, 164 | setLastCorrectPosition, 165 | lastTypedPosition, 166 | setLastTypedPosition, 167 | currentErrorMap, 168 | setCurrentErrorMap, 169 | incorrectNewlinePosition, 170 | setIncorrectNewlinePosition, 171 | showHighErrorChars, 172 | toggleHighErrorChars, 173 | highErrorChars, 174 | handleSubmit, 175 | resetTest, 176 | addError 177 | }; 178 | } -------------------------------------------------------------------------------- /src/i18n/i18n.ts: -------------------------------------------------------------------------------- 1 | import i18n from 'i18next'; 2 | import { initReactI18next } from 'react-i18next'; 3 | import LanguageDetector from 'i18next-browser-languagedetector'; 4 | 5 | // Import translations 6 | import enTranslation from './locales/en.json'; 7 | import esTranslation from './locales/es.json'; 8 | import frTranslation from './locales/fr.json'; 9 | import deTranslation from './locales/de.json'; 10 | import jaTranslation from './locales/ja.json'; 11 | import zhTranslation from './locales/zh.json'; 12 | import koTranslation from './locales/ko.json'; 13 | import ruTranslation from './locales/ru.json'; 14 | import arTranslation from './locales/ar.json'; 15 | import hiTranslation from './locales/hi.json'; 16 | import bnTranslation from './locales/bn.json'; 17 | 18 | i18n 19 | .use(LanguageDetector) 20 | .use(initReactI18next) 21 | .init({ 22 | resources: { 23 | en: { 24 | translation: enTranslation, 25 | }, 26 | es: { 27 | translation: esTranslation, 28 | }, 29 | fr: { 30 | translation: frTranslation, 31 | }, 32 | de: { 33 | translation: deTranslation, 34 | }, 35 | ja: { 36 | translation: jaTranslation, 37 | }, 38 | zh: { 39 | translation: zhTranslation, 40 | }, 41 | ko: { 42 | translation: koTranslation, 43 | }, 44 | ru: { 45 | translation: ruTranslation, 46 | }, 47 | ar: { 48 | translation: arTranslation, 49 | }, 50 | hi: { 51 | translation: hiTranslation, 52 | }, 53 | bn: { 54 | translation: bnTranslation, 55 | }, 56 | }, 57 | fallbackLng: 'en', 58 | interpolation: { 59 | escapeValue: false, 60 | }, 61 | detection: { 62 | order: ['localStorage', 'navigator'], 63 | caches: ['localStorage'], 64 | }, 65 | }); 66 | 67 | export default i18n; -------------------------------------------------------------------------------- /src/i18n/locales/ar.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "start": "ابدأ", 4 | "stop": "إيقاف", 5 | "reset": "إعادة تعيين", 6 | "settings": "الإعدادات", 7 | "statistics": "الإحصائيات", 8 | "practice": "ممارسة الكتابة", 9 | "improveTyping": "حسّن سرعة ودقة كتابتك", 10 | "wpm": "كلمة في الدقيقة", 11 | "accuracy": "الدقة", 12 | "time": "الوقت", 13 | "characters": "الحروف", 14 | "errors": "الأخطاء", 15 | "selectTopic": "اختر الموضوع", 16 | "chooseTopic": "اختر موضوعًا للتدرب عليه", 17 | "practiceDuration": "مدة التدريب", 18 | "selectDuration": "اختر مدة التدريب", 19 | "seconds": "ثانية", 20 | "minute": "دقيقة", 21 | "minutes": "دقائق", 22 | "noTimeLimit": "لا يوجد حد زمني", 23 | "startPractice": "ابدأ التدريب", 24 | "savedText": "النص المحفوظ", 25 | "createCustomText": "إنشاء نص مخصص", 26 | "tip": "نصيحة: ابدأ بمدد أقصر وزدها تدريجياً مع تحسنك" 27 | }, 28 | "settings": { 29 | "theme": "السمة", 30 | "language": "اللغة", 31 | "sound": "الصوت", 32 | "difficulty": "الصعوبة", 33 | "timeLimit": "الحد الزمني", 34 | "customText": "نص مخصص" 35 | }, 36 | "difficulty": { 37 | "easy": "سهل", 38 | "medium": "متوسط", 39 | "hard": "صعب", 40 | "custom": "مخصص" 41 | }, 42 | "statistics": { 43 | "bestWpm": "أفضل كلمة في الدقيقة", 44 | "averageWpm": "متوسط كلمة في الدقيقة", 45 | "totalPracticeTime": "إجمالي وقت التدريب", 46 | "totalCharacters": "إجمالي الحروف", 47 | "totalErrors": "إجمالي الأخطاء" 48 | } 49 | } -------------------------------------------------------------------------------- /src/i18n/locales/bn.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "start": "শুরু করুন", 4 | "stop": "বন্ধ করুন", 5 | "reset": "রিসেট করুন", 6 | "settings": "সেটিংস", 7 | "statistics": "পরিসংখ্যান", 8 | "practice": "টাইপিং অনুশীলন", 9 | "improveTyping": "আপনার টাইপিং গতি এবং নির্ভুলতা উন্নত করুন", 10 | "wpm": "শব্দ প্রতি মিনিট", 11 | "accuracy": "নির্ভুলতা", 12 | "time": "সময়", 13 | "characters": "অক্ষর", 14 | "errors": "ত্রুটি", 15 | "selectTopic": "বিষয় নির্বাচন করুন", 16 | "chooseTopic": "অনুশীলনের জন্য একটি বিষয় নির্বাচন করুন", 17 | "practiceDuration": "অনুশীলনের সময়কাল", 18 | "selectDuration": "অনুশীলনের সময়কাল নির্বাচন করুন", 19 | "seconds": "সেকেন্ড", 20 | "minute": "মিনিট", 21 | "minutes": "মিনিট", 22 | "noTimeLimit": "সময় সীমা নেই", 23 | "startPractice": "অনুশীলন শুরু করুন", 24 | "savedText": "সংরক্ষিত টেক্সট", 25 | "createCustomText": "কাস্টম টেক্সট তৈরি করুন", 26 | "tip": "পরামর্শ: ছোট সময়কাল দিয়ে শুরু করুন এবং আপনার উন্নতির সাথে সাথে ধীরে ধীরে বাড়ান" 27 | }, 28 | "settings": { 29 | "theme": "থিম", 30 | "language": "ভাষা", 31 | "sound": "সাউন্ড", 32 | "difficulty": "কঠিনতা", 33 | "timeLimit": "সময় সীমা", 34 | "customText": "কাস্টম টেক্সট" 35 | }, 36 | "difficulty": { 37 | "easy": "সহজ", 38 | "medium": "মাঝারি", 39 | "hard": "কঠিন", 40 | "custom": "কাস্টম" 41 | }, 42 | "statistics": { 43 | "bestWpm": "সেরা WPM", 44 | "averageWpm": "গড় WPM", 45 | "totalPracticeTime": "মোট অনুশীলনের সময়", 46 | "totalCharacters": "মোট অক্ষর", 47 | "totalErrors": "মোট ত্রুটি" 48 | } 49 | } -------------------------------------------------------------------------------- /src/i18n/locales/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "start": "Starten", 4 | "stop": "Stoppen", 5 | "reset": "Zurücksetzen", 6 | "settings": "Einstellungen", 7 | "statistics": "Statistiken", 8 | "practice": "Tipp-Übung", 9 | "improveTyping": "Verbessern Sie Ihre Tippgeschwindigkeit und -genauigkeit", 10 | "wpm": "WPM", 11 | "accuracy": "Genauigkeit", 12 | "time": "Zeit", 13 | "characters": "Zeichen", 14 | "errors": "Fehler", 15 | "selectTopic": "Thema auswählen", 16 | "chooseTopic": "Wählen Sie ein Thema zum Üben", 17 | "practiceDuration": "Übungsdauer", 18 | "selectDuration": "Übungsdauer auswählen", 19 | "seconds": "Sekunden", 20 | "minute": "Minute", 21 | "minutes": "Minuten", 22 | "noTimeLimit": "Keine Zeitbegrenzung", 23 | "startPractice": "Übung starten", 24 | "savedText": "Gespeicherter Text", 25 | "createCustomText": "Benutzerdefinierten Text erstellen", 26 | "tip": "Tipp: Beginnen Sie mit kürzeren Dauern und erhöhen Sie diese schrittweise, wenn Sie sich verbessern" 27 | }, 28 | "settings": { 29 | "theme": "Thema", 30 | "language": "Sprache", 31 | "sound": "Ton", 32 | "difficulty": "Schwierigkeitsgrad", 33 | "timeLimit": "Zeitlimit", 34 | "customText": "Benutzerdefinierter Text" 35 | }, 36 | "difficulty": { 37 | "easy": "Leicht", 38 | "medium": "Mittel", 39 | "hard": "Schwer", 40 | "custom": "Benutzerdefiniert" 41 | }, 42 | "statistics": { 43 | "bestWpm": "Beste WPM", 44 | "averageWpm": "Durchschnittliche WPM", 45 | "totalPracticeTime": "Gesamte Übungszeit", 46 | "totalCharacters": "Gesamtzeichen", 47 | "totalErrors": "Gesamtfehler" 48 | } 49 | } -------------------------------------------------------------------------------- /src/i18n/locales/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "start": "Start", 4 | "stop": "Stop", 5 | "reset": "Reset", 6 | "settings": "Settings", 7 | "statistics": "Statistics", 8 | "practice": "Typing Practice", 9 | "improveTyping": "Improve your typing speed and accuracy", 10 | "wpm": "WPM", 11 | "accuracy": "Accuracy", 12 | "time": "Time", 13 | "characters": "Characters", 14 | "errors": "Errors", 15 | "selectTopic": "Select Topic", 16 | "chooseTopic": "Choose a topic to practice", 17 | "practiceDuration": "Practice Duration", 18 | "selectDuration": "Select practice duration", 19 | "seconds": "seconds", 20 | "minute": "minute", 21 | "minutes": "minutes", 22 | "noTimeLimit": "No time limit", 23 | "startPractice": "Start Practice", 24 | "savedText": "Saved Text", 25 | "createCustomText": "Create Custom Text", 26 | "tip": "Tip: Start with shorter durations and gradually increase as you improve" 27 | }, 28 | "settings": { 29 | "theme": "Theme", 30 | "language": "Language", 31 | "sound": "Sound", 32 | "difficulty": "Difficulty", 33 | "timeLimit": "Time Limit", 34 | "customText": "Custom Text" 35 | }, 36 | "difficulty": { 37 | "easy": "Easy", 38 | "medium": "Medium", 39 | "hard": "Hard", 40 | "custom": "Custom" 41 | }, 42 | "statistics": { 43 | "bestWpm": "Best WPM", 44 | "averageWpm": "Average WPM", 45 | "totalPracticeTime": "Total Practice Time", 46 | "totalCharacters": "Total Characters", 47 | "totalErrors": "Total Errors" 48 | } 49 | } -------------------------------------------------------------------------------- /src/i18n/locales/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "start": "Comenzar", 4 | "stop": "Detener", 5 | "reset": "Reiniciar", 6 | "settings": "Configuración", 7 | "statistics": "Estadísticas", 8 | "practice": "Práctica de Escritura", 9 | "improveTyping": "Mejora tu velocidad y precisión al escribir", 10 | "wpm": "PPM", 11 | "accuracy": "Precisión", 12 | "time": "Tiempo", 13 | "characters": "Caracteres", 14 | "errors": "Errores", 15 | "selectTopic": "Seleccionar Tema", 16 | "chooseTopic": "Elige un tema para practicar", 17 | "practiceDuration": "Duración de la Práctica", 18 | "selectDuration": "Selecciona la duración", 19 | "seconds": "segundos", 20 | "minute": "minuto", 21 | "minutes": "minutos", 22 | "noTimeLimit": "Sin límite de tiempo", 23 | "startPractice": "Comenzar Práctica", 24 | "savedText": "Textos Guardados", 25 | "createCustomText": "Crear Texto Personalizado", 26 | "tip": "Consejo: Comienza con duraciones más cortas y aumenta gradualmente mientras mejoras" 27 | }, 28 | "settings": { 29 | "theme": "Tema", 30 | "language": "Idioma", 31 | "sound": "Sonido", 32 | "difficulty": "Dificultad", 33 | "timeLimit": "Límite de Tiempo", 34 | "customText": "Texto Personalizado" 35 | }, 36 | "difficulty": { 37 | "easy": "Fácil", 38 | "medium": "Medio", 39 | "hard": "Difícil", 40 | "custom": "Personalizado" 41 | }, 42 | "statistics": { 43 | "bestWpm": "Mejor PPM", 44 | "averageWpm": "PPM Promedio", 45 | "totalPracticeTime": "Tiempo Total de Práctica", 46 | "totalCharacters": "Total de Caracteres", 47 | "totalErrors": "Total de Errores" 48 | } 49 | } -------------------------------------------------------------------------------- /src/i18n/locales/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "start": "Démarrer", 4 | "stop": "Arrêter", 5 | "reset": "Réinitialiser", 6 | "settings": "Paramètres", 7 | "statistics": "Statistiques", 8 | "practice": "Pratique de dactylographie", 9 | "improveTyping": "Améliorez votre vitesse et votre précision de frappe", 10 | "wpm": "Mots par minute", 11 | "accuracy": "Précision", 12 | "time": "Temps", 13 | "characters": "Caractères", 14 | "errors": "Erreurs", 15 | "selectTopic": "Sélectionner un sujet", 16 | "chooseTopic": "Choisissez un sujet à pratiquer", 17 | "practiceDuration": "Durée de la pratique", 18 | "selectDuration": "Sélectionnez la durée de la pratique", 19 | "seconds": "secondes", 20 | "minute": "minute", 21 | "minutes": "minutes", 22 | "noTimeLimit": "Pas de limite de temps", 23 | "startPractice": "Commencer la pratique", 24 | "savedText": "Texte enregistré", 25 | "createCustomText": "Créer un texte personnalisé", 26 | "tip": "Astuce : Commencez par des durées plus courtes et augmentez progressivement à mesure que vous vous améliorez" 27 | }, 28 | "settings": { 29 | "theme": "Thème", 30 | "language": "Langue", 31 | "sound": "Son", 32 | "difficulty": "Difficulté", 33 | "timeLimit": "Limite de temps", 34 | "customText": "Texte personnalisé" 35 | }, 36 | "difficulty": { 37 | "easy": "Facile", 38 | "medium": "Moyen", 39 | "hard": "Difficile", 40 | "custom": "Personnalisé" 41 | }, 42 | "statistics": { 43 | "bestWpm": "Meilleur WPM", 44 | "averageWpm": "WPM moyen", 45 | "totalPracticeTime": "Temps total de pratique", 46 | "totalCharacters": "Total de caractères", 47 | "totalErrors": "Total d'erreurs" 48 | } 49 | } -------------------------------------------------------------------------------- /src/i18n/locales/hi.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "start": "शुरू करें", 4 | "stop": "रोकें", 5 | "reset": "रीसेट करें", 6 | "settings": "सेटिंग्स", 7 | "statistics": "आँकड़े", 8 | "practice": "टाइपिंग अभ्यास", 9 | "improveTyping": "अपनी टाइपिंग गति और सटीकता में सुधार करें", 10 | "wpm": "शब्द प्रति मिनट", 11 | "accuracy": "शुद्धता", 12 | "time": "समय", 13 | "characters": "वर्ण", 14 | "errors": "त्रुटियां", 15 | "selectTopic": "विषय चुनें", 16 | "chooseTopic": "अभ्यास करने के लिए एक विषय चुनें", 17 | "practiceDuration": "अभ्यास की अवधि", 18 | "selectDuration": "अभ्यास की अवधि चुनें", 19 | "seconds": "सेकंड", 20 | "minute": "मिनट", 21 | "minutes": "मिनट", 22 | "noTimeLimit": "कोई समय सीमा नहीं", 23 | "startPractice": "अभ्यास शुरू करें", 24 | "savedText": "सहेजा गया पाठ", 25 | "createCustomText": "कस्टम पाठ बनाएँ", 26 | "tip": "टिप: छोटी अवधि से शुरू करें और जैसे-जैसे आप सुधार करते हैं, धीरे-धीरे बढ़ाएँ" 27 | }, 28 | "settings": { 29 | "theme": "थीम", 30 | "language": "भाषा", 31 | "sound": "ध्वनि", 32 | "difficulty": "कठिनाई", 33 | "timeLimit": "समय सीमा", 34 | "customText": "कस्टम पाठ" 35 | }, 36 | "difficulty": { 37 | "easy": "आसान", 38 | "medium": "मध्यम", 39 | "hard": "कठिन", 40 | "custom": "कस्टम" 41 | }, 42 | "statistics": { 43 | "bestWpm": "सर्वश्रेष्ठ WPM", 44 | "averageWpm": "औसत WPM", 45 | "totalPracticeTime": "कुल अभ्यास समय", 46 | "totalCharacters": "कुल वर्ण", 47 | "totalErrors": "कुल त्रुटियां" 48 | } 49 | } -------------------------------------------------------------------------------- /src/i18n/locales/ja.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "start": "開始", 4 | "stop": "停止", 5 | "reset": "リセット", 6 | "settings": "設定", 7 | "statistics": "統計", 8 | "practice": "タイピング練習", 9 | "improveTyping": "タイピングの速度と精度を向上させる", 10 | "wpm": "WPM", 11 | "accuracy": "正確性", 12 | "time": "時間", 13 | "characters": "文字", 14 | "errors": "エラー", 15 | "selectTopic": "トピックを選択", 16 | "chooseTopic": "練習するトピックを選択してください", 17 | "practiceDuration": "練習時間", 18 | "selectDuration": "練習時間を選択", 19 | "seconds": "秒", 20 | "minute": "分", 21 | "minutes": "分", 22 | "noTimeLimit": "時間制限なし", 23 | "startPractice": "練習を開始", 24 | "savedText": "保存されたテキスト", 25 | "createCustomText": "カスタムテキストを作成", 26 | "tip": "ヒント:短い時間から始めて、上達するにつれて徐々に増やしてください" 27 | }, 28 | "settings": { 29 | "theme": "テーマ", 30 | "language": "言語", 31 | "sound": "サウンド", 32 | "difficulty": "難易度", 33 | "timeLimit": "時間制限", 34 | "customText": "カスタムテキスト" 35 | }, 36 | "difficulty": { 37 | "easy": "簡単", 38 | "medium": "中", 39 | "hard": "難しい", 40 | "custom": "カスタム" 41 | }, 42 | "statistics": { 43 | "bestWpm": "最高WPM", 44 | "averageWpm": "平均WPM", 45 | "totalPracticeTime": "総練習時間", 46 | "totalCharacters": "総文字数", 47 | "totalErrors": "総エラー数" 48 | } 49 | } -------------------------------------------------------------------------------- /src/i18n/locales/ko.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "start": "시작", 4 | "stop": "정지", 5 | "reset": "초기화", 6 | "settings": "설정", 7 | "statistics": "통계", 8 | "practice": "타이핑 연습", 9 | "improveTyping": "타이핑 속도와 정확도를 향상시키세요", 10 | "wpm": "WPM", 11 | "accuracy": "정확도", 12 | "time": "시간", 13 | "characters": "문자", 14 | "errors": "오류", 15 | "selectTopic": "주제 선택", 16 | "chooseTopic": "연습할 주제를 선택하세요", 17 | "practiceDuration": "연습 시간", 18 | "selectDuration": "연습 시간 선택", 19 | "seconds": "초", 20 | "minute": "분", 21 | "minutes": "분", 22 | "noTimeLimit": "시간 제한 없음", 23 | "startPractice": "연습 시작", 24 | "savedText": "저장된 텍스트", 25 | "createCustomText": "사용자 지정 텍스트 생성", 26 | "tip": "팁: 짧은 시간부터 시작하여 실력이 향상됨에 따라 점차 늘려나가세요" 27 | }, 28 | "settings": { 29 | "theme": "테마", 30 | "language": "언어", 31 | "sound": "사운드", 32 | "difficulty": "난이도", 33 | "timeLimit": "시간 제한", 34 | "customText": "사용자 지정 텍스트" 35 | }, 36 | "difficulty": { 37 | "easy": "쉬움", 38 | "medium": "보통", 39 | "hard": "어려움", 40 | "custom": "사용자 지정" 41 | }, 42 | "statistics": { 43 | "bestWpm": "최고 WPM", 44 | "averageWpm": "평균 WPM", 45 | "totalPracticeTime": "총 연습 시간", 46 | "totalCharacters": "총 문자 수", 47 | "totalErrors": "총 오류 수" 48 | } 49 | } -------------------------------------------------------------------------------- /src/i18n/locales/ru.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "start": "Начать", 4 | "stop": "Остановить", 5 | "reset": "Сброс", 6 | "settings": "Настройки", 7 | "statistics": "Статистика", 8 | "practice": "Практика печати", 9 | "improveTyping": "Улучшите свою скорость и точность печати", 10 | "wpm": "Символов в минуту", 11 | "accuracy": "Точность", 12 | "time": "Время", 13 | "characters": "Символы", 14 | "errors": "Ошибки", 15 | "selectTopic": "Выбрать тему", 16 | "chooseTopic": "Выберите тему для практики", 17 | "practiceDuration": "Длительность практики", 18 | "selectDuration": "Выберите длительность практики", 19 | "seconds": "секунд", 20 | "minute": "минута", 21 | "minutes": "минуты", 22 | "noTimeLimit": "Без ограничения по времени", 23 | "startPractice": "Начать практику", 24 | "savedText": "Сохраненный текст", 25 | "createCustomText": "Создать собственный текст", 26 | "tip": "Совет: Начните с более коротких продолжительностей и постепенно увеличивайте по мере улучшения" 27 | }, 28 | "settings": { 29 | "theme": "Тема", 30 | "language": "Язык", 31 | "sound": "Звук", 32 | "difficulty": "Сложность", 33 | "timeLimit": "Лимит времени", 34 | "customText": "Пользовательский текст" 35 | }, 36 | "difficulty": { 37 | "easy": "Легко", 38 | "medium": "Средне", 39 | "hard": "Сложно", 40 | "custom": "Пользовательский" 41 | }, 42 | "statistics": { 43 | "bestWpm": "Лучший WPM", 44 | "averageWpm": "Средний WPM", 45 | "totalPracticeTime": "Общее время практики", 46 | "totalCharacters": "Всего символов", 47 | "totalErrors": "Всего ошибок" 48 | } 49 | } -------------------------------------------------------------------------------- /src/i18n/locales/zh.json: -------------------------------------------------------------------------------- 1 | { 2 | "common": { 3 | "start": "开始", 4 | "stop": "停止", 5 | "reset": "重置", 6 | "settings": "设置", 7 | "statistics": "统计", 8 | "practice": "打字练习", 9 | "improveTyping": "提高您的打字速度和准确性", 10 | "wpm": "WPM", 11 | "accuracy": "准确率", 12 | "time": "时间", 13 | "characters": "字符", 14 | "errors": "错误", 15 | "selectTopic": "选择主题", 16 | "chooseTopic": "选择一个主题进行练习", 17 | "practiceDuration": "练习时长", 18 | "selectDuration": "选择练习时长", 19 | "seconds": "秒", 20 | "minute": "分钟", 21 | "minutes": "分钟", 22 | "noTimeLimit": "无时间限制", 23 | "startPractice": "开始练习", 24 | "savedText": "已保存的文本", 25 | "createCustomText": "创建自定义文本", 26 | "tip": "提示:从较短的时长开始,随着进步逐渐增加" 27 | }, 28 | "settings": { 29 | "theme": "主题", 30 | "language": "语言", 31 | "sound": "声音", 32 | "difficulty": "难度", 33 | "timeLimit": "时间限制", 34 | "customText": "自定义文本" 35 | }, 36 | "difficulty": { 37 | "easy": "简单", 38 | "medium": "中等", 39 | "hard": "困难", 40 | "custom": "自定义" 41 | }, 42 | "statistics": { 43 | "bestWpm": "最佳WPM", 44 | "averageWpm": "平均WPM", 45 | "totalPracticeTime": "总练习时间", 46 | "totalCharacters": "总字符数", 47 | "totalErrors": "总错误数" 48 | } 49 | } -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Ubuntu:ital,wght@0,300;0,400;0,500;0,700;1,300;1,400;1,500;1,700&display=swap'); 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | 7 | @layer utilities { 8 | .animate-fade-in { 9 | animation: fadeIn 0.5s ease-in; 10 | } 11 | 12 | .animate-bounce { 13 | animation: bounce 1s infinite; 14 | } 15 | 16 | .animate-pulse { 17 | animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; 18 | } 19 | } 20 | 21 | @keyframes fadeIn { 22 | from { 23 | opacity: 0; 24 | transform: translateY(-10px); 25 | } 26 | to { 27 | opacity: 1; 28 | transform: translateY(0); 29 | } 30 | } 31 | 32 | @keyframes bounce { 33 | 0%, 100% { 34 | transform: translateY(-5%); 35 | animation-timing-function: cubic-bezier(0.8, 0, 1, 1); 36 | } 37 | 50% { 38 | transform: translateY(0); 39 | animation-timing-function: cubic-bezier(0, 0, 0.2, 1); 40 | } 41 | } 42 | 43 | @keyframes pulse { 44 | 0%, 100% { 45 | opacity: 1; 46 | } 47 | 50% { 48 | opacity: 0.5; 49 | } 50 | } 51 | 52 | /* Custom scrollbar styles */ 53 | ::-webkit-scrollbar { 54 | width: 8px; 55 | height: 8px; 56 | } 57 | 58 | ::-webkit-scrollbar-track { 59 | background: transparent; 60 | } 61 | 62 | ::-webkit-scrollbar-thumb { 63 | background: #888; 64 | border-radius: 4px; 65 | } 66 | 67 | ::-webkit-scrollbar-thumb:hover { 68 | background: #666; 69 | } 70 | 71 | /* Dark mode scrollbar */ 72 | .dark ::-webkit-scrollbar-thumb { 73 | background: #555; 74 | } 75 | 76 | .dark ::-webkit-scrollbar-thumb:hover { 77 | background: #777; 78 | } -------------------------------------------------------------------------------- /src/lib/commands.ts: -------------------------------------------------------------------------------- 1 | export interface Command { 2 | id: string; 3 | name: string; 4 | shortcut: string; 5 | action: () => void; 6 | description: string; 7 | } 8 | 9 | export const createCommands = (handleSubmit: () => void): Command[] => [ 10 | { 11 | id: "restart", 12 | name: "Restart Test", 13 | shortcut: "⌘R", 14 | action: () => window.location.reload(), 15 | description: "Start a new typing test" 16 | }, 17 | { 18 | id: "finish", 19 | name: "Finish Test", 20 | shortcut: "⌘Enter", 21 | action: handleSubmit, 22 | description: "End the current test and view results" 23 | }, 24 | { 25 | id: "toggle-theme", 26 | name: "Toggle Theme", 27 | shortcut: "⌘T", 28 | action: () => document.documentElement.classList.toggle("dark"), 29 | description: "Switch between light and dark mode" 30 | }, 31 | { 32 | id: "focus-input", 33 | name: "Focus Input", 34 | shortcut: "⌘I", 35 | action: () => document.querySelector("textarea")?.focus(), 36 | description: "Focus on the typing area" 37 | } 38 | ]; -------------------------------------------------------------------------------- /src/lib/compare.ts: -------------------------------------------------------------------------------- 1 | // Function to extract plain text from text with translations 2 | function extractPlainText(text: string): string { 3 | // Replace [text](translation) with just text 4 | return text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1'); 5 | } 6 | 7 | export default function calculateAccuracy(text1: string, text2: string): string { 8 | // Extract plain text from both strings 9 | const plainText1 = extractPlainText(text1); 10 | const plainText2 = text2; // User input doesn't have translations 11 | 12 | const totalCharacters: number = plainText1.length; 13 | let correctCharacters: number = 0; 14 | 15 | for (let i: number = 0; i < totalCharacters; i++) { 16 | if (plainText1[i] === plainText2[i]) { 17 | correctCharacters++; 18 | } 19 | } 20 | 21 | const accuracy: number = (correctCharacters / totalCharacters) * 100; 22 | return accuracy.toFixed(2); 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const TOPIC_ICONS: Record = { 2 | physics: '⚛️', 3 | programming: '💻', 4 | literature: '📚', 5 | history: '📜', 6 | science: '🔬', 7 | default: '📝' 8 | }; 9 | 10 | export const KEYBOARD_ELEMENTS = [ 11 | '⌨️', '⌘', '⌥', '⇧', '⌃', '↵', '⌫', '⇥', 12 | 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 13 | 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 14 | '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 15 | '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', 16 | '←', '→', '↑', '↓' 17 | ]; -------------------------------------------------------------------------------- /src/lib/generateRandomWords.ts: -------------------------------------------------------------------------------- 1 | import words from "./words"; 2 | 3 | export default function generateRandomWords(): string { 4 | const randomWords: string[] = []; 5 | 6 | while (randomWords.length < 50) { 7 | const randomIndex: number = Math.floor(Math.random() * words.length); 8 | const randomWord: string = words[randomIndex]; 9 | randomWords.push(randomWord); 10 | } 11 | 12 | return randomWords.join(" "); 13 | } 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/lib/topics/biology.ts: -------------------------------------------------------------------------------- 1 | export const biologySentece = [ 2 | { 3 | id: "biology-1", 4 | text: "[Biology](জীববিজ্ঞান) gives you a [brain](মস্তিষ্ক), life turns it into a mind.", 5 | topic: "biology", 6 | }, 7 | { 8 | id: "biology-2", 9 | text: "The [cell](কোষ) is the fundamental unit of life, forming the building blocks of all living [organisms](জীব).", 10 | topic: "biology", 11 | }, 12 | { 13 | id: "biology-3", 14 | text: "[DNA](ডিএনএ), or [deoxyribonucleic acid](ডিঅক্সিরাইবোনিউক্লিক অ্যাসিড), is the [molecule](অণু) that carries the [genetic instructions](জিনগত নির্দেশনা) for life.", 15 | topic: "biology", 16 | }, 17 | { 18 | id: "biology-4", 19 | text: "[Photosynthesis](সালোকসংশ্লেষণ) is the process by which [plants](উদ্ভিদ) convert [light energy](আলোক শক্তি) into [chemical energy](রাসায়নিক শক্তি).", 20 | topic: "biology", 21 | }, 22 | { 23 | id: "biology-5", 24 | text: "[Ecosystems](বাস্তুতন্ত্র) are communities of living [organisms](জীব) interacting with their [physical environment](ভৌত পরিবেশ).", 25 | topic: "biology", 26 | }, 27 | { 28 | id: "biology-6", 29 | text: "[Homeostasis](হোমিওস্ট্যাসিস) is the ability of an [organism](জীব) to maintain a stable [internal environment](অভ্যন্তরীণ পরিবেশ) despite external changes.", 30 | topic: "biology", 31 | }, 32 | { 33 | id: "biology-7", 34 | text: "[Genetics](জিনতত্ত্ব) is the study of [heredity](বংশগতি) and [variation](বৈচিত্র্য) in organisms, explaining how [traits](বৈশিষ্ট্য) are passed from parents to offspring.", 35 | topic: "biology", 36 | }, 37 | { 38 | id: "biology-8", 39 | text: "[Biotechnology](জৈবপ্রযুক্তি) involves the use of living [organisms](জীব) or their products to modify [human health](মানুষের স্বাস্থ্য) and the [environment](পরিবেশ).", 40 | topic: "biology", 41 | }, 42 | { 43 | id: "biology-9", 44 | text: "[Microbiology](অণুজীববিজ্ঞান) is the study of [microscopic organisms](অণুবীক্ষণিক জীব), including [bacteria](ব্যাকটেরিয়া), [viruses](ভাইরাস), [fungi](ছত্রাক), and [protozoa](প্রোটোজোয়া).", 45 | topic: "biology", 46 | }, 47 | { 48 | id: "biology-10", 49 | text: "[Ecology](পরিবেশবিদ্যা) examines the interactions among [organisms](জীব) and their [environment](পরিবেশ), emphasizing the importance of [biodiversity](জৈববৈচিত্র্য).", 50 | topic: "biology", 51 | }, 52 | { 53 | id: "biology-11", 54 | text: "[Neuroscience](স্নায়ুবিজ্ঞান) explores the structure and function of the [nervous system](স্নায়ুতন্ত্র), including the [brain](মস্তিষ্ক).", 55 | topic: "biology", 56 | }, 57 | ]; 58 | -------------------------------------------------------------------------------- /src/lib/topics/chemistry.ts: -------------------------------------------------------------------------------- 1 | export const chemistrySentences = [ 2 | { 3 | id: "chemistry-1", 4 | text: "The [periodic table](পর্যায় সারণী) organizes [elements](মৌল) based on their [atomic number](পরমাণু সংখ্যা), [electron configuration](ইলেকট্রন বিন্যাস), and [chemical properties](রাসায়নিক ধর্ম).", 5 | topic: "chemistry", 6 | }, 7 | { 8 | id: "chemistry-2", 9 | text: "[Chemical reactions](রাসায়নিক বিক্রিয়া) involve the rearrangement of [atoms](পরমাণু) to form new substances, governed by principles such as [conservation of mass](ভর সংরক্ষণ) and [energy](শক্তি).", 10 | topic: "chemistry", 11 | }, 12 | { 13 | id: "chemistry-3", 14 | text: "[Atoms](পরমাণু) consist of a [nucleus](নিউক্লিয়াস) containing [protons](প্রোটন) and [neutrons](নিউট্রন), surrounded by [electrons](ইলেকট্রন) in [energy levels](শক্তি স্তর) or [orbitals](অরবিটাল).", 15 | topic: "chemistry", 16 | }, 17 | { 18 | id: "chemistry-4", 19 | text: "[Ionic bonds](আয়নিক বন্ধন) form when [atoms](পরমাণু) transfer [electrons](ইলেকট্রন) to achieve a stable configuration, resulting in the formation of [ions](আয়ন).", 20 | topic: "chemistry", 21 | }, 22 | { 23 | id: "chemistry-5", 24 | text: "[Covalent bonds](সমযোজী বন্ধন) involve the sharing of [electron pairs](ইলেকট্রন জোড়া) between [atoms](পরমাণু), resulting in the formation of [molecules](অণু).", 25 | topic: "chemistry", 26 | }, 27 | { 28 | id: "chemistry-6", 29 | text: "[Acids](অম্ল) are substances that donate [protons](প্রোটন) in solution, while [bases](ক্ষার) accept protons. The [pH scale](পিএইচ স্কেল) measures the acidity or basicity of a solution.", 30 | topic: "chemistry", 31 | }, 32 | { 33 | id: "chemistry-7", 34 | text: "[Chemical kinetics](রাসায়নিক গতিবিদ্যা) studies the rates of [chemical reactions](রাসায়নিক বিক্রিয়া) and the factors that influence reaction rates, such as [temperature](তাপমাত্রা) and [concentration](ঘনত্ব).", 35 | topic: "chemistry", 36 | }, 37 | { 38 | id: "chemistry-8", 39 | text: "[Thermodynamics](তাপগতিবিদ্যা) deals with the relationships between [heat](তাপ), [work](কাজ), and [energy](শক্তি) in chemical systems, including concepts like [enthalpy](এনথালপি) and [entropy](এনট্রপি).", 40 | topic: "chemistry", 41 | }, 42 | { 43 | id: "chemistry-9", 44 | text: "[Organic chemistry](জৈব রসায়ন) focuses on the study of [carbon](কার্বন)-containing compounds, including [hydrocarbons](হাইড্রোকার্বন), [alcohols](অ্যালকোহল), and [carbohydrates](কার্বোহাইড্রেট).", 45 | topic: "chemistry", 46 | }, 47 | { 48 | id: "chemistry-10", 49 | text: "[Chemical equilibrium](রাসায়নিক সাম্যাবস্থা) occurs when the rates of forward and reverse reactions are equal, resulting in a constant [concentration](ঘনত্ব) of [reactants](বিক্রিয়ক) and [products](উৎপাদ).", 50 | topic: "chemistry", 51 | } 52 | ]; 53 | -------------------------------------------------------------------------------- /src/lib/topics/english-writting.ts: -------------------------------------------------------------------------------- 1 | export const englishWrittingSentences = [ 2 | { 3 | id: 1, 4 | text: "Climate change is caused mainly by human activities like burning coal, oil, and gas, which release harmful gases such as carbon dioxide into the atmosphere. These gases trap heat from the sun, making the Earth's temperature rise. This process is called global warming. Another major cause is deforestation—cutting down too many trees reduces the Earth’s ability to absorb carbon dioxide, making the air even dirtier. As a result, glaciers and ice caps in places like Antarctica and Greenland are melting, causing sea levels to rise. This leads to flooding in coastal cities, forcing people to leave their homes. Climate change also makes weather more extreme. Some areas experience heavy rainfall and floods, while others suffer from long droughts, making it hard to grow food. Wildfires are becoming more common, destroying forests and killing animals. Hurricanes and storms are getting stronger, damaging homes and cities. Many species of plants and animals are at risk of extinction because their natural habitats are changing too fast. People also suffer from heatwaves and air pollution, which lead to health problems. To slow down climate change, we must use clean energy like wind and solar power, plant more trees, and reduce waste. If we act together, we can protect our planet for future generations.", 5 | topic: "english-writting", 6 | }, 7 | { 8 | id: 2, 9 | text: "Food adulteration happens when harmful or low-quality substances are added to food to increase profit. This is done by mixing cheap or harmful ingredients, using artificial colors, or adding chemicals to make food look fresh. For example, some traders mix water with milk, add harmful dyes to sweets, or use formalin to keep fish and fruits looking fresh for longer. These practices make food unsafe and can cause serious health problems. Eating adulterated food can lead to food poisoning, stomach pain, liver and kidney damage, and even deadly diseases like cancer. Children and elderly people are at higher risk because their bodies are weaker. Over time, consuming toxic food lowers immunity and reduces life expectancy. Food adulteration also affects the economy because unhealthy people cannot work properly, and medical costs rise. To stop this, strict laws must be enforced, and food sellers must be monitored. People should buy fresh and natural food, check labels, and avoid brightly colored or artificially glossy food. Awareness is key—everyone must learn about food safety and report dishonest sellers. Only by working together can we ensure safe and healthy food for all.", 10 | topic: "english-writting", 11 | }, 12 | { 13 | id: 3, 14 | text: "Female education is very important because it helps women become independent, knowledgeable, and confident. When girls get an education, they can build better futures for themselves and their families. Educated women can work in different fields like medicine, teaching, business, and science, which helps a country grow. They also make better decisions about health, hygiene, and raising children, leading to healthier families and stronger societies. Unfortunately, in some places, girls still face barriers like poverty, early marriage, and social restrictions that stop them from going to school. Without education, many women are forced to depend on others and struggle to improve their lives. However, when girls are educated, they can break free from these limitations and help in the progress of their communities. Educated women also fight against injustice and stand up for their rights. Countries that invest in female education see lower child mortality rates, less poverty, and faster development. To ensure equal opportunities, governments and societies must work together to provide free and quality education for all girls. When women are educated, they can shape a better future, not just for themselves but for the entire world.", 15 | topic: "english-writting", 16 | }, 17 | { 18 | id: 4, 19 | text: "Visiting the National Memorial in Savar was a memorable experience. This monument stands as a tribute to the brave souls who sacrificed their lives for Bangladesh’s independence in 1971. As I arrived, I was struck by the towering structure, symbolizing the courage and struggle of our freedom fighters. The vast green fields and the peaceful surroundings added to the solemn beauty of the place. Walking along the pathways, I saw the names of the martyrs inscribed on walls, reminding me of their immense sacrifice. The reflection of the monument in the water body nearby made the scene even more breathtaking. Many visitors were there, paying their respects and learning about the country’s history. The museum inside provided deeper insights into the Liberation War, showcasing pictures, artifacts, and documents from that time. Standing before the monument, I felt a deep sense of pride and gratitude for those who fought for our freedom. The visit was not just educational but also an emotional journey, making me realize the true cost of independence. The National Memorial in Savar is not just a structure—it is a symbol of our nation’s resilience and a place that every Bangladeshi should visit at least once.", 20 | topic: "english-writting", 21 | }, 22 | ]; 23 | -------------------------------------------------------------------------------- /src/lib/topics/index.ts: -------------------------------------------------------------------------------- 1 | import { biologySentece } from "./biology"; 2 | import { chemistrySentences } from "./chemistry"; 3 | import { englishWrittingSentences } from "./english-writting"; 4 | import { physicsSentences } from "./physics"; 5 | 6 | export const sentences = [ 7 | ...biologySentece, 8 | ...physicsSentences, 9 | ...chemistrySentences, 10 | ...englishWrittingSentences, 11 | ]; 12 | -------------------------------------------------------------------------------- /src/lib/topics/physics.ts: -------------------------------------------------------------------------------- 1 | export const physicsSentences = [ 2 | { 3 | id: "physics-1", 4 | text: "[Newton's first law of motion](নিউটনের গতির প্রথম সূত্র) states that an [object](বস্তু) will remain at [rest](বিশ্রাম) or in [uniform motion](সমান গতি) unless acted upon by an [external force](বাহ্যিক বল).", 5 | topic: "physics", 6 | }, 7 | { 8 | id: "physics-2", 9 | text: "[Energy](শক্তি) is the ability to do [work](কাজ), and it comes in various forms such as [kinetic energy](গতিশক্তি), [potential energy](স্থিতিশক্তি), and [thermal energy](তাপশক্তি).", 10 | topic: "physics", 11 | }, 12 | { 13 | id: "physics-3", 14 | text: "The [law of conservation of energy](শক্তির সংরক্ষণ সূত্র) states that [energy](শক্তি) cannot be created or destroyed, only transformed from one form to another.", 15 | topic: "physics", 16 | }, 17 | { 18 | id: "physics-4", 19 | text: "[Electricity](বিদ্যুৎ) is the flow of [electric charge](তড়িৎ আধান), typically through [conductors](পরিবাহী) such as [wires](তারের).", 20 | topic: "physics", 21 | }, 22 | { 23 | id: "physics-5", 24 | text: "The [electromagnetic spectrum](তড়িৎচুম্বকীয় বর্ণালী) encompasses all forms of [electromagnetic radiation](তড়িৎচুম্বকীয় বিকিরণ), from [radio waves](রেডিও তরঙ্গ) to [gamma rays](গামা রশ্মি).", 25 | topic: "physics", 26 | }, 27 | { 28 | id: "physics-6", 29 | text: "[Gravity](মহাকর্ষ) is the [force](বল) of attraction between [objects](বস্তু) with [mass](ভর), and it governs the [motion](গতি) of [planets](গ্রহ), [stars](তারা), and [galaxies](ছায়াপথ).", 30 | topic: "physics", 31 | }, 32 | { 33 | id: "physics-7", 34 | text: "The [laws of thermodynamics](তাপগতিবিদ্যার সূত্রসমূহ) govern the transfer of [energy](শক্তি) and describe the behavior of [systems](তন্ত্র) in terms of [heat](তাপ) and [work](কাজ).", 35 | topic: "physics", 36 | }, 37 | { 38 | id: "physics-8", 39 | text: "[Quantum mechanics](কোয়ান্টাম বলবিদ্যা) is the branch of [physics](পদার্থবিদ্যা) that deals with the behavior of [particles](কণা) on the smallest scales, such as [atoms](পরমাণু) and [subatomic particles](উপ-পরমাণবিক কণা).", 40 | topic: "physics", 41 | }, 42 | { 43 | id: "physics-9", 44 | text: "[Optics](আলোকবিদ্যা) is the study of [light](আলো) and its interactions with [matter](পদার্থ), including [reflection](প্রতিফলন), [refraction](প্রতিসরণ), and [diffraction](বিচ্ছুরণ).", 45 | topic: "physics", 46 | }, 47 | { 48 | id: "physics-10", 49 | text: "[Nuclear physics](নিউক্লিয় পদার্থবিদ্যা) explores the properties and behavior of [atomic nuclei](পরমাণুর নিউক্লিয়াস), including [nuclear reactions](নিউক্লিয় বিক্রিয়া) and [radioactive decay](তেজস্ক্রিয় ক্ষয়).", 50 | topic: "physics", 51 | } 52 | ]; 53 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | export function shuffleArray(array: string[]) { 2 | let currentIndex = array.length, 3 | randomIndex; 4 | 5 | // While there remain elements to shuffle. 6 | while (currentIndex !== 0) { 7 | // Pick a remaining element. 8 | randomIndex = Math.floor(Math.random() * currentIndex); 9 | currentIndex--; 10 | 11 | // And swap it with the current element. 12 | [array[currentIndex], array[randomIndex]] = [ 13 | array[randomIndex], 14 | array[currentIndex], 15 | ]; 16 | } 17 | 18 | return array; 19 | } 20 | 21 | export const ignoredKeys = [ 22 | "Shift", 23 | "Alt", 24 | "Control", 25 | "Meta", 26 | "CapsLock", 27 | "Escape", 28 | "ArrowUp", 29 | "ArrowDown", 30 | "ArrowLeft", 31 | "ArrowRight", 32 | "PageUp", 33 | "PageDown", 34 | "Home", 35 | "End", 36 | "Insert", 37 | "Delete", 38 | "Tab", 39 | "F1", 40 | "F2", 41 | "F3", 42 | "F4", 43 | "F5", 44 | "F6", 45 | "F7", 46 | "F8", 47 | "F9", 48 | "F10", 49 | "F11", 50 | "F12", 51 | ]; 52 | 53 | -------------------------------------------------------------------------------- /src/lib/words.ts: -------------------------------------------------------------------------------- 1 | const words: string[] = [ 2 | "apple", 3 | "banana", 4 | "carrot", 5 | "dog", 6 | "elephant", 7 | "flower", 8 | "guitar", 9 | "house", 10 | "ice cream", 11 | "jungle", 12 | "kangaroo", 13 | "lemon", 14 | "mango", 15 | "night", 16 | "ocean", 17 | "piano", 18 | "queen", 19 | "rainbow", 20 | "sun", 21 | "tiger", 22 | "umbrella", 23 | "violin", 24 | "watermelon", 25 | "xylophone", 26 | "yoga", 27 | "zebra", 28 | "airplane", 29 | "beach", 30 | "cat", 31 | "dolphin", 32 | "eagle", 33 | "forest", 34 | "globe", 35 | "honey", 36 | "island", 37 | "jacket", 38 | "kiwi", 39 | "lion", 40 | "moon", 41 | "noodle", 42 | "orange", 43 | "parrot", 44 | "quilt", 45 | "river", 46 | "sunset", 47 | "tree", 48 | "unicorn", 49 | "volcano", 50 | "waterfall", 51 | "xylophone", 52 | "yacht", 53 | "zeppelin", 54 | "ant", 55 | "bear", 56 | "camel", 57 | "dragon", 58 | "elephant", 59 | "fox", 60 | "giraffe", 61 | "horse", 62 | "iguana", 63 | "jaguar", 64 | "koala", 65 | "lemur", 66 | "monkey", 67 | "narwhal", 68 | "octopus", 69 | "penguin", 70 | "quokka", 71 | "raccoon", 72 | "sloth", 73 | "toucan", 74 | "unicorn", 75 | "vulture", 76 | "walrus", 77 | "x-ray fish", 78 | "yak", 79 | "zebra", 80 | "astronaut", 81 | "butterfly", 82 | "chef", 83 | "detective", 84 | "engineer", 85 | "firefighter", 86 | "gardener", 87 | "hiker", 88 | "inventor", 89 | "juggler", 90 | "king", 91 | "lawyer", 92 | "musician", 93 | "nurse", 94 | "officer", 95 | "pilot", 96 | "queen", 97 | "scientist", 98 | "teacher", 99 | "unicorn", 100 | "veterinarian", 101 | "writer", 102 | ]; 103 | 104 | export default words; 105 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import './index.css' 4 | import { RouterProvider, createRouter } from '@tanstack/react-router' 5 | import './i18n/i18n' // Import i18n configuration 6 | 7 | // Import the generated route tree 8 | import { routeTree } from './routeTree.gen' 9 | import NotFound from './components/NotFound' 10 | 11 | // Create a new router instance 12 | const router = createRouter({ routeTree, defaultNotFoundComponent: () => }) 13 | 14 | // Register the router instance for type safety 15 | declare module '@tanstack/react-router' { 16 | interface Register { 17 | router: typeof router 18 | } 19 | } 20 | 21 | ReactDOM.createRoot(document.getElementById('root')!).render( 22 | 23 | 24 | , 25 | ) 26 | -------------------------------------------------------------------------------- /src/routes/__root.tsx: -------------------------------------------------------------------------------- 1 | import { createRootRoute, Outlet } from '@tanstack/react-router' 2 | import { HelmetProvider } from 'react-helmet-async' 3 | // import { TanStackRouterDevtools } from '@tanstack/router-devtools' 4 | import Navbar from '../components/Navbar' 5 | import Footer from '../components/Footer' 6 | 7 | export const Route = createRootRoute({ 8 | component: () => ( 9 | 10 |
11 | 12 | 13 |
14 | {/* */} 15 |
16 |
17 | ), 18 | }) 19 | -------------------------------------------------------------------------------- /src/routes/code.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from '@tanstack/react-router' 2 | import { useState } from 'react' 3 | import CodeTypingTest from '../components/CodeTypingTest' 4 | import { SEO } from '../components/SEO' 5 | 6 | export const Route = createFileRoute('/code')({ 7 | component: () => ( 8 | <> 9 | 14 | 15 | 16 | ), 17 | }) 18 | 19 | function RouteComponent() { 20 | const [selectedLanguage, setSelectedLanguage] = useState('javascript') 21 | const [selectedTime, setSelectedTime] = useState(60) 22 | 23 | const codeExamples = { 24 | javascript: `function calculateSum(numbers) { 25 | return numbers.reduce((sum, num) => sum + num, 0); 26 | } 27 | 28 | const numbers = [1, 2, 3, 4, 5]; 29 | const result = calculateSum(numbers); 30 | console.log(result); // Output: 15`, 31 | python: `def calculate_sum(numbers): 32 | return sum(numbers) 33 | 34 | numbers = [1, 2, 3, 4, 5] 35 | result = calculate_sum(numbers) 36 | print(result) # Output: 15`, 37 | java: `public class Calculator { 38 | public static int calculateSum(int[] numbers) { 39 | int sum = 0; 40 | for (int num : numbers) { 41 | sum += num; 42 | } 43 | return sum; 44 | } 45 | 46 | public static void main(String[] args) { 47 | int[] numbers = {1, 2, 3, 4, 5}; 48 | int result = calculateSum(numbers); 49 | System.out.println(result); // Output: 15 50 | } 51 | }` 52 | } 53 | 54 | return ( 55 |
56 |
57 |
58 | 67 | 78 |
79 | 84 |
85 |
86 | ) 87 | } 88 | -------------------------------------------------------------------------------- /src/routes/guide.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from "@tanstack/react-router"; 2 | import { ArrowLeft, HomeIcon, Copy, Check } from "lucide-react"; 3 | import { Link } from "@tanstack/react-router"; 4 | import { useState } from "react"; 5 | import { SEO } from "../components/SEO"; 6 | 7 | export const Route = createFileRoute("/guide")({ 8 | component: () => ( 9 | <> 10 | 21 | 22 | 23 | ), 24 | }); 25 | 26 | function RouteComponent() { 27 | const [topic, setTopic] = useState(""); 28 | const [copied, setCopied] = useState(false); 29 | 30 | const prompt = `Write a one-paragraph explanation about ${ 31 | topic || "[topic]" 32 | } in English. Add the meaning of most English words (except very common words like a, an, the, this, that, etc.) in Bangla using the format [word](বাংলা অর্থ). Make the paragraph suitable for students learning English vocabulary. Do not use any characters or formatting that are not found on a standard keyboard (e.g., avoid special symbols or non-standard punctuation). 33 | 34 | Example format: 35 | Photosynthesis is the [process](প্রক্রিয়া) by which plants [convert](রূপান্তর) light energy into chemical energy. During this [amazing](অসাধারণ) [process](প্রক্রিয়া), plants use [sunlight](সূর্যালোক), water, and carbon dioxide to [create](তৈরি) [glucose](গ্লুকোজ) and [oxygen](অক্সিজেন). 36 | 37 | Please format the response in Markdown`; 38 | 39 | const handleCopy = async () => { 40 | await navigator.clipboard.writeText(prompt); 41 | setCopied(true); 42 | setTimeout(() => setCopied(false), 2000); 43 | }; 44 | 45 | return ( 46 |
47 |
48 |
49 |

50 | AI Content Generation Guide 51 |

52 |

53 | Learn how to create educational content with translations in 54 | Markdown format 55 |

56 |
57 | 58 |
59 |
60 | {/* Interactive Prompt Section */} 61 |
62 |

63 | Generate Your Prompt 64 |

65 |
66 |
67 | setTopic(e.target.value)} 73 | /> 74 | 91 |
92 |
93 |
94 |

95 | {prompt} 96 |

97 |
98 |
99 |
100 |
101 | 102 | {/* How to Use Section */} 103 |
104 |

How to Use

105 |
106 | {[ 107 | "Enter your topic in the input field above", 108 | 'Click the "Copy" button to copy the generated prompt', 109 | "Paste the prompt into your AI tool of choice", 110 | "The response will be formatted in Markdown with translations", 111 | ].map((step, index) => ( 112 |
113 |
114 | {index + 1} 115 |
116 |

{step}

117 |
118 | ))} 119 |
120 |
121 | 122 | {/* Example Section */} 123 |
124 |

Example Output

125 |
126 |
127 |
128 |                     {`# Photosynthesis
129 | 
130 | Photosynthesis is the [process](প্রক্রিয়া) by which plants [convert](রূপান্তর) light energy into chemical energy. During this [amazing](অসাধারণ) [process](প্রক্রিয়া), plants use [sunlight](সূর্যালোক), water, and carbon dioxide to [create](তৈরি) [glucose](গ্লুকোজ) and [oxygen](অক্সিজেন).
131 | 
132 | ## Key Terms
133 | - [process](প্রক্রিয়া) - The way something happens
134 | - [convert](রূপান্তর) - To change from one form to another
135 | - [amazing](অসাধারণ) - Very surprising or impressive
136 | - [sunlight](সূর্যালোক) - Light from the sun
137 | - [create](তৈরি) - To make something new
138 | - [glucose](গ্লুকোজ) - A type of sugar
139 | - [oxygen](অক্সিজেন) - A gas that living things need to breathe`}
140 |                   
141 |
142 |
143 |
144 | 145 | {/* Tips Section */} 146 |
147 |

Tips

148 |
    149 |
  • Keep topics focused and specific for better results
  • 150 |
  • 151 | Common words like "the", "is", "and" won't have translations 152 |
  • 153 |
  • The translations appear as tooltips while typing
  • 154 |
  • You can use this for any subject or topic
  • 155 |
  • 156 | The Markdown format makes it easy to use in various platforms 157 |
  • 158 |
159 |
160 | 161 | {/* Navigation Buttons */} 162 |
163 | 170 | 171 | 175 | 176 |
177 |
178 |
179 |
180 |
181 | ); 182 | } 183 | -------------------------------------------------------------------------------- /src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from '@tanstack/react-router' 2 | import App from '../App' 3 | import { SEO } from '../components/SEO' 4 | 5 | export const Route = createFileRoute('/')({ 6 | component: () => ( 7 | <> 8 | 13 | 14 | 15 | ), 16 | }) -------------------------------------------------------------------------------- /src/routes/practice.tsx: -------------------------------------------------------------------------------- 1 | import { Link, createFileRoute, useNavigate } from "@tanstack/react-router"; 2 | import { z } from "zod"; 3 | import { useSentenceStore } from "../store/sentenceStore"; 4 | import TypingTest from "../components/TypingTest"; 5 | import BengaliTyping from "../components/NonEnTyping"; 6 | import { ArrowLeft, HomeIcon } from "lucide-react"; 7 | import { useEffect, useState } from "react"; 8 | import { SEO } from '../components/SEO' 9 | import { franc } from 'franc'; 10 | 11 | const topicsSearchSchema = z.object({ 12 | topic: z.string().optional(), 13 | eclipsedTime: z.number().optional(), 14 | savedTextId: z.number().optional(), 15 | }); 16 | 17 | export const Route = createFileRoute("/practice")({ 18 | component: PracticeComponent, 19 | validateSearch: (search: Record) => 20 | topicsSearchSchema.parse(search), 21 | }); 22 | 23 | function PracticeComponent() { 24 | const { topic, eclipsedTime, savedTextId } = Route.useSearch(); 25 | const navigate = useNavigate(); 26 | const [savedSentence, setSavedSentence] = useState<{ 27 | id: number; 28 | label: string; 29 | text: string; 30 | } | null>(null); 31 | 32 | useEffect(() => { 33 | // If no props are provided, redirect to home 34 | if (!topic && !savedTextId) { 35 | navigate({ to: '/' }); 36 | return; 37 | } 38 | 39 | if (savedTextId) { 40 | const text = localStorage.getItem("customTextData"); 41 | if (text) { 42 | const textArray = JSON.parse(text); 43 | const savedSentenceObj = textArray.find( 44 | (item: { id: number; label: string; text: string }) => 45 | item.id === savedTextId 46 | ); 47 | if (savedSentenceObj) { 48 | setSavedSentence(savedSentenceObj); 49 | } 50 | } 51 | } 52 | }, [savedTextId, topic, navigate]); 53 | 54 | const getCompleteSentences = useSentenceStore((state) => state.getCompleteSentences); 55 | const sentences = topic ? getCompleteSentences(topic, 1000) : ""; 56 | 57 | if (!sentences && savedSentence === null) { 58 | return ( 59 | <> 60 | 65 |
66 |
67 |

68 | Not found text of topic {topic}! 69 |

70 | 71 |
72 | 79 | 80 | 84 | 85 |
86 |
87 |
88 | 89 | ); 90 | } 91 | 92 | const textToType = savedSentence ? savedSentence.text : sentences; 93 | // Use franc to detect language 94 | const lang = franc(textToType || ''); 95 | const isBengali = lang !== 'eng'; 96 | 97 | return ( 98 | <> 99 | 104 | {isBengali ? ( 105 | 106 | ) : ( 107 | 111 | )} 112 | 113 | ); 114 | } -------------------------------------------------------------------------------- /src/routes/privacy.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from '@tanstack/react-router' 2 | import { SEO } from '../components/SEO' 3 | import { ArrowLeft, HomeIcon } from 'lucide-react' 4 | import { Link } from '@tanstack/react-router' 5 | 6 | export const Route = createFileRoute('/privacy')({ 7 | component: () => ( 8 | <> 9 | 14 | 15 | 16 | ), 17 | }) 18 | 19 | function PrivacyComponent() { 20 | return ( 21 |
22 |
23 |
24 |

25 | Privacy Policy 26 |

27 |

28 | Last updated: {new Date().toLocaleDateString()} 29 |

30 |
31 | 32 |
33 | {/* Introduction */} 34 |
35 |
36 |

37 | Introduction 38 |

39 |

40 | We respect your privacy and are committed to protecting your personal data. This privacy policy will inform you about how we look after your personal data when you visit our typing practice application and tell you about your privacy rights. 41 |

42 |
43 |
44 | 45 | {/* Data Collection */} 46 |
47 |
48 |

49 | Data We Collect 50 |

51 |
52 |
53 |

Practice Data

54 |
    55 |
  • Typing speed and accuracy metrics
  • 56 |
  • Practice session duration
  • 57 |
  • Error patterns and statistics
  • 58 |
  • Custom text content you create
  • 59 |
60 |
61 |
62 |

Usage Data

63 |
    64 |
  • Browser type and version
  • 65 |
  • Operating system
  • 66 |
  • Time and date of access
  • 67 |
  • Pages visited
  • 68 |
69 |
70 |
71 |
72 |
73 | 74 | {/* Data Usage */} 75 |
76 |
77 |

78 | How We Use Your Data 79 |

80 |
81 |

82 | We use your data to: 83 |

84 |
    85 |
  • Provide and improve our typing practice services
  • 86 |
  • Track your progress and show statistics
  • 87 |
  • Personalize your practice experience
  • 88 |
  • Analyze and improve our application
  • 89 |
  • Ensure the security of our services
  • 90 |
91 |
92 |
93 |
94 | 95 | {/* Data Storage */} 96 |
97 |
98 |

99 | Data Storage 100 |

101 |

102 | Your practice data is stored locally in your browser using localStorage. We do not store your personal data on our servers. This means your data remains on your device and is not transmitted to our servers. 103 |

104 |
105 |
106 | 107 | {/* Your Rights */} 108 |
109 |
110 |

111 | Your Rights 112 |

113 |
114 |

115 | You have the right to: 116 |

117 |
    118 |
  • Access your practice data
  • 119 |
  • Delete your practice data
  • 120 |
  • Export your practice data
  • 121 |
  • Clear your browser's localStorage
  • 122 |
123 |
124 |
125 |
126 | 127 | {/* Contact */} 128 |
129 |
130 |

131 | Contact Us 132 |

133 |

134 | If you have any questions about this privacy policy or our data practices, please contact us through our GitHub repository or Twitter account. 135 |

136 |
137 |
138 | 139 | {/* Navigation Buttons */} 140 |
141 | 148 | 149 | 153 | 154 |
155 |
156 |
157 |
158 | ) 159 | } -------------------------------------------------------------------------------- /src/routes/stats.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from '@tanstack/react-router' 2 | import { SEO } from '../components/SEO' 3 | import { ArrowLeft, HomeIcon } from 'lucide-react' 4 | import { Link } from '@tanstack/react-router' 5 | import GlobalStats from '../components/GlobalStats' 6 | 7 | export const Route = createFileRoute('/stats')({ 8 | component: () => ( 9 | <> 10 | 15 | 16 | 17 | ), 18 | }) 19 | 20 | function StatsComponent() { 21 | return ( 22 |
23 |
24 |
25 |

26 | Your Typing Statistics 27 |

28 |

29 | Track your progress and improvement over time 30 |

31 |
32 | 33 |
34 | {/* Global Error Statistics */} 35 | 36 | 37 | {/* Navigation Buttons */} 38 |
39 | 46 | 47 | 51 | 52 |
53 |
54 |
55 |
56 | ) 57 | } 58 | 59 | -------------------------------------------------------------------------------- /src/routes/terms.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from '@tanstack/react-router' 2 | import { SEO } from '../components/SEO' 3 | import { ArrowLeft, HomeIcon } from 'lucide-react' 4 | import { Link } from '@tanstack/react-router' 5 | 6 | export const Route = createFileRoute('/terms')({ 7 | component: () => ( 8 | <> 9 | 14 | 15 | 16 | ), 17 | }) 18 | 19 | function TermsComponent() { 20 | return ( 21 |
22 |
23 |
24 |

25 | Terms of Service 26 |

27 |

28 | Last updated: {new Date().toLocaleDateString()} 29 |

30 |
31 | 32 |
33 | {/* Introduction */} 34 |
35 |
36 |

37 | Introduction 38 |

39 |

40 | Welcome to Typing Practice. By using our application, you agree to these terms of service. Please read them carefully before using our services. 41 |

42 |
43 |
44 | 45 | {/* Acceptance of Terms */} 46 |
47 |
48 |

49 | Acceptance of Terms 50 |

51 |

52 | By accessing or using our typing practice application, you agree to be bound by these Terms of Service. If you do not agree to these terms, please do not use our application. 53 |

54 |
55 |
56 | 57 | {/* User Responsibilities */} 58 |
59 |
60 |

61 | User Responsibilities 62 |

63 |
64 |

65 | As a user of our application, you agree to: 66 |

67 |
    68 |
  • Use the application for its intended purpose
  • 69 |
  • Not attempt to manipulate or cheat the system
  • 70 |
  • Not use the application for any illegal purposes
  • 71 |
  • Not share inappropriate or offensive content
  • 72 |
  • Respect the privacy of other users
  • 73 |
74 |
75 |
76 |
77 | 78 | {/* Intellectual Property */} 79 |
80 |
81 |

82 | Intellectual Property 83 |

84 |

85 | The application, including its original content, features, and functionality, is owned by Typing Practice and is protected by international copyright, trademark, and other intellectual property laws. 86 |

87 |
88 |
89 | 90 | {/* Limitation of Liability */} 91 |
92 |
93 |

94 | Limitation of Liability 95 |

96 |

97 | Our application is provided "as is" without any warranties. We are not liable for any damages arising from the use or inability to use our application. 98 |

99 |
100 |
101 | 102 | {/* Changes to Terms */} 103 |
104 |
105 |

106 | Changes to Terms 107 |

108 |

109 | We reserve the right to modify these terms at any time. We will notify users of any material changes by updating the "Last updated" date at the top of this page. 110 |

111 |
112 |
113 | 114 | {/* Contact */} 115 |
116 |
117 |

118 | Contact Us 119 |

120 |

121 | If you have any questions about these Terms of Service, please contact us through our GitHub repository or Twitter account. 122 |

123 |
124 |
125 | 126 | {/* Navigation Buttons */} 127 |
128 | 135 | 136 | 140 | 141 |
142 |
143 |
144 |
145 | ) 146 | } -------------------------------------------------------------------------------- /src/store/__mocks__/themeStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | const useMockThemeStore = create((set) => ({ 4 | isDarkMode: false, 5 | toggleTheme: () => set((state: { isDarkMode: boolean }) => ({ 6 | isDarkMode: !state.isDarkMode 7 | })), 8 | })); 9 | 10 | export default useMockThemeStore; -------------------------------------------------------------------------------- /src/store/errorStatsStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { persist } from 'zustand/middleware'; 3 | 4 | interface ErrorStats { 5 | totalErrors: number; 6 | errorMap: Record; 7 | lastUpdated: string; 8 | } 9 | 10 | interface ErrorStatsStore { 11 | errorStats: ErrorStats; 12 | showHighErrorChars: boolean; 13 | addError: (key: string) => void; 14 | resetStats: () => void; 15 | getHighErrorChars: () => string[]; 16 | toggleHighErrorChars: () => void; 17 | } 18 | 19 | const initialErrorStats: ErrorStats = { 20 | totalErrors: 0, 21 | errorMap: {}, 22 | lastUpdated: new Date().toISOString(), 23 | }; 24 | 25 | export const useErrorStatsStore = create()( 26 | persist( 27 | (set, get) => ({ 28 | errorStats: initialErrorStats, 29 | showHighErrorChars: false, 30 | addError: (key: string) => 31 | set((state) => { 32 | const newErrorMap = { ...state.errorStats.errorMap }; 33 | newErrorMap[key] = (newErrorMap[key] || 0) + 1; 34 | return { 35 | errorStats: { 36 | totalErrors: state.errorStats.totalErrors + 1, 37 | errorMap: newErrorMap, 38 | lastUpdated: new Date().toISOString(), 39 | }, 40 | }; 41 | }), 42 | resetStats: () => 43 | set({ 44 | errorStats: initialErrorStats, 45 | }), 46 | getHighErrorChars: () => { 47 | const state = get(); 48 | const { errorMap, totalErrors } = state.errorStats; 49 | 50 | // Calculate error rate for each character 51 | return Object.entries(errorMap) 52 | .filter(([, count]) => { 53 | const errorRate = (count / totalErrors) * 100; 54 | return errorRate > 20; 55 | }) 56 | .map(([char]) => char); 57 | }, 58 | toggleHighErrorChars: () => 59 | set((state) => ({ 60 | showHighErrorChars: !state.showHighErrorChars 61 | })), 62 | }), 63 | { 64 | name: 'error-stats-storage', 65 | } 66 | ) 67 | ); -------------------------------------------------------------------------------- /src/store/sentenceStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { sentences } from "../lib/topics"; 3 | 4 | // Define the type for a sentence 5 | type Sentence = { 6 | id: string; 7 | text: string; 8 | topic: string; 9 | }; 10 | 11 | // Define the type for the store's state 12 | interface SentenceStore { 13 | sentences: Sentence[]; 14 | getSentencesByTopic: (topic: string) => string[]; 15 | getAllTopics: () => string[]; 16 | getCompleteSentences: (topic: string, maxLength: number) => string; 17 | } 18 | 19 | // Initial sentences data 20 | // Convert all sentence ids to string 21 | const initialSentences: Sentence[] = sentences.map((sentence) => ({ 22 | ...sentence, 23 | id: sentence.id.toString(), // Convert id to string if it's a number 24 | })); 25 | 26 | // Create the Zustand store with TypeScript types 27 | export const useSentenceStore = create((_set, get) => ({ 28 | sentences: initialSentences, 29 | getSentencesByTopic: (topic: string) => { 30 | const state = get(); 31 | return state.sentences 32 | .filter((sentence) => sentence.topic === topic) 33 | .map((sentence) => sentence.text); 34 | }, 35 | getAllTopics: () => { 36 | const state = get(); 37 | const topics = state.sentences.map((sentence) => sentence.topic); 38 | return Array.from(new Set(topics)); 39 | }, 40 | getCompleteSentences: (topic: string, maxLength: number) => { 41 | const state = get(); 42 | const topicSentences = state.sentences 43 | .filter((sentence) => sentence.topic === topic) 44 | .map((sentence) => sentence.text); 45 | 46 | let result = ""; 47 | for (const sentence of topicSentences) { 48 | // Check if adding this sentence would exceed maxLength 49 | if (result.length + sentence.length + 1 > maxLength) { 50 | break; 51 | } 52 | // Add sentence with a space if not the first sentence 53 | result += (result ? " " : "") + sentence; 54 | } 55 | return result; 56 | }, 57 | })); 58 | -------------------------------------------------------------------------------- /src/store/themeStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { persist } from "zustand/middleware"; 3 | 4 | export type Theme = 5 | | "light" 6 | | "dark" 7 | | "cupcake" 8 | | "bumblebee" 9 | | "emerald" 10 | | "corporate" 11 | | "synthwave" 12 | | "retro" 13 | | "cyberpunk" 14 | | "valentine" 15 | | "halloween" 16 | | "garden" 17 | | "forest" 18 | | "aqua" 19 | | "lofi" 20 | | "pastel" 21 | | "fantasy" 22 | | "wireframe" 23 | | "black" 24 | | "luxury" 25 | | "dracula" 26 | | "cmyk" 27 | | "autumn" 28 | | "business" 29 | | "acid" 30 | | "lemonade" 31 | | "night" 32 | | "coffee" 33 | | "winter" 34 | | "dim" 35 | | "nord" 36 | | "sunset" 37 | | "caramellatte" 38 | | "abyss" 39 | | "silk"; 40 | 41 | export const themes: Theme[] = [ 42 | "light", 43 | "dark", 44 | "cupcake", 45 | "bumblebee", 46 | "emerald", 47 | "corporate", 48 | "synthwave", 49 | "retro", 50 | "cyberpunk", 51 | "valentine", 52 | "halloween", 53 | "garden", 54 | "forest", 55 | "aqua", 56 | "lofi", 57 | "pastel", 58 | "fantasy", 59 | "wireframe", 60 | "black", 61 | "luxury", 62 | "dracula", 63 | "cmyk", 64 | "autumn", 65 | "business", 66 | "acid", 67 | "lemonade", 68 | "night", 69 | "coffee", 70 | "winter", 71 | "dim", 72 | "nord", 73 | "sunset", 74 | "caramellatte", 75 | "abyss", 76 | "silk" 77 | ]; 78 | 79 | interface ThemeState { 80 | currentTheme: Theme; 81 | setTheme: (theme: Theme) => void; 82 | toggleTheme: () => void; 83 | } 84 | 85 | const useThemeStore = create()( 86 | persist( 87 | (set) => ({ 88 | currentTheme: "light", 89 | setTheme: (theme: Theme) => { 90 | document.documentElement.setAttribute("data-theme", theme); 91 | set({ currentTheme: theme }); 92 | }, 93 | toggleTheme: () => { 94 | set((state) => { 95 | const newTheme = state.currentTheme === "light" ? "dark" : "light"; 96 | document.documentElement.setAttribute("data-theme", newTheme); 97 | return { currentTheme: newTheme }; 98 | }); 99 | }, 100 | }), 101 | { 102 | name: "theme", 103 | onRehydrateStorage: () => (state) => { 104 | if (state?.currentTheme) { 105 | document.documentElement.setAttribute("data-theme", state.currentTheme); 106 | } 107 | }, 108 | } 109 | ) 110 | ); 111 | 112 | export default useThemeStore; 113 | -------------------------------------------------------------------------------- /src/stories/Button.stories.ts: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import { fn } from '@storybook/test'; 3 | 4 | import { Button } from './Button'; 5 | 6 | // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export 7 | const meta = { 8 | title: 'Example/Button', 9 | component: Button, 10 | parameters: { 11 | // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout 12 | layout: 'centered', 13 | }, 14 | // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs 15 | tags: ['autodocs'], 16 | // More on argTypes: https://storybook.js.org/docs/api/argtypes 17 | argTypes: { 18 | backgroundColor: { control: 'color' }, 19 | }, 20 | // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args 21 | args: { onClick: fn() }, 22 | } satisfies Meta; 23 | 24 | export default meta; 25 | type Story = StoryObj; 26 | 27 | // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args 28 | export const Primary: Story = { 29 | args: { 30 | primary: true, 31 | label: 'Button', 32 | }, 33 | }; 34 | 35 | export const Secondary: Story = { 36 | args: { 37 | label: 'Button', 38 | }, 39 | }; 40 | 41 | export const Large: Story = { 42 | args: { 43 | size: 'large', 44 | label: 'Button', 45 | }, 46 | }; 47 | 48 | export const Small: Story = { 49 | args: { 50 | size: 'small', 51 | label: 'Button', 52 | }, 53 | }; 54 | -------------------------------------------------------------------------------- /src/stories/Button.tsx: -------------------------------------------------------------------------------- 1 | import './button.css'; 2 | 3 | export interface ButtonProps { 4 | /** Is this the principal call to action on the page? */ 5 | primary?: boolean; 6 | /** What background color to use */ 7 | backgroundColor?: string; 8 | /** How large should the button be? */ 9 | size?: 'small' | 'medium' | 'large'; 10 | /** Button contents */ 11 | label: string; 12 | /** Optional click handler */ 13 | onClick?: () => void; 14 | } 15 | 16 | /** Primary UI component for user interaction */ 17 | export const Button = ({ 18 | primary = false, 19 | size = 'medium', 20 | backgroundColor, 21 | label, 22 | ...props 23 | }: ButtonProps) => { 24 | const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary'; 25 | return ( 26 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/stories/Header.stories.ts: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import { fn } from '@storybook/test'; 3 | 4 | import { Header } from './Header'; 5 | 6 | const meta = { 7 | title: 'Example/Header', 8 | component: Header, 9 | // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs 10 | tags: ['autodocs'], 11 | parameters: { 12 | // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout 13 | layout: 'fullscreen', 14 | }, 15 | args: { 16 | onLogin: fn(), 17 | onLogout: fn(), 18 | onCreateAccount: fn(), 19 | }, 20 | } satisfies Meta; 21 | 22 | export default meta; 23 | type Story = StoryObj; 24 | 25 | export const LoggedIn: Story = { 26 | args: { 27 | user: { 28 | name: 'Jane Doe', 29 | }, 30 | }, 31 | }; 32 | 33 | export const LoggedOut: Story = {}; 34 | -------------------------------------------------------------------------------- /src/stories/Header.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from './Button'; 2 | import './header.css'; 3 | 4 | type User = { 5 | name: string; 6 | }; 7 | 8 | export interface HeaderProps { 9 | user?: User; 10 | onLogin?: () => void; 11 | onLogout?: () => void; 12 | onCreateAccount?: () => void; 13 | } 14 | 15 | export const Header = ({ user, onLogin, onLogout, onCreateAccount }: HeaderProps) => ( 16 |
17 |
18 |
19 | 20 | 21 | 25 | 29 | 33 | 34 | 35 |

Acme

36 |
37 |
38 | {user ? ( 39 | <> 40 | 41 | Welcome, {user.name}! 42 | 43 |
52 |
53 |
54 | ); 55 | -------------------------------------------------------------------------------- /src/stories/Page.stories.ts: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import { expect, userEvent, within } from '@storybook/test'; 3 | 4 | import { Page } from './Page'; 5 | 6 | const meta = { 7 | title: 'Example/Page', 8 | component: Page, 9 | parameters: { 10 | // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout 11 | layout: 'fullscreen', 12 | }, 13 | } satisfies Meta; 14 | 15 | export default meta; 16 | type Story = StoryObj; 17 | 18 | export const LoggedOut: Story = {}; 19 | 20 | // More on component testing: https://storybook.js.org/docs/writing-tests/component-testing 21 | export const LoggedIn: Story = { 22 | play: async ({ canvasElement }) => { 23 | const canvas = within(canvasElement); 24 | const loginButton = canvas.getByRole('button', { name: /Log in/i }); 25 | await expect(loginButton).toBeInTheDocument(); 26 | await userEvent.click(loginButton); 27 | await expect(loginButton).not.toBeInTheDocument(); 28 | 29 | const logoutButton = canvas.getByRole('button', { name: /Log out/i }); 30 | await expect(logoutButton).toBeInTheDocument(); 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /src/stories/Page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Header } from './Header'; 4 | import './page.css'; 5 | 6 | type User = { 7 | name: string; 8 | }; 9 | 10 | export const Page: React.FC = () => { 11 | const [user, setUser] = React.useState(); 12 | 13 | return ( 14 |
15 |
setUser({ name: 'Jane Doe' })} 18 | onLogout={() => setUser(undefined)} 19 | onCreateAccount={() => setUser({ name: 'Jane Doe' })} 20 | /> 21 | 22 |
23 |

Pages in Storybook

24 |

25 | We recommend building UIs with a{' '} 26 | 27 | component-driven 28 | {' '} 29 | process starting with atomic components and ending with pages. 30 |

31 |

32 | Render pages with mock data. This makes it easy to build and review page states without 33 | needing to navigate to them in your app. Here are some handy patterns for managing page 34 | data in Storybook: 35 |

36 |
    37 |
  • 38 | Use a higher-level connected component. Storybook helps you compose such data from the 39 | "args" of child component stories 40 |
  • 41 |
  • 42 | Assemble data in the page component from your services. You can mock these services out 43 | using Storybook. 44 |
  • 45 |
46 |

47 | Get a guided tutorial on component-driven development at{' '} 48 | 49 | Storybook tutorials 50 | 51 | . Read more in the{' '} 52 | 53 | docs 54 | 55 | . 56 |

57 |
58 | Tip Adjust the width of the canvas with the{' '} 59 | 60 | 61 | 66 | 67 | 68 | Viewports addon in the toolbar 69 |
70 |
71 |
72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /src/stories/assets/accessibility.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashsajal1/typing-app/bb465576eabd160b2412a633d108ac3943397d79/src/stories/assets/accessibility.png -------------------------------------------------------------------------------- /src/stories/assets/accessibility.svg: -------------------------------------------------------------------------------- 1 | Accessibility -------------------------------------------------------------------------------- /src/stories/assets/addon-library.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashsajal1/typing-app/bb465576eabd160b2412a633d108ac3943397d79/src/stories/assets/addon-library.png -------------------------------------------------------------------------------- /src/stories/assets/assets.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashsajal1/typing-app/bb465576eabd160b2412a633d108ac3943397d79/src/stories/assets/assets.png -------------------------------------------------------------------------------- /src/stories/assets/avif-test-image.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashsajal1/typing-app/bb465576eabd160b2412a633d108ac3943397d79/src/stories/assets/avif-test-image.avif -------------------------------------------------------------------------------- /src/stories/assets/context.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashsajal1/typing-app/bb465576eabd160b2412a633d108ac3943397d79/src/stories/assets/context.png -------------------------------------------------------------------------------- /src/stories/assets/discord.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/stories/assets/docs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashsajal1/typing-app/bb465576eabd160b2412a633d108ac3943397d79/src/stories/assets/docs.png -------------------------------------------------------------------------------- /src/stories/assets/figma-plugin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashsajal1/typing-app/bb465576eabd160b2412a633d108ac3943397d79/src/stories/assets/figma-plugin.png -------------------------------------------------------------------------------- /src/stories/assets/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/stories/assets/share.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashsajal1/typing-app/bb465576eabd160b2412a633d108ac3943397d79/src/stories/assets/share.png -------------------------------------------------------------------------------- /src/stories/assets/styling.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashsajal1/typing-app/bb465576eabd160b2412a633d108ac3943397d79/src/stories/assets/styling.png -------------------------------------------------------------------------------- /src/stories/assets/testing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashsajal1/typing-app/bb465576eabd160b2412a633d108ac3943397d79/src/stories/assets/testing.png -------------------------------------------------------------------------------- /src/stories/assets/theming.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ashsajal1/typing-app/bb465576eabd160b2412a633d108ac3943397d79/src/stories/assets/theming.png -------------------------------------------------------------------------------- /src/stories/assets/tutorials.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/stories/assets/youtube.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/stories/button.css: -------------------------------------------------------------------------------- 1 | .storybook-button { 2 | display: inline-block; 3 | cursor: pointer; 4 | border: 0; 5 | border-radius: 3em; 6 | font-weight: 700; 7 | line-height: 1; 8 | font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 9 | } 10 | .storybook-button--primary { 11 | background-color: #555ab9; 12 | color: white; 13 | } 14 | .storybook-button--secondary { 15 | box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset; 16 | background-color: transparent; 17 | color: #333; 18 | } 19 | .storybook-button--small { 20 | padding: 10px 16px; 21 | font-size: 12px; 22 | } 23 | .storybook-button--medium { 24 | padding: 11px 20px; 25 | font-size: 14px; 26 | } 27 | .storybook-button--large { 28 | padding: 12px 24px; 29 | font-size: 16px; 30 | } 31 | -------------------------------------------------------------------------------- /src/stories/header.css: -------------------------------------------------------------------------------- 1 | .storybook-header { 2 | display: flex; 3 | justify-content: space-between; 4 | align-items: center; 5 | border-bottom: 1px solid rgba(0, 0, 0, 0.1); 6 | padding: 15px 20px; 7 | font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 8 | } 9 | 10 | .storybook-header svg { 11 | display: inline-block; 12 | vertical-align: top; 13 | } 14 | 15 | .storybook-header h1 { 16 | display: inline-block; 17 | vertical-align: top; 18 | margin: 6px 0 6px 10px; 19 | font-weight: 700; 20 | font-size: 20px; 21 | line-height: 1; 22 | } 23 | 24 | .storybook-header button + button { 25 | margin-left: 10px; 26 | } 27 | 28 | .storybook-header .welcome { 29 | margin-right: 10px; 30 | color: #333; 31 | font-size: 14px; 32 | } 33 | -------------------------------------------------------------------------------- /src/stories/page.css: -------------------------------------------------------------------------------- 1 | .storybook-page { 2 | margin: 0 auto; 3 | padding: 48px 20px; 4 | max-width: 600px; 5 | color: #333; 6 | font-size: 14px; 7 | line-height: 24px; 8 | font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 9 | } 10 | 11 | .storybook-page h2 { 12 | display: inline-block; 13 | vertical-align: top; 14 | margin: 0 0 4px; 15 | font-weight: 700; 16 | font-size: 32px; 17 | line-height: 1; 18 | } 19 | 20 | .storybook-page p { 21 | margin: 1em 0; 22 | } 23 | 24 | .storybook-page a { 25 | color: inherit; 26 | } 27 | 28 | .storybook-page ul { 29 | margin: 1em 0; 30 | padding-left: 30px; 31 | } 32 | 33 | .storybook-page li { 34 | margin-bottom: 8px; 35 | } 36 | 37 | .storybook-page .tip { 38 | display: inline-block; 39 | vertical-align: top; 40 | margin-right: 10px; 41 | border-radius: 1em; 42 | background: #e7fdd8; 43 | padding: 4px 12px; 44 | color: #357a14; 45 | font-weight: 700; 46 | font-size: 11px; 47 | line-height: 12px; 48 | } 49 | 50 | .storybook-page .tip-wrapper { 51 | margin-top: 40px; 52 | margin-bottom: 40px; 53 | font-size: 13px; 54 | line-height: 20px; 55 | } 56 | 57 | .storybook-page .tip-wrapper svg { 58 | display: inline-block; 59 | vertical-align: top; 60 | margin-top: 3px; 61 | margin-right: 4px; 62 | width: 12px; 63 | height: 12px; 64 | } 65 | 66 | .storybook-page .tip-wrapper svg path { 67 | fill: #1ea7fd; 68 | } 69 | -------------------------------------------------------------------------------- /src/test/setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { expect, afterEach } from 'vitest'; 3 | import { cleanup } from '@testing-library/react'; 4 | import * as matchers from '@testing-library/jest-dom/matchers'; 5 | 6 | // Extend Vitest's expect method with methods from react-testing-library 7 | expect.extend(matchers); 8 | 9 | // Cleanup after each test case (e.g. clearing jsdom) 10 | afterEach(() => { 11 | cleanup(); 12 | }); -------------------------------------------------------------------------------- /src/types/json.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.json' { 2 | const value: Record; 3 | export default value; 4 | } -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | darkMode: "class", 4 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 5 | theme: { 6 | fontFamily: { 7 | sans: ["Ubuntu", "ui-sans-serif", "system-ui"], 8 | }, 9 | extend: { 10 | animation: { 11 | 'float': 'float 6s ease-in-out infinite', 12 | }, 13 | keyframes: { 14 | float: { 15 | '0%, 100%': { transform: 'translateY(0) rotate(0deg)' }, 16 | '50%': { transform: 'translateY(-20px) rotate(5deg)' }, 17 | } 18 | } 19 | }, 20 | }, 21 | plugins: [require("daisyui")], 22 | daisyui: { 23 | themes: [ 24 | "light", 25 | "dark", 26 | "cupcake", 27 | "bumblebee", 28 | "emerald", 29 | "corporate", 30 | "synthwave", 31 | "retro", 32 | "cyberpunk", 33 | "valentine", 34 | "halloween", 35 | "garden", 36 | "forest", 37 | "aqua", 38 | "lofi", 39 | "pastel", 40 | "fantasy", 41 | "wireframe", 42 | "black", 43 | "luxury", 44 | "dracula", 45 | "cmyk", 46 | "autumn", 47 | "business", 48 | "acid", 49 | "lemonade", 50 | "night", 51 | "coffee", 52 | "winter", 53 | "dim", 54 | "nord", 55 | "sunset", 56 | "caramellatte", 57 | "abyss", 58 | "silk" 59 | ], 60 | }, 61 | }; 62 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import { VitePWA } from "vite-plugin-pwa"; 4 | import { TanStackRouterVite } from "@tanstack/router-vite-plugin"; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [ 9 | react(), 10 | TanStackRouterVite(), 11 | VitePWA({ 12 | registerType: "autoUpdate", 13 | includeAssets: ["favicon.ico", "apple-touch-icon.png", "mask-icon.svg"], 14 | manifest: { 15 | name: "Typing Practice App", 16 | short_name: "Typing Practice App", 17 | theme_color: "#ffffff", 18 | icons: [ 19 | { 20 | src: "pwa-64x64.png", 21 | sizes: "64x64", 22 | type: "image/png", 23 | }, 24 | { 25 | src: "pwa-192x192.png", 26 | sizes: "192x192", 27 | type: "image/png", 28 | }, 29 | { 30 | src: "pwa-512x512.png", 31 | sizes: "512x512", 32 | type: "image/png", 33 | purpose: "any", 34 | }, 35 | { 36 | src: "maskable-icon-512x512.png", 37 | sizes: "512x512", 38 | type: "image/png", 39 | purpose: "maskable", 40 | }, 41 | ], 42 | }, 43 | }), 44 | ], 45 | test: { 46 | globals: true, 47 | environment: 'jsdom', 48 | setupFiles: ['./src/test/setup.ts'], 49 | coverage: { 50 | provider: 'v8', 51 | reporter: ['text', 'json', 'html'], 52 | }, 53 | ui: true, 54 | }, 55 | }); 56 | -------------------------------------------------------------------------------- /vitest.workspace.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { fileURLToPath } from 'node:url'; 3 | 4 | import { defineWorkspace } from 'vitest/config'; 5 | 6 | import { storybookTest } from '@storybook/experimental-addon-test/vitest-plugin'; 7 | 8 | const dirname = 9 | typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); 10 | 11 | // More info at: https://storybook.js.org/docs/writing-tests/test-addon 12 | export default defineWorkspace([ 13 | 'vite.config.ts', 14 | { 15 | extends: 'vite.config.ts', 16 | plugins: [ 17 | // The plugin will run tests for the stories defined in your Storybook config 18 | // See options at: https://storybook.js.org/docs/writing-tests/test-addon#storybooktest 19 | storybookTest({ configDir: path.join(dirname, '.storybook') }), 20 | ], 21 | test: { 22 | name: 'storybook', 23 | browser: { 24 | enabled: true, 25 | headless: true, 26 | name: 'chromium', 27 | provider: 'playwright' 28 | }, 29 | setupFiles: ['.storybook/vitest.setup.ts'], 30 | }, 31 | }, 32 | ]); 33 | --------------------------------------------------------------------------------