├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── feature_request.yml └── workflows │ └── main.yml ├── .gitignore ├── .prettierignore ├── .vscode └── settings.json ├── AI.md ├── CLAUDE.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── SECURITY.md ├── TRANSLATING.md ├── TimedGame.md ├── UI_DESIGN.md ├── app ├── ClientLayout.tsx ├── [locale] │ ├── academy │ │ ├── hiragana-101 │ │ │ └── page.tsx │ │ └── page.tsx │ ├── achievements │ │ └── page.tsx │ ├── kana │ │ ├── [subset] │ │ │ └── page.tsx │ │ ├── loading.tsx │ │ ├── page.tsx │ │ ├── timed-challenge │ │ │ └── page.tsx │ │ └── train │ │ │ └── [gameMode] │ │ │ ├── loading.tsx │ │ │ ├── page.tsx │ │ │ └── timed │ │ │ └── page.tsx │ ├── kanji │ │ ├── loading.tsx │ │ ├── page.tsx │ │ └── train │ │ │ └── [gameMode] │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ ├── layout.tsx │ ├── loading.tsx │ ├── page.tsx │ ├── patch-notes │ │ └── page.tsx │ ├── preferences │ │ ├── loading.tsx │ │ └── page.tsx │ ├── privacy │ │ ├── loading.tsx │ │ └── page.tsx │ ├── progress │ │ └── page.tsx │ ├── sandbox │ │ └── page.tsx │ ├── security │ │ ├── loading.tsx │ │ └── page.tsx │ ├── settings │ │ └── page.tsx │ ├── terms │ │ ├── loading.tsx │ │ └── page.tsx │ └── vocabulary │ │ ├── loading.tsx │ │ ├── page.tsx │ │ └── train │ │ └── [gameMode] │ │ ├── loading.tsx │ │ └── page.tsx ├── favicon.ico ├── globals.css └── layout.tsx ├── canvas-confetti.d.ts ├── components.json ├── components ├── Academy │ ├── Hiragana101.tsx │ └── index.tsx ├── Dojo │ ├── Kana │ │ ├── Game │ │ │ ├── Input.tsx │ │ │ ├── Pick.tsx │ │ │ └── index.tsx │ │ ├── KanaCards │ │ │ ├── Subset.tsx │ │ │ └── index.tsx │ │ ├── SubsetDictionary.tsx │ │ └── TimedChallenge.tsx │ ├── Kanji │ │ ├── Game │ │ │ ├── Input.tsx │ │ │ ├── Pick.tsx │ │ │ └── index.tsx │ │ ├── SetDictionary.tsx │ │ └── index.tsx │ └── Vocab │ │ ├── Game │ │ ├── Input.tsx │ │ ├── Pick.tsx │ │ └── index.tsx │ │ ├── SetDictionary.tsx │ │ └── index.tsx ├── Legal │ ├── Privacy.tsx │ ├── Security.tsx │ └── Terms.tsx ├── MainMenu │ ├── Banner.tsx │ ├── Decorations.tsx │ └── index.tsx ├── Modals │ ├── AchievementModal.tsx │ └── WelcomeModal.tsx ├── PatchNotes.tsx ├── Progress │ ├── AchievementProgress.tsx │ ├── ProgressWithSidebar.tsx │ └── SimpleProgress.tsx ├── Sandbox.tsx ├── Settings │ ├── Backup.tsx │ ├── Behavior.tsx │ ├── Fonts.tsx │ ├── HotkeyReference.tsx │ ├── Themes.tsx │ └── index.tsx ├── analytics │ ├── GoogleAnalytics.tsx │ └── MSClarity.tsx ├── reusable │ ├── AchievementBadge.tsx │ ├── AchievementIntegration.tsx │ ├── AchievementNotification.tsx │ ├── AnimatedCard.tsx │ ├── AudioButton.tsx │ ├── DevNotice.tsx │ ├── FuriganaText.tsx │ ├── Game │ │ ├── Animals.tsx │ │ ├── AnswerSummary.tsx │ │ ├── GameIntel.tsx │ │ ├── ProgressBar.tsx │ │ ├── ReturnFromGame.tsx │ │ ├── Stars.tsx │ │ └── Stats.tsx │ ├── LanguageSelector.tsx │ ├── Link.tsx │ ├── Menu │ │ ├── Banner.tsx │ │ ├── CollectionSelector.tsx │ │ ├── DojoMenu.tsx │ │ ├── GameModes.tsx │ │ ├── Info.tsx │ │ ├── Sidebar.tsx │ │ └── TopBar.tsx │ ├── PostWrapper.tsx │ ├── SSRAudioButton.tsx │ ├── Skeletons │ │ └── Loader.tsx │ └── Timer │ │ └── GoalTimersPanel.tsx └── ui │ ├── button.tsx │ └── select.tsx ├── docs ├── ACHIEVEMENTS.md ├── UI_DESIGN.md └── translations │ ├── README.de.md │ ├── README.es.md │ ├── README.fr.md │ ├── README.hin.md │ ├── README.pt-br.md │ ├── README.tr.md │ ├── README.zh-CN.md │ └── README.zh-tw.md ├── eslint.config.mjs ├── global.d.ts ├── hooks ├── useAchievements.ts ├── useAudio.ts ├── useGoalTimers.ts ├── useGridColumns.ts ├── useInfiniteConfetti.tsx ├── useJapaneseTTS.ts ├── useStats.tsx ├── useTimer.ts └── useTranslation.ts ├── i18n ├── config.ts ├── request.ts └── routing.ts ├── lib ├── backup.ts ├── flattenKanaGroup.ts ├── fontawesome.ts ├── generateKanaQuestions.ts ├── helperFunctions.ts ├── interfaces.ts ├── keyMappings.ts ├── pathUtils.ts └── utils.ts ├── middleware.ts ├── next-sitemap.config.js ├── next.config.ts ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── googlec56b4e4a94af22ad.html ├── manifest.json ├── robots.txt ├── sitemap-0.xml ├── sitemap.xml ├── sounds │ ├── click │ │ ├── click4 │ │ │ ├── click4_11.wav │ │ │ ├── click4_22.wav │ │ │ ├── click4_33.wav │ │ │ ├── click4_44.wav │ │ │ ├── click4_55.wav │ │ │ └── click4_66.wav │ │ └── click9 │ │ │ ├── click9_1.wav │ │ │ ├── click9_2.wav │ │ │ ├── click9_3.wav │ │ │ ├── click9_4.wav │ │ │ └── click9_5.wav │ ├── correct.wav │ ├── error │ │ ├── error1 │ │ │ └── error1_1.wav │ │ └── error2 │ │ │ └── error2_1.wav │ └── long.wav └── wallpapers │ └── neonretrocarcity.jpg ├── static ├── about.ts ├── academy │ └── hiraganaBlogPost.ts ├── cloze │ └── n5 │ │ └── nouns.json ├── fonts.ts ├── icons.tsx ├── info.tsx ├── kana.ts ├── kanji │ ├── N1.ts │ ├── N2.ts │ ├── N3.ts │ ├── N4.ts │ └── N5.ts ├── legal │ ├── privacyPolicy.ts │ ├── securityPolicy.ts │ └── termsOfService.ts ├── patchNotes.ts ├── styles.ts ├── themes.ts ├── unitSets.ts └── vocab │ ├── n1.json │ ├── n2.json │ ├── n2 │ └── nouns.ts │ ├── n3.json │ ├── n3 │ └── nouns.ts │ ├── n4.json │ ├── n4 │ └── nouns.ts │ ├── n5.json │ └── n5 │ └── nouns.ts ├── store ├── useAchievementStore.ts ├── useKanaStore.ts ├── useKanjiStore.ts ├── useOnboardingStore.ts ├── usePreferencesStore.ts ├── useStatsStore.ts └── useVocabStore.ts ├── tailwind.config.js ├── todo.txt ├── translations ├── README.ar.md ├── en.json └── es.json └── tsconfig.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: kanadojo 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Create a report to help us improve 3 | title: "[BUG] " 4 | labels: [] 5 | assignees: [] 6 | body: 7 | - type: textarea 8 | id: description 9 | attributes: 10 | label: "Describe the bug" 11 | description: "A clear and concise description of what the bug is." 12 | placeholder: "When I try to create a new page, I get a 404 error." 13 | validations: 14 | required: true 15 | - type: textarea 16 | id: to-reproduce 17 | attributes: 18 | label: "To Reproduce" 19 | description: "Steps to reproduce the behavior:" 20 | placeholder: | 21 | 1. Go to the dashboard 22 | 2. Click on 'New Page' 23 | 3. See error 24 | validations: 25 | required: true 26 | - type: textarea 27 | id: expected-behavior 28 | attributes: 29 | label: "Expected behavior" 30 | description: "A clear and concise description of what you expected to happen." 31 | placeholder: "A new page should be created and I should be redirected to it." 32 | validations: 33 | required: true 34 | - type: textarea 35 | id: screenshots 36 | attributes: 37 | label: "Screenshots" 38 | description: "If applicable, add screenshots to help explain your problem." 39 | - type: textarea 40 | id: desktop 41 | attributes: 42 | label: "Desktop" 43 | placeholder: | 44 | - OS: [e.g. iOS] 45 | - Browser [e.g. chrome, safari] 46 | - Version [e.g. 22] 47 | - type: textarea 48 | id: smartphone 49 | attributes: 50 | label: "Smartphone" 51 | placeholder: | 52 | - Device: [e.g. iPhone6] 53 | - OS: [e.g. iOS8.1] 54 | - Browser [e.g. stock browser, safari] 55 | - Version [e.g. 22] 56 | - type: textarea 57 | id: additional-context 58 | attributes: 59 | label: "Additional context" 60 | description: "Add any other context about the problem here." 61 | placeholder: "This happens when using the newPagePopup.jsx component. The error appears to be in the createPage.controller.js file, which is not handling the request correctly." -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea for this project 3 | title: "[FEATURE] " 4 | labels: [] 5 | assignees: [] 6 | body: 7 | - type: textarea 8 | id: related-problem 9 | attributes: 10 | label: Is your feature request related to a problem? Please describe. 11 | description: A clear and concise description of what the problem is. 12 | placeholder: I'm always frustrated when I have to manually save the page every time I make a change. 13 | validations: 14 | required: true 15 | - type: textarea 16 | id: solution 17 | attributes: 18 | label: Describe the solution you'd like 19 | description: A clear and concise description of what you want to happen. 20 | placeholder: I would like the page to be saved automatically every 10 seconds. 21 | - type: textarea 22 | id: alternatives 23 | attributes: 24 | label: Describe alternatives you've considered 25 | description: A clear and concise description of any alternative solutions or features you've considered. 26 | placeholder: I've considered using the useSavePageOnChange.js hook, but it only saves when the content changes, not periodically. 27 | - type: textarea 28 | id: additional-context 29 | attributes: 30 | label: Additional context 31 | description: Add any other context or screenshots about the feature request here. 32 | placeholder: This would be a great improvement for the user experience. -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Sync preview with main 2 | 3 | on: 4 | push: 5 | branches: 6 | - main # Trigger when main branch is updated 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | sync-branch: 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 30 15 | 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 # Ensures full history is available 21 | 22 | - name: Push main branch to preview 23 | run: | 24 | git config --global user.name "GitHub Actions" 25 | git config --global user.email "actions@github.com" 26 | 27 | # Fetch the latest branches 28 | git fetch origin 29 | 30 | # Create or update 'preview' branch with the latest main branch 31 | git checkout -B preview origin/main 32 | 33 | # Push the preview branch forcefully to ensure it matches main exactly 34 | git push --force origin preview 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /~ 5 | /node_modules 6 | /.pnp 7 | .pnp.* 8 | .yarn/* 9 | !.yarn/patches 10 | !.yarn/plugins 11 | !.yarn/releases 12 | !.yarn/versions 13 | 14 | # testing 15 | /coverage 16 | 17 | # next.js 18 | /.next/ 19 | /out/ 20 | 21 | # production 22 | /build 23 | 24 | # misc 25 | .DS_Store 26 | *.pem 27 | 28 | # debug 29 | npm-debug.log* 30 | yarn-debug.log* 31 | yarn-error.log* 32 | .pnpm-debug.log* 33 | 34 | # env files (can opt-in for committing if needed) 35 | .env* 36 | 37 | # vercel 38 | .vercel 39 | 40 | # typescript 41 | *.tsbuildinfo 42 | next-env.d.ts 43 | 44 | .claude 45 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | .next/ 4 | out/ 5 | dist/ 6 | build/ 7 | 8 | # Production 9 | *.min.js 10 | 11 | # Environment variables 12 | .env 13 | .env*.local 14 | 15 | # Logs 16 | *.log 17 | npm-debug.log* 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage/ 27 | 28 | # OS generated files 29 | .DS_Store 30 | .DS_Store? 31 | ._* 32 | .Spotlight-V100 33 | .Trashes 34 | ehthumbs.db 35 | Thumbs.db 36 | 37 | # IDE 38 | .vscode/ 39 | .idea/ 40 | *.swp 41 | *.swo 42 | 43 | # Next.js 44 | .next/ 45 | 46 | # Temporary folders 47 | tmp/ 48 | temp/ -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | } -------------------------------------------------------------------------------- /AI.md: -------------------------------------------------------------------------------- 1 | # AI.md 2 | 3 | This file provides guidance to GitHub Copilot (https://github.com/features/copilot) and other AI models when working with code in this repository for them to effectively understand the structure, conventions and purpose of each directory and function within the repository. When generating any code or other changes to the project, these guidelines should be followed: 4 | 5 | ## Project Overview 6 | 7 | KanaDojo is a Next.js-based Japanese learning application focused on training Kana (Hiragana/Katakana), Kanji, and Vocabulary. The app provides multiple game modes and customizable practice sessions. 8 | 9 | ## Development Commands 10 | 11 | ### Core Commands 12 | - `npm run dev` - Start development server 13 | - `npm run build` - Build for production 14 | - `npm run postbuild` - Generates sitemap after build 15 | - `npm run start` - Start production server 16 | - `npm run lint` - Run ESLint 17 | 18 | ### Testing 19 | No test framework is configured in this project. 20 | 21 | ## Architecture 22 | 23 | ### App Structure 24 | This is a Next.js 15 app using the App Router with TypeScript and React 19. 25 | 26 | **Key Directories:** 27 | - `/app` - Next.js App Router pages and layouts 28 | - `/components` - React components organized by feature 29 | - `/lib` - Utilities, hooks, interfaces, and helper functions 30 | - `/store` - Zustand stores for global state management 31 | - `/public` - Static assets including sounds and wallpapers 32 | 33 | ### Routing Structure 34 | - `/` - Main menu 35 | - `/kana` - Kana selection and training 36 | - `/kana/train/[gameMode]` - Kana game modes 37 | - `/kanji` - Kanji selection and training 38 | - `/kanji/train/[gameMode]` - Kanji game modes 39 | - `/vocabulary` - Vocabulary selection and training 40 | - `/vocabulary/train/[gameMode]` - Vocabulary game modes 41 | - `/academy` - Educational content 42 | - `/preferences` - Settings page 43 | - Legal pages: `/terms`, `/privacy`, `/security` 44 | 45 | ### State Management 46 | Uses Zustand for state management with these main stores: 47 | - `useKanaKanjiStore` - Manages Kana/Kanji selections and game modes 48 | - `useVocabStore` - Manages vocabulary selections and game modes 49 | - `useStatsStore` - Manages game statistics and progress 50 | - `useThemeStore` - Manages UI themes and preferences 51 | 52 | ### Component Organization 53 | - `components/Dojo/` - Game-specific components for each content type (Kana, Kanji, Vocab) 54 | - `components/reusable/` - Shared components used across the app 55 | - `components/ui/` - Basic UI components (shadcn/ui) 56 | - `components/Settings/` - Preference and configuration components 57 | 58 | ### Game Modes 59 | Each content type (Kana, Kanji, Vocabulary) supports four game modes: 60 | - **Pick** - Multiple choice selection 61 | - **Reverse-Pick** - Reverse multiple choice 62 | - **Input** - Text input mode 63 | - **Reverse-Input** - Reverse text input mode 64 | 65 | ### Styling & UI 66 | - **Tailwind CSS** for styling 67 | - **shadcn/ui** components 68 | - **FontAwesome** icons via React FontAwesome 69 | - **Framer Motion** (`motion` package) for animations 70 | - Custom theme system with multiple color schemes 71 | 72 | ### Audio System 73 | - Custom audio hooks in `lib/hooks/useAudio.ts` 74 | - Sound files organized in `/public/sounds/` 75 | - Supports click sounds, correct/error feedback, and achievement sounds 76 | 77 | ### Key Libraries 78 | - **Zustand** - State management 79 | - **clsx** + **tailwind-merge** - Conditional styling 80 | - **canvas-confetti** - Celebration effects 81 | - **react-timer-hook** - Timer functionality 82 | - **use-sound** - Audio playback 83 | - **react-markdown** - Markdown rendering for content 84 | - **random-js** - Random number generation 85 | 86 | ## Development Patterns 87 | 88 | ### Component Structure 89 | Components follow a consistent pattern: 90 | - Use TypeScript interfaces from `lib/interfaces.ts` 91 | - Leverage Zustand stores for state 92 | - Implement responsive design with Tailwind 93 | - Use custom hooks from `lib/hooks/` 94 | 95 | ### Path Aliases 96 | - `@/*` maps to project root for clean imports 97 | 98 | ### Data Flow 99 | 1. User selects content in menu components 100 | 2. Selections stored in Zustand stores 101 | 3. Game components read from stores to render questions 102 | 4. Stats tracked and stored for progress monitoring 103 | 104 | ### File Naming 105 | - Components use PascalCase 106 | - Hooks use camelCase with `use` prefix 107 | - Stores use camelCase with `use` prefix and `Store` suffix 108 | - Pages follow Next.js App Router conventions 109 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | KanaDojo is a Next.js-based Japanese learning application focused on training Kana (Hiragana/Katakana), Kanji, and Vocabulary. The app provides multiple game modes and customizable practice sessions. 8 | 9 | ## Development Commands 10 | 11 | ### Core Commands 12 | - `npm run dev` - Start development server 13 | - `npm run build` - Build for production 14 | - `npm run postbuild` - Generates sitemap after build 15 | - `npm run start` - Start production server 16 | - `npm run lint` - Run ESLint 17 | 18 | ### Testing 19 | No test framework is configured in this project. 20 | 21 | ## Architecture 22 | 23 | ### App Structure 24 | This is a Next.js 15 app using the App Router with TypeScript and React 19. 25 | 26 | **Key Directories:** 27 | - `/app` - Next.js App Router pages and layouts 28 | - `/components` - React components organized by feature 29 | - `/lib` - Utilities, hooks, interfaces, and helper functions 30 | - `/store` - Zustand stores for global state management 31 | - `/public` - Static assets including sounds and wallpapers 32 | 33 | ### Routing Structure 34 | - `/` - Main menu 35 | - `/kana` - Kana selection and training 36 | - `/kana/train/[gameMode]` - Kana game modes 37 | - `/kanji` - Kanji selection and training 38 | - `/kanji/train/[gameMode]` - Kanji game modes 39 | - `/vocabulary` - Vocabulary selection and training 40 | - `/vocabulary/train/[gameMode]` - Vocabulary game modes 41 | - `/academy` - Educational content 42 | - `/preferences` - Settings page 43 | - Legal pages: `/terms`, `/privacy`, `/security` 44 | 45 | ### State Management 46 | Uses Zustand for state management with these main stores: 47 | - `useKanaKanjiStore` - Manages Kana/Kanji selections and game modes 48 | - `useVocabStore` - Manages vocabulary selections and game modes 49 | - `useStatsStore` - Manages game statistics and progress 50 | - `useThemeStore` - Manages UI themes and preferences 51 | 52 | ### Component Organization 53 | - `components/Dojo/` - Game-specific components for each content type (Kana, Kanji, Vocab) 54 | - `components/reusable/` - Shared components used across the app 55 | - `components/ui/` - Basic UI components (shadcn/ui) 56 | - `components/Settings/` - Preference and configuration components 57 | 58 | ### Game Modes 59 | Each content type (Kana, Kanji, Vocabulary) supports four game modes: 60 | - **Pick** - Multiple choice selection 61 | - **Reverse-Pick** - Reverse multiple choice 62 | - **Input** - Text input mode 63 | - **Reverse-Input** - Reverse text input mode 64 | 65 | ### Styling & UI 66 | - **Tailwind CSS** for styling 67 | - **shadcn/ui** components 68 | - **FontAwesome** icons via React FontAwesome 69 | - **Framer Motion** (`motion` package) for animations 70 | - Custom theme system with multiple color schemes 71 | 72 | ### Audio System 73 | - Custom audio hooks in `lib/hooks/useAudio.ts` 74 | - Sound files organized in `/public/sounds/` 75 | - Supports click sounds, correct/error feedback, and achievement sounds 76 | 77 | ### Key Libraries 78 | - **Zustand** - State management 79 | - **clsx** + **tailwind-merge** - Conditional styling 80 | - **canvas-confetti** - Celebration effects 81 | - **react-timer-hook** - Timer functionality 82 | - **use-sound** - Audio playback 83 | - **react-markdown** - Markdown rendering for content 84 | - **random-js** - Random number generation 85 | 86 | ## Development Patterns 87 | 88 | ### Component Structure 89 | Components follow a consistent pattern: 90 | - Use TypeScript interfaces from `lib/interfaces.ts` 91 | - Leverage Zustand stores for state 92 | - Implement responsive design with Tailwind 93 | - Use custom hooks from `lib/hooks/` 94 | 95 | ### Path Aliases 96 | - `@/*` maps to project root for clean imports 97 | 98 | ### Data Flow 99 | 1. User selects content in menu components 100 | 2. Selections stored in Zustand stores 101 | 3. Game components read from stores to render questions 102 | 4. Stats tracked and stored for progress monitoring 103 | 104 | ### File Naming 105 | - Components use PascalCase 106 | - Hooks use camelCase with `use` prefix 107 | - Stores use camelCase with `use` prefix and `Store` suffix 108 | - Pages follow Next.js App Router conventions -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | For vulnerabilities that impact the confidentiality, integrity, and availability of Monkeytype services, please send your disclosure via 5 | email. 6 | Include as much detail as possible to ensure reproducibility. At a minimum, vulnerability disclosures should include: 7 | - Vulnerability Description 8 | - Proof of Concept 9 | - Impact 10 | - Screenshots or Proof 11 | 12 | If you discover a security vulnerability in this project, please report it responsibly: 13 | - **Report via:** [lingdojo.dev@gmail.com](mailto:lingdojo.dev@gmail.com), open a **issue** on this repository or contact the developer directly through our Discord server. 14 | - **Response time:** We aim to acknowledge all reports within **48 hours**. 15 | - **Next steps:** Once verified, we will provide a timeline for a fix. If the report is declined, we will provide an explanation. 16 | - **Confidentiality:** Please do **not publicly disclose** the actual vulnerability itself until a fix has been released. 17 | 18 | We take security seriously and will work to ensure all users remain protected. 19 | 20 | ## Notes 21 | Please do not engage in activities that might cause a denial of service condition, create significant strains on critical resources, or negatively impact users of our website. 22 | -------------------------------------------------------------------------------- /TRANSLATING.md: -------------------------------------------------------------------------------- 1 | # This is the basic directories, files and workflow to translate KanaDojo using `next-intl` internationalization tool. 2 | 3 | ## Packages. 4 | 5 | We use `next-intl` internationalization tool in this project to manage translations into several languages. 6 | 7 | ## Directories and files. 8 | 9 | Translated text content is staticly stored as key-value pairs properties of a single object using only one file per language under **/translations/ directory** and it must follow a two letters language code just like `es for Spanish` or `en for English` and its extension must be .json 10 | 11 | You can go to the translations directory and see already partial translations sitting there. 12 | 13 | `Translation request helper` is the function defined in **/i18n/request.ts** and it helps retrieving the translated text on demand. Every time we need to render a translated text through `next-intl implementation` this is the function solving the translation. Its conventions are mandatory since they are keys for the `next-intl` workflow. 14 | 15 | This **/i18n/** directory will host more essential functions for the `translation management system` as we implement workflows and strategies to make translation and localization smooth for our users. 16 | 17 | ## Implementation. 18 | 19 | The described `initial translation management system` enables the developer to change: 20 | 21 | `

Welcome to KanaDojo!

` 22 | 23 | to 24 | 25 | `

{t('greeting')}

` 26 | 27 | being 28 | 29 | `t = useTranslations('query')` **for server components** 30 | 31 | and 32 | 33 | `t = getTranslations('query')` **for client components.** 34 | 35 | Above functions are provided by `next-inl` package and **can only be used inside of react functions.** 36 | 37 | ## Example. 38 | 39 | if **/translations/es.json** holds: 40 | 41 | `{ "MenuInfo": { "greeting": "¡Bienvenido a KanaDojo!"...} }` 42 | 43 | the usage of `const t = useTranslations('MenuInfo')` will store the queried property and all of its nested properties can be thus accessed in `t`. 44 | 45 | The developer can now implement 46 | 47 | `

{t('greeting')}

` 48 | 49 | and expect 50 | 51 | `

¡Bienvenido a KanaDojo!

` 52 | 53 | to be actually rendered if the locale of Spanish 'es' is provided by the system. 54 | -------------------------------------------------------------------------------- /TimedGame.md: -------------------------------------------------------------------------------- 1 | # 🕒 Timed Challenge Mode — KanaDojo 2 | 3 | The **Timed Challenge Mode** is a fast-paced training feature that tests your kana recognition speed and accuracy under pressure. It complements existing modes like Pick, Reverse-Pick, Input, and Reverse-Input with a time-bound twist. 4 | 5 | ## ✨ Features 6 | 7 | - ⏱ 60-second challenge window 8 | - 🎯 Real-time scoring with streak tracking 9 | - 🧠 Instant feedback on each answer 10 | - 📊 Separate stat tracking for timed mode (correct, incorrect, streak) 11 | - 🔁 Retry option with full stat reset 12 | 13 | ## 📁 File Structure 14 | 15 | | File | Purpose | 16 | |------|---------| 17 | | `lib/generateKanaQuestion.ts` | Utility to randomly select kana from user’s selection | 18 | | `store/useStatsStore.ts` | Extended Zustand store with timed stats | 19 | | `components/Dojo/Kana/TimedChallenge.tsx` | Main game component with timer, input, and scoring | 20 | | `app/kana/train/timed/page.tsx` | App Router entry point for timed mode | 21 | 22 | ## 🧠 How It Works 23 | 24 | 1. User selects kana characters from the Kana dojo. 25 | 2. On starting the challenge, a 60-second timer begins. 26 | 3. One kana character is shown at a time. 27 | 4. User types the correct romaji and submits. 28 | 5. Stats update in real time. 29 | 6. When time runs out, a summary screen appears with retry option. 30 | 31 | ## 🛠 Developer Notes 32 | 33 | - Stats are tracked separately from regular modes to avoid overlap. 34 | - Timer logic is handled via `useChallengeTimer` hook. 35 | - Component is modular and can be extended to Kanji/Vocab dojos easily. 36 | - All state updates are handled via Zustand for consistency. 37 | 38 | --- 39 | 40 | > PR: Adds Timed Challenge mode for Kana training. Includes stat tracking, timer logic, and App Router integration. 41 | -------------------------------------------------------------------------------- /app/ClientLayout.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import clsx from 'clsx'; 3 | // 1. Import useState to hold the dynamically imported module 4 | import { useEffect, useState } from 'react'; 5 | import usePreferencesStore from '@/store/usePreferencesStore'; 6 | // 2. Remove the static import 7 | // import fonts from '@/static/fonts'; 8 | import { ScrollRestoration } from 'next-scroll-restoration'; 9 | import WelcomeModal from '@/components/Modals/WelcomeModal'; 10 | import { AchievementNotificationContainer } from '@/components/reusable/AchievementNotification'; 11 | import AchievementIntegration from '@/components/reusable/AchievementIntegration'; 12 | import { applyTheme } from '@/static/themes'; 13 | 14 | // Define a type for the font object for clarity, adjust as needed 15 | type FontObject = { 16 | name: string; 17 | font: { 18 | className: string; 19 | }; 20 | }; 21 | 22 | export default function ClientLayout({ 23 | children 24 | }: Readonly<{ 25 | children: React.ReactNode; 26 | }>) { 27 | const { theme } = usePreferencesStore(); 28 | const font = usePreferencesStore(state => state.font); 29 | 30 | // 3. Create state to hold the fonts module 31 | const [fontsModule, setFontsModule] = useState(null); 32 | 33 | // 4. Dynamically import the fonts module only in production 34 | useEffect(() => { 35 | if (process.env.NODE_ENV === 'production') { 36 | import('@/static/fonts') 37 | .then(module => { 38 | // Assuming 'fonts' is a default export from that module 39 | setFontsModule(module.default); 40 | }) 41 | .catch(err => { 42 | console.error('Failed to dynamically load fonts:', err); 43 | }); 44 | } 45 | }, []); // Empty dependency array ensures this runs only once on mount 46 | 47 | // 5. Calculate fontClassName based on the stateful fontsModule 48 | // This will be an empty string if fontsModule is null (i.e., in dev or before prod load) 49 | const fontClassName = fontsModule 50 | ? fontsModule.find(fontObj => font === fontObj.name)?.font.className 51 | : ''; 52 | 53 | useEffect(() => { 54 | applyTheme(theme); // This now sets both CSS variables AND data-theme attribute 55 | 56 | if (typeof window !== 'undefined') { 57 | window.history.scrollRestoration = 'manual'; 58 | } 59 | }, [theme]); 60 | 61 | useEffect(() => { 62 | // Resume AudioContext on first user interaction 63 | const handleClick = () => { 64 | // @ts-expect-error (use-sound exposes Howler globally) 65 | if (window.Howler?.ctx?.state === 'suspended') { 66 | // @ts-expect-error (use-sound exposes Howler globally) 67 | window.Howler.ctx.resume(); 68 | } 69 | }; 70 | 71 | document.addEventListener('click', handleClick, { once: true }); 72 | return () => document.removeEventListener('click', handleClick); 73 | }, []); 74 | 75 | return ( 76 |
89 | {children} 90 | 91 | 92 | 93 | 94 |
95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /app/[locale]/academy/hiragana-101/page.tsx: -------------------------------------------------------------------------------- 1 | import Hiragana101 from '@/components/Academy/Hiragana101'; 2 | 3 | export default function Hiragana101Page() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/[locale]/academy/page.tsx: -------------------------------------------------------------------------------- 1 | import Academy from '@/components/Academy'; 2 | 3 | export default function AcademyPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/[locale]/achievements/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from 'next'; 2 | import AchievementProgress from '@/components/Progress/AchievementProgress'; 3 | import Sidebar from '@/components/reusable/Menu/Sidebar'; 4 | 5 | export const metadata: Metadata = { 6 | title: 'Achievements - KanaDojo', 7 | description: 'Track your learning progress and unlock achievements in KanaDojo', 8 | }; 9 | 10 | export default function AchievementsPage() { 11 | return ( 12 |
13 | 14 |
15 | 16 |
17 |
18 | ); 19 | } -------------------------------------------------------------------------------- /app/[locale]/kana/[subset]/page.tsx: -------------------------------------------------------------------------------- 1 | import SubsetDictionary from '@/components/Dojo/Kana/SubsetDictionary'; 2 | 3 | export default function KanaSubsetDictionaryPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/[locale]/kana/loading.tsx: -------------------------------------------------------------------------------- 1 | import Loader from '@/components/reusable/Skeletons/Loader'; 2 | 3 | export default function KanaMenuLoaderPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/[locale]/kana/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import DojoMenu from '@/components/reusable/Menu/DojoMenu'; 3 | 4 | export const metadata: Metadata = { 5 | title: 'KanaDojo: Kana', 6 | description: 7 | 'The kana dojo is the place where you can learn and practice the two core syllabaries of Japanese - Hiragana and Katakana.', 8 | openGraph: { 9 | title: 'KanaDojo: Kana', 10 | description: 11 | 'The kana dojo is the place where you can learn and practice the two core syllabaries of Japanese - Hiragana and Katakana.', 12 | url: 'https://kanadojo.com/kana', 13 | type: 'website', 14 | locale: 'en_US', 15 | }, 16 | }; 17 | 18 | export default function KanaPage() { 19 | return ; 20 | } 21 | -------------------------------------------------------------------------------- /app/[locale]/kana/timed-challenge/page.tsx: -------------------------------------------------------------------------------- 1 | import TimedChallengeKana from '@/components/Dojo/Kana/TimedChallenge'; 2 | import type { Metadata } from 'next'; 3 | 4 | export const metadata: Metadata = { 5 | title: 'KanaDojo: Timed Challenge', 6 | description: 7 | 'Test your kana recognition skills in a 60-second timed challenge. Race against the clock and see how many kana you can correctly identify!', 8 | openGraph: { 9 | title: 'KanaDojo: Timed Challenge', 10 | description: 11 | 'Test your kana recognition skills in a 60-second timed challenge. Race against the clock and see how many kana you can correctly identify!', 12 | url: 'https://kanadojo.com/kana/timed-challenge', 13 | type: 'website', 14 | locale: 'en_US', 15 | }, 16 | }; 17 | 18 | export default function TimedChallengePage() { 19 | return ; 20 | } 21 | -------------------------------------------------------------------------------- /app/[locale]/kana/train/[gameMode]/loading.tsx: -------------------------------------------------------------------------------- 1 | import Loader from '@/components/reusable/Skeletons/Loader'; 2 | 3 | export default function KanaTrainLoaderPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/[locale]/kana/train/[gameMode]/page.tsx: -------------------------------------------------------------------------------- 1 | import KanaGame from '@/components/Dojo/Kana/Game'; 2 | 3 | export default function Train() { 4 | return ; 5 | } 6 | 7 | -------------------------------------------------------------------------------- /app/[locale]/kana/train/[gameMode]/timed/page.tsx: -------------------------------------------------------------------------------- 1 | import TimedChallengeKana from '@/components/Dojo/Kana/TimedChallenge'; 2 | 3 | export default function TimedKanaPage() { 4 | return ( 5 |
6 |

Timed Challenge: Kana

7 | 8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /app/[locale]/kanji/loading.tsx: -------------------------------------------------------------------------------- 1 | import Loader from '@/components/reusable/Skeletons/Loader'; 2 | 3 | export default function KanjiLoaderPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/[locale]/kanji/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import DojoMenu from '@/components/reusable/Menu/DojoMenu'; 3 | 4 | export const metadata: Metadata = { 5 | title: 'KanaDojo: Kanji', 6 | description: 7 | 'The kanji dojo is the place where you can learn and practice the main component of the Japanese writing system - the kanji characters.', 8 | openGraph: { 9 | title: 'KanaDojo: Kanji', 10 | description: 11 | 'The kanji dojo is the place where you can learn and practice the main component of the Japanese writing system - the kanji characters.', 12 | url: 'https://kanadojo.com/kanji', 13 | type: 'website', 14 | locale: 'en_US', 15 | }, 16 | }; 17 | 18 | export default function KanjiPage() { 19 | return ; 20 | } 21 | -------------------------------------------------------------------------------- /app/[locale]/kanji/train/[gameMode]/loading.tsx: -------------------------------------------------------------------------------- 1 | import Loader from '@/components/reusable/Skeletons/Loader'; 2 | 3 | export default function KanjiTrainLoaderPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/[locale]/kanji/train/[gameMode]/page.tsx: -------------------------------------------------------------------------------- 1 | import KanjiGame from '@/components/Dojo/Kanji/Game'; 2 | 3 | export default function Train() { 4 | return ; 5 | } 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/[locale]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from 'next/navigation'; 2 | import { routing } from '@/i18n/routing'; 3 | import { NextIntlClientProvider } from 'next-intl'; 4 | import { getMessages } from 'next-intl/server'; 5 | import ClientLayout from '../ClientLayout'; 6 | 7 | type Locale = 'en' | 'es'; 8 | 9 | export function generateStaticParams() { 10 | return routing.locales.map(locale => ({ locale })); 11 | } 12 | 13 | interface LocaleLayoutProps { 14 | children: React.ReactNode; 15 | params: Promise<{ locale: string }>; 16 | } 17 | 18 | export default async function LocaleLayout({ 19 | children, 20 | params 21 | }: LocaleLayoutProps) { 22 | // Await params in Next.js 23 | const { locale } = await params; 24 | 25 | // Validate that the locale is valid 26 | if (!routing.locales.includes(locale as Locale)) { 27 | notFound(); 28 | } 29 | 30 | // Providing all messages to the client with explicit locale 31 | const messages = await getMessages({ locale }); 32 | 33 | return ( 34 | 35 | {children} 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /app/[locale]/loading.tsx: -------------------------------------------------------------------------------- 1 | import Loader from '@/components/reusable/Skeletons/Loader'; 2 | 3 | export default function MainMenuLoaderPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/[locale]/page.tsx: -------------------------------------------------------------------------------- 1 | import MainMenu from '@/components/MainMenu'; 2 | 3 | export default function Home() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/[locale]/patch-notes/page.tsx: -------------------------------------------------------------------------------- 1 | import PatchNotes from '@/components/PatchNotes'; 2 | 3 | export default function PatchNotesPage() { 4 | return ; 5 | } 6 | 7 | -------------------------------------------------------------------------------- /app/[locale]/preferences/loading.tsx: -------------------------------------------------------------------------------- 1 | import Loader from '@/components/reusable/Skeletons/Loader'; 2 | 3 | export default function PreferencesLoaderPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/[locale]/preferences/page.tsx: -------------------------------------------------------------------------------- 1 | import Settings from '@/components/Settings'; 2 | 3 | export default function SettingsPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/[locale]/privacy/loading.tsx: -------------------------------------------------------------------------------- 1 | import Loader from '@/components/reusable/Skeletons/Loader'; 2 | 3 | export default function PrivacyLoaderPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/[locale]/privacy/page.tsx: -------------------------------------------------------------------------------- 1 | import PrivacyPolicy from '@/components/Legal/Privacy'; 2 | 3 | export default function PrivacyPolicyPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/[locale]/progress/page.tsx: -------------------------------------------------------------------------------- 1 | import ProgressWithSidebar from '@/components/Progress/ProgressWithSidebar'; 2 | import type { Metadata } from 'next'; 3 | 4 | export const metadata: Metadata = { 5 | title: 'KanaDojo: Progress', 6 | description: 'Track your Japanese learning progress and see detailed statistics.', 7 | }; 8 | 9 | export default function ProgressPage() { 10 | return ; 11 | } -------------------------------------------------------------------------------- /app/[locale]/sandbox/page.tsx: -------------------------------------------------------------------------------- 1 | import Sandbox from '@/components/Sandbox'; 2 | 3 | export default function SandboxPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/[locale]/security/loading.tsx: -------------------------------------------------------------------------------- 1 | import Loader from '@/components/reusable/Skeletons/Loader'; 2 | 3 | export default function SecurityLoaderPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/[locale]/security/page.tsx: -------------------------------------------------------------------------------- 1 | import SecurityPolicy from '@/components/Legal/Security'; 2 | 3 | export default function SecurityPolicyPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/[locale]/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import Settings from '@/components/Settings'; 2 | 3 | export default function SettingsAlias() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/[locale]/terms/loading.tsx: -------------------------------------------------------------------------------- 1 | import Loader from '@/components/reusable/Skeletons/Loader'; 2 | 3 | export default function TermsLoaderPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/[locale]/terms/page.tsx: -------------------------------------------------------------------------------- 1 | import TermsOfService from '@/components/Legal/Terms'; 2 | 3 | export default function TermsOfServicePage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/[locale]/vocabulary/loading.tsx: -------------------------------------------------------------------------------- 1 | import Loader from '@/components/reusable/Skeletons/Loader'; 2 | 3 | export default function VocabLoaderPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/[locale]/vocabulary/page.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import DojoMenu from '@/components/reusable/Menu/DojoMenu'; 3 | 4 | export const metadata: Metadata = { 5 | title: 'KanaDojo: Vocabulary', 6 | description: 7 | 'The vocabulary dojo is the place where you can learn and practice the words and vocabulary used in day-to-day Japanese.', 8 | openGraph: { 9 | title: 'KanaDojo: Vocabulary', 10 | description: 11 | 'The vocabulary dojo is the place where you can learn and practice the words and vocabulary used in day-to-day Japanese.', 12 | url: 'https://kanadojo.com/vocabulary', 13 | type: 'website', 14 | locale: 'en_US' 15 | } 16 | }; 17 | 18 | export const viewport = { 19 | width: 'device-width', 20 | initialScale: 1 21 | }; 22 | 23 | export default function VocabPage() { 24 | return ; 25 | } 26 | -------------------------------------------------------------------------------- /app/[locale]/vocabulary/train/[gameMode]/loading.tsx: -------------------------------------------------------------------------------- 1 | import Loader from '@/components/reusable/Skeletons/Loader'; 2 | 3 | export default function VocabTrainLoaderPage() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/[locale]/vocabulary/train/[gameMode]/page.tsx: -------------------------------------------------------------------------------- 1 | import VocabGame from '@/components/Dojo/Vocab/Game'; 2 | 3 | export default function Train() { 4 | return ; 5 | } 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lingdojo/kana-dojo/44ebf70a2981236531feed05d39a636f46f518e6/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | @import 'tw-animate-css'; 3 | @tailwind utilities; 4 | 5 | @theme { 6 | --breakpoint-xs: 30rem; 7 | --breakpoint-3xl: 110rem; 8 | } 9 | 10 | body { 11 | touch-action: manipulation; /* Prevents pinch-to-zoom */ 12 | overscroll-behavior: none; /* Prevents zooming on iOS */ 13 | font-family: 'Noto Sans JP', sans-serif; 14 | scroll-behavior: smooth; 15 | } 16 | 17 | /* Smooths dropdown toggle */ 18 | .dropdown-content { 19 | max-height: 0; 20 | opacity: 0; 21 | overflow: hidden; 22 | transition: max-height 0.08s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.05s; 23 | will-change: max-height, opacity; 24 | } 25 | 26 | /* This doesn't work right now but I've left it in to fix later */ 27 | /* .dropdown { 28 | transition: height 0.3s cubic-bezier(0.4,0,0.2,1); 29 | will-change: height; 30 | } */ 31 | 32 | .dropdown.open .dropdown-content { 33 | max-height: 500px; 34 | opacity: 1; 35 | } 36 | 37 | /* Provides a nicer looking checkbox to replace browser defaults */ 38 | input[type='checkbox'] { 39 | appearance: none; 40 | -webkit-appearance: none; 41 | background-color: var(--card-color); 42 | border: 2px solid var(--border-color); 43 | width: 1.1em; 44 | height: 1.1em; 45 | border-radius: 0.25em; 46 | display: inline-block; 47 | position: relative; 48 | vertical-align: middle; 49 | cursor: pointer; 50 | transition: border-color 0.2s, background-color 0.2s; 51 | } 52 | 53 | input[type='checkbox']:checked { 54 | background-color: var(--main-color); 55 | border-color: var(--main-color); 56 | } 57 | 58 | input[type='checkbox']:checked::after { 59 | content: ''; 60 | display: block; 61 | position: absolute; 62 | left: 0.28em; 63 | top: 0.05em; 64 | width: 0.3em; 65 | height: 0.6em; 66 | border: solid var(--background-color); 67 | border-width: 0 0.18em 0.18em 0; 68 | transform: rotate(45deg); 69 | } 70 | 71 | :root { 72 | /* Default theme (using light theme as default) */ 73 | --background-color: hsla(210, 17%, 100%, 1); 74 | --card-color: hsla(210, 17%, 91%, 1); 75 | --border-color: hsla(210, 17%, 76%, 1); 76 | 77 | --main-color: hsl(0, 0%, 0%); 78 | --secondary-color: hsl(0, 0%, 35%); 79 | 80 | /* New aliases */ 81 | --surface: hsla(210, 17%, 100%, 1); 82 | --on-surface: hsl(0, 0%, 0%); 83 | --surface-variant: hsla(210, 17%, 91%, 1); 84 | --on-surface-variant: hsl(0, 0%, 0%); 85 | --outline: hsla(210, 17%, 76%, 1); 86 | --primary: hsl(0, 0%, 0%); 87 | --on-primary: hsla(210, 17%, 100%, 1); 88 | --secondary: hsl(0, 0%, 35%); 89 | --on-secondary: hsla(210, 17%, 100%, 1); 90 | --background: hsla(210, 17%, 100%, 1); 91 | --on-background: hsl(0, 0%, 0%); 92 | } 93 | 94 | /* @media (prefers-color-scheme: light) { 95 | :root { 96 | --background-color: hsl(40, 18%, 95%); 97 | --card-color: hsl(40, 18%, 99%); 98 | --border-color: hsl(40, 18%, 75%); 99 | 100 | --main-color: hsl(0, 0%, 0%); 101 | --secondary-color: hsl(0, 0%, 45%); 102 | 103 | --surface: hsla(210, 24%, 99%, 1); 104 | --on-surface: hsl(222, 47%, 11%); 105 | --surface-variant: hsla(210, 20%, 92%, 1); 106 | --on-surface-variant: hsl(222, 32%, 20%); 107 | --outline: hsla(210, 16%, 70%, 1); 108 | --primary: hsl(212, 88%, 56%); 109 | --on-primary: hsla(0, 0%, 100%, 1); 110 | --secondary: hsl(33, 100%, 58%); 111 | --on-secondary: hsla(0, 0%, 100%, 1); 112 | --background: hsla(210, 28%, 97%, 1); 113 | --on-background: hsl(222, 45%, 15%); 114 | 115 | } 116 | } 117 | 118 | @media (prefers-color-scheme: dark) { 119 | :root { 120 | --background-color: hsl(204, 36%, 10%); 121 | --card-color: hsl(204, 36%, 12%); 122 | --border-color: hsl(204, 36%, 20%); 123 | 124 | --main-color: hsl(0, 0%, 100%); 125 | --secondary-color: hsl(0, 0%, 75%); 126 | 127 | --surface: hsla(0, 0%, 13%, 1); 128 | --on-surface: hsl(204, 40%, 92%); 129 | --surface-variant: hsla(204, 32%, 18%, 1); 130 | --on-surface-variant: hsl(204, 40%, 80%); 131 | --outline: hsla(204, 28%, 32%, 1); 132 | --primary: hsl(212, 88%, 56%); 133 | --on-primary: hsla(0, 0%, 100%, 1); 134 | --secondary: hsl(33, 100%, 58%); 135 | --on-secondary: hsla(0, 0%, 100%, 1); 136 | --background: hsla(210, 25%, 9%, 1); 137 | --on-background: hsl(204, 38%, 90%); 138 | 139 | } 140 | } */ 141 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Analytics } from '@vercel/analytics/next'; 2 | import { SpeedInsights } from '@vercel/speed-insights/next'; 3 | import './globals.css'; 4 | import '@fortawesome/fontawesome-svg-core/styles.css'; 5 | import GoogleAnalytics from '@/components/analytics/GoogleAnalytics'; 6 | import MSClarity from '@/components/analytics/MSClarity'; 7 | import { Metadata, Viewport } from 'next'; 8 | 9 | const googleVerificationToken = process.env.GOOGLE_VERIFICATION_TOKEN || ''; 10 | const msVerificationToken = process.env.MS_VERIFICATION_TOKEN || ''; 11 | 12 | export const viewport: Viewport = { 13 | width: 'device-width', 14 | initialScale: 1.0, 15 | maximumScale: 1.0, 16 | userScalable: false 17 | }; 18 | 19 | export const metadata: Metadata = { 20 | metadataBase: new URL('https://kanadojo.com'), 21 | manifest: '/manifest.json', 22 | title: 'KanaDojo', 23 | description: 24 | 'KanaDojo is a fun, aesthetic, minimalist platform for learning and practicing Japanese online.', 25 | icons: { 26 | icon: [ 27 | { url: '/favicon.ico?v=2' }, 28 | { url: '/favicon.ico?v=2', sizes: '16x16', type: 'image/x-icon' }, 29 | { url: '/favicon.ico?v=2', sizes: '32x32', type: 'image/x-icon' } 30 | ], 31 | shortcut: '/favicon.ico?v=2', 32 | apple: '/favicon.ico?v=2' 33 | }, 34 | verification: { 35 | google: googleVerificationToken, 36 | other: { 'msvalidate.01': msVerificationToken } 37 | }, 38 | keywords: 39 | 'learn japanese, learn hiragana, learn katakana, learn kana, learn japanese kana, hiragana practice, katakana practice, learn kanji, kanji practice online, kana learning, japanese online lessons, japanese writing system', 40 | openGraph: { 41 | title: 'KanaDojo', 42 | description: 43 | 'KanaDojo is a fun, aesthetic, minimalist platform for learning and practicing Japanese online.', 44 | url: 'https://kanadojo.com', 45 | type: 'website', 46 | locale: 'en_US' 47 | } 48 | }; 49 | 50 | // Move analytics condition to a constant to avoid repeated evaluation 51 | const isAnalyticsEnabled = 52 | process.env.NODE_ENV === 'production' && 53 | process.env.ANALYTICS_DISABLED !== 'true'; 54 | 55 | interface RootLayoutProps { 56 | readonly children: React.ReactNode; 57 | } 58 | 59 | export default function RootLayout({ children }: RootLayoutProps) { 60 | return ( 61 | 62 | 63 | {isAnalyticsEnabled && ( 64 | <> 65 | 66 | 67 | 68 | 69 | 70 | )} 71 | {children} 72 | 73 | 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /canvas-confetti.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | declare module 'canvas-confetti' { 3 | export interface ConfettiOptions { 4 | particleCount?: number; 5 | angle?: number; 6 | spread?: number; 7 | startVelocity?: number; 8 | gravity?: number; 9 | origin?: { x?: number; y?: number }; 10 | shapes?: any[]; 11 | ticks?: number; 12 | zIndex?: number; 13 | scalar?: number; 14 | } 15 | export interface ShapeFromTextOptions { 16 | text: string; 17 | scalar?: number; 18 | } 19 | export interface ConfettiFunction { 20 | (options?: ConfettiOptions): void; 21 | shapeFromText: (opts: ShapeFromTextOptions) => any; 22 | } 23 | const confetti: ConfettiFunction; 24 | export default confetti; 25 | } 26 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /components/Academy/Hiragana101.tsx: -------------------------------------------------------------------------------- 1 | import PostWrapper from '@/components/reusable/PostWrapper'; 2 | import hiragana101 from '@/static/academy/hiraganaBlogPost'; 3 | 4 | const Hiragana101 = () => { 5 | return ; 6 | }; 7 | 8 | export default Hiragana101; 9 | -------------------------------------------------------------------------------- /components/Academy/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@/i18n/routing'; 2 | import Banner from '@/components/reusable/Menu/Banner'; 3 | import clsx from 'clsx'; 4 | 5 | const Academy = () => { 6 | const links = [ 7 | { 8 | title: 'Hiragana 101', 9 | href: '/academy/hiragana-101', 10 | description: 11 | 'Learn the basics of Hiragana, the first of the three Japanese scripts.', 12 | }, 13 | { 14 | title: 'Katakana 101', 15 | href: '/academy/katakana-101', 16 | description: 17 | 'Learn the basics of Katakana, the second of the three Japanese scripts.', 18 | }, 19 | { 20 | title: 'Kanji 101', 21 | href: '/academy/kanji-101', 22 | description: 23 | 'Learn the basics of Kanji, the third of the three Japanese scripts.', 24 | }, 25 | { 26 | title: 'Grammar 101', 27 | href: '/academy/grammar-101', 28 | description: 29 | 'Learn the basics of Japanese grammar, including particles and verb conjugation.', 30 | }, 31 | ]; 32 | 33 | return ( 34 |
35 | 36 |
37 | {links.map((link, i) => ( 38 | 42 | 51 | 52 | ))} 53 |
54 |
55 | ); 56 | }; 57 | 58 | export default Academy; 59 | -------------------------------------------------------------------------------- /components/Dojo/Kana/Game/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import clsx from 'clsx'; 3 | import { useEffect } from 'react'; 4 | import Return from '@/components/reusable/Game/ReturnFromGame'; 5 | import Pick from './Pick'; 6 | import Input from './Input'; 7 | import useKanaStore from '@/store/useKanaStore'; 8 | import useStatsStore from '@/store/useStatsStore'; 9 | import Stats from '@/components/reusable/Game/Stats'; 10 | 11 | const Game = () => { 12 | const showStats = useStatsStore(state => state.showStats); 13 | 14 | const resetStats = useStatsStore(state => state.resetStats); 15 | 16 | const gameMode = useKanaStore(state => state.selectedGameModeKana); 17 | 18 | useEffect(() => { 19 | resetStats(); 20 | }, []); 21 | 22 | return ( 23 |
30 | {showStats && } 31 | 32 | {gameMode.toLowerCase() === 'pick' ? ( 33 | 34 | ) : gameMode.toLowerCase() === 'reverse-pick' ? ( 35 | 36 | ) : gameMode.toLowerCase() === 'input' ? ( 37 | 38 | ) : gameMode.toLowerCase() === 'reverse-input' ? ( 39 | 40 | ) : null} 41 |
42 | ); 43 | }; 44 | 45 | export default Game; 46 | -------------------------------------------------------------------------------- /components/Dojo/Kana/KanaCards/Subset.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import clsx from 'clsx'; 3 | import { kana } from '@/static/kana'; 4 | import useKanaStore from '@/store/useKanaStore'; 5 | import usePreferencesStore from '@/store/usePreferencesStore'; 6 | import { useClick } from '@/hooks/useAudio'; 7 | import { miniButtonBorderStyles } from '@/static/styles'; 8 | import { MousePointer } from 'lucide-react'; 9 | import { useState } from 'react'; 10 | import { motion } from 'framer-motion'; 11 | 12 | const finalCharactersInEachGroup = [ 13 | 'h.b.w', 14 | 'h.d.p', 15 | 'h.y.py', 16 | 'k.b.w', 17 | 'k.d.p', 18 | 'k.y.py', 19 | 'k.f.ts' 20 | ]; 21 | 22 | const Subset = ({ 23 | sliceRange, 24 | // group, 25 | subgroup 26 | }: { 27 | sliceRange: number[]; 28 | group: string; 29 | subgroup: string; 30 | }) => { 31 | const { playClick } = useClick(); 32 | 33 | const kanaGroups = kana.slice(sliceRange[0], sliceRange[1]); 34 | 35 | const kanaGroupIndices = useKanaStore(state => state.kanaGroupIndices); 36 | const addKanaGroupIndex = useKanaStore(state => state.addKanaGroupIndex); 37 | const addKanaGroupIndices = useKanaStore(state => state.addKanaGroupIndices); 38 | const displayKana = usePreferencesStore(state => state.displayKana); 39 | const [focusedRow, setFocusedRow] = useState(''); 40 | 41 | return ( 42 |
43 | {kanaGroups.map((group, i) => ( 44 |
45 | 108 | {!finalCharactersInEachGroup.includes(group.groupName) && ( 109 |
110 | )} 111 |
112 | ))} 113 |
114 | 141 | {/* 142 | 152 | */} 153 |
154 |
155 | ); 156 | }; 157 | 158 | export default Subset; 159 | -------------------------------------------------------------------------------- /components/Dojo/Kana/KanaCards/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { Fragment } from 'react'; 3 | import clsx from 'clsx'; 4 | import { useState } from 'react'; 5 | import Subset from './Subset'; 6 | import { useClick } from '@/hooks/useAudio'; 7 | import { cardBorderStyles } from '@/static/styles'; 8 | import { ChevronUp } from 'lucide-react'; 9 | 10 | const KanaCards = () => { 11 | const { playClick } = useClick(); 12 | 13 | const kanaGroups = [ 14 | { 15 | name: 'Hiragana ひらがな', 16 | subsets: [ 17 | { 18 | name: 'HBase', 19 | sliceRange: [0, 10] 20 | }, 21 | { 22 | name: 'HDakuon', 23 | sliceRange: [10, 15] 24 | }, 25 | { 26 | name: 'HYoon', 27 | sliceRange: [15, 26] 28 | } 29 | ] 30 | }, 31 | { 32 | name: 'Katakana カタカナ', 33 | subsets: [ 34 | { 35 | name: 'KBase', 36 | sliceRange: [26, 36] 37 | }, 38 | { 39 | name: 'KDakuon', 40 | sliceRange: [36, 41] 41 | }, 42 | { 43 | name: 'KYoon', 44 | sliceRange: [41, 52] 45 | }, 46 | { 47 | name: 'KForeign Sounds', 48 | sliceRange: [52, 60] 49 | } 50 | ] 51 | } 52 | ]; 53 | 54 | const [hiddenSubsets, setHiddenSubsets] = useState([ 55 | 'hdakuon', 56 | 'hyoon', 57 | 'kdakuon', 58 | 'kyoon', 59 | 'kforeign sounds' 60 | ]); 61 | 62 | return ( 63 |
69 | {kanaGroups.map((kanaGroup, i) => ( 70 | 71 |
76 | { 82 | playClick(); 83 | if (hiddenSubsets.includes(kanaGroup.name.toLowerCase())) { 84 | const filteredHiddenSubsets = hiddenSubsets.filter( 85 | subset => subset !== kanaGroup.name.toLowerCase() 86 | ); 87 | setHiddenSubsets(filteredHiddenSubsets); 88 | return; 89 | } 90 | setHiddenSubsets([ 91 | ...hiddenSubsets, 92 | kanaGroup.name.toLowerCase() 93 | ]); 94 | }} 95 | > 96 | 106 |

107 | {kanaGroup.name.split(' ')[0]} 108 | 109 | {kanaGroup.name.split(' ')[1]} 110 | 111 |

112 |
113 | {!hiddenSubsets.includes(kanaGroup.name.toLowerCase()) && 114 | kanaGroup.subsets.map((subset, i) => ( 115 |
116 |
117 |

{ 123 | playClick(); 124 | 125 | if (hiddenSubsets.includes(subset.name.toLowerCase())) { 126 | const filteredHiddenSubsets = hiddenSubsets.filter( 127 | currentSubset => 128 | currentSubset !== subset.name.toLowerCase() 129 | ); 130 | setHiddenSubsets(filteredHiddenSubsets); 131 | return; 132 | } 133 | setHiddenSubsets([ 134 | ...hiddenSubsets, 135 | subset.name.toLowerCase() 136 | ]); 137 | }} 138 | > 139 | 150 | {subset.name.slice(1)} 151 |

152 | {!hiddenSubsets.includes(subset.name.toLowerCase()) && ( 153 | 158 | )} 159 |
160 | 161 | {i < kanaGroup.subsets.length - 1 && 162 | !hiddenSubsets.includes(kanaGroup.name.toLowerCase()) && ( 163 |
164 | )} 165 |
166 | ))} 167 |
168 | 169 | {i < kanaGroups.length - 1 && ( 170 |
177 | )} 178 | 179 | ))} 180 |
181 | ); 182 | }; 183 | 184 | export default KanaCards; 185 | -------------------------------------------------------------------------------- /components/Dojo/Kana/SubsetDictionary.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import clsx from 'clsx'; 3 | import { kana } from '@/static/kana'; 4 | import { useParams } from 'next/navigation'; 5 | import usePreferencesStore from '@/store/usePreferencesStore'; 6 | 7 | const sliceRanges = { 8 | hiraganabase: [0, 10], 9 | hiraganadakuon: [10, 15], 10 | hiraganayoon: [15, 26], 11 | katakanabase: [26, 36], 12 | katakanadakuon: [36, 41], 13 | katakanayoon: [41, 52], 14 | katakanaforeign: [52, 60] 15 | }; 16 | 17 | const SetDictionary = () => { 18 | const params = useParams<{ subset: string }>(); 19 | const { subset }: { subset: string } = params; 20 | const [group, subgroup] = subset.split('-'); 21 | const displayKana = usePreferencesStore(state => state.displayKana); 22 | 23 | const [startIndex, endIndex] = 24 | sliceRanges[(group + subgroup) as keyof typeof sliceRanges]; 25 | 26 | const kanaToDisplay = kana.slice(startIndex, endIndex); 27 | 28 | return ( 29 |
30 |
31 | {kanaToDisplay.map(kanaSubgroup => ( 32 |
39 |

40 | {kanaSubgroup.kana.join(' ')} 41 |

42 |
43 | {!displayKana && ( 44 | 50 | {kanaSubgroup.romanji.join(' ')} 51 | 52 | )} 53 |
54 |
55 | ))} 56 |
57 |
58 | ); 59 | }; 60 | 61 | export default SetDictionary; 62 | -------------------------------------------------------------------------------- /components/Dojo/Kanji/Game/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useEffect } from 'react'; 3 | import Return from '@/components/reusable/Game/ReturnFromGame'; 4 | import Pick from './Pick'; 5 | import Input from './Input'; 6 | import useKanjiStore from '@/store/useKanjiStore'; 7 | import useStatsStore from '@/store/useStatsStore'; 8 | import Stats from '@/components/reusable/Game/Stats'; 9 | import { usePathname } from 'next/navigation'; 10 | import { removeLocaleFromPath } from '@/lib/pathUtils'; 11 | 12 | const Game = () => { 13 | const fullPathname = usePathname(); 14 | // Remove locale and get back to kanji root 15 | const pathWithoutLocale = removeLocaleFromPath(fullPathname); 16 | const pathname = pathWithoutLocale.split('/').slice(0, -2).join('/'); 17 | 18 | const showStats = useStatsStore(state => state.showStats); 19 | 20 | const resetStats = useStatsStore(state => state.resetStats); 21 | 22 | const gameMode = useKanjiStore(state => state.selectedGameModeKanji); 23 | const selectedKanjiObjs = useKanjiStore(state => state.selectedKanjiObjs); 24 | 25 | useEffect(() => { 26 | resetStats(); 27 | }, []); 28 | 29 | return ( 30 |
31 | {showStats && } 32 | 33 | {gameMode.toLowerCase() === 'pick' ? ( 34 | 35 | ) : gameMode.toLowerCase() === 'reverse-pick' ? ( 36 | 41 | ) : gameMode.toLowerCase() === 'input' ? ( 42 | 43 | ) : gameMode.toLowerCase() === 'reverse-input' ? ( 44 | 49 | ) : null} 50 |
51 | ); 52 | }; 53 | 54 | export default Game; 55 | -------------------------------------------------------------------------------- /components/Dojo/Kanji/SetDictionary.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import clsx from 'clsx'; 3 | import { cardBorderStyles } from '@/static/styles'; 4 | import N5Kanji from '@/static/kanji/N5'; 5 | import N4Kanji from '@/static/kanji/N4'; 6 | import N3Kanji from '@/static/kanji/N3'; 7 | import N2Kanji from '@/static/kanji/N2'; 8 | import N1Kanji from '@/static/kanji/N1'; 9 | import useKanjiStore from '@/store/useKanjiStore'; 10 | import usePreferencesStore from '@/store/usePreferencesStore'; 11 | import FuriganaText from '@/components/reusable/FuriganaText'; 12 | import { useClick } from '@/hooks/useAudio'; 13 | 14 | const createKanjiSetRanges = (numSets: number) => 15 | Array.from({ length: numSets }, (_, i) => i + 1).reduce( 16 | (acc, curr) => ({ 17 | ...acc, 18 | [`Set ${curr}`]: [(curr - 1) * 10, curr * 10] 19 | }), 20 | {} 21 | ); 22 | 23 | const kanjiSetSliceRanges = createKanjiSetRanges(200); 24 | 25 | const kanjiCollections = { 26 | n5: N5Kanji, 27 | n4: N4Kanji, 28 | n3: N3Kanji, 29 | n2: N2Kanji, 30 | n1: N1Kanji 31 | }; 32 | 33 | const KanjiSetDictionary = ({ set }: { set: string }) => { 34 | const { playClick } = useClick(); 35 | 36 | const selectedKanjiCollection = useKanjiStore( 37 | state => state.selectedKanjiCollection 38 | ); 39 | const displayKanjiCollection = 40 | kanjiCollections[selectedKanjiCollection as keyof typeof kanjiCollections]; 41 | 42 | const sliceRange = 43 | kanjiSetSliceRanges[set as keyof typeof kanjiSetSliceRanges]; 44 | 45 | const showKana = usePreferencesStore(state => state.displayKana); 46 | 47 | return ( 48 |
49 | {displayKanjiCollection 50 | .slice(sliceRange[0], sliceRange[1]) 51 | .map((kanjiObj, i) => ( 52 |
59 |
60 | { 66 | playClick(); 67 | }} 68 | > 69 | {/* 4-segment square background */} 70 |
71 |
72 |
73 |
74 |
75 |
76 | 77 | 83 |
84 | 85 |
86 |
97 | {kanjiObj.onyomi.slice(0, 2).map((onyomiReading, i) => ( 98 | 108 | {showKana ? onyomiReading.split(' ')[1] : onyomiReading} 109 | 110 | ))} 111 |
112 | 113 |
124 | {kanjiObj.kunyomi.slice(0, 2).map((kunyomiReading, i) => ( 125 | 134 | {showKana ? kunyomiReading.split(' ')[1] : kunyomiReading} 135 | 136 | ))} 137 |
138 |
139 |
140 | 141 |

142 | {kanjiObj.fullDisplayMeanings.join(', ')} 143 |

144 |
145 | ))} 146 |
147 | ); 148 | }; 149 | 150 | export default KanjiSetDictionary; 151 | -------------------------------------------------------------------------------- /components/Dojo/Vocab/Game/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useEffect } from 'react'; 3 | import Return from '@/components/reusable/Game/ReturnFromGame'; 4 | import Pick from './Pick'; 5 | import Input from './Input'; 6 | 7 | import useVocabStore from '@/store/useVocabStore'; 8 | import useStatsStore from '@/store/useStatsStore'; 9 | import Stats from '@/components/reusable/Game/Stats'; 10 | import { usePathname } from 'next/navigation'; 11 | import { removeLocaleFromPath } from '@/lib/pathUtils'; 12 | 13 | const Game = () => { 14 | const fullPathname = usePathname(); 15 | // Remove locale and get back to vocabulary root 16 | const pathWithoutLocale = removeLocaleFromPath(fullPathname); 17 | const pathname = pathWithoutLocale.split('/').slice(0, -2).join('/'); 18 | 19 | const showStats = useStatsStore(state => state.showStats); 20 | 21 | const resetStats = useStatsStore(state => state.resetStats); 22 | 23 | const gameMode = useVocabStore(state => state.selectedGameModeVocab); 24 | const selectedWordObjs = useVocabStore(state => state.selectedWordObjs); 25 | 26 | useEffect(() => { 27 | resetStats(); 28 | }, []); 29 | 30 | return ( 31 |
32 | {showStats && } 33 | 34 | {gameMode.toLowerCase() === 'pick' ? ( 35 | 36 | ) : gameMode.toLowerCase() === 'reverse-pick' ? ( 37 | 42 | ) : gameMode.toLowerCase() === 'input' ? ( 43 | 44 | ) : gameMode.toLowerCase() === 'reverse-input' ? ( 45 | 50 | ) : null} 51 |
52 | ); 53 | }; 54 | 55 | export default Game; 56 | -------------------------------------------------------------------------------- /components/Dojo/Vocab/SetDictionary.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import clsx from 'clsx'; 3 | import { IWord } from '@/lib/interfaces'; 4 | import { cardBorderStyles } from '@/static/styles'; 5 | import useVocabStore from '@/store/useVocabStore'; 6 | import usePreferencesStore from '@/store/usePreferencesStore'; 7 | import FuriganaText from '@/components/reusable/FuriganaText'; 8 | 9 | import N5Nouns from '@/static/vocab/n5/nouns'; 10 | import N4Nouns from '@/static/vocab/n4/nouns'; 11 | import N3Nouns from '@/static/vocab/n3/nouns'; 12 | import N2Nouns from '@/static/vocab/n2/nouns'; 13 | 14 | const createVocabSetRanges = (numSets: number) => 15 | Array.from({ length: numSets }, (_, i) => i + 1).reduce( 16 | (acc, curr) => ({ 17 | ...acc, 18 | [`Set ${curr}`]: [(curr - 1) * 10, curr * 10] 19 | }), 20 | {} 21 | ); 22 | 23 | const vocabSetSliceRanges = createVocabSetRanges(200); 24 | 25 | const vocabData = { 26 | jlpt: { 27 | n5: { 28 | nouns: N5Nouns 29 | }, 30 | n4: { 31 | nouns: N4Nouns 32 | }, 33 | n3: { 34 | nouns: N3Nouns 35 | }, 36 | n2: { 37 | nouns: N2Nouns 38 | } 39 | } 40 | }; 41 | 42 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 43 | type VocabData = Record>; 44 | 45 | const SetDictionary = ({ set }: { set: string }) => { 46 | const showKana = usePreferencesStore(state => state.displayKana); 47 | 48 | const selectedVocabCollection = useVocabStore( 49 | state => state.selectedVocabCollection 50 | ); 51 | const displayVocabCollection = (vocabData as VocabData)['jlpt'][ 52 | selectedVocabCollection 53 | ]['nouns']; 54 | 55 | const sliceRange = 56 | vocabSetSliceRanges[set as keyof typeof vocabSetSliceRanges]; 57 | 58 | return ( 59 |
60 | {displayVocabCollection 61 | .slice(sliceRange[0], sliceRange[1]) 62 | .map((wordObj: IWord, i: number) => ( 63 |
70 | 76 |
77 | 84 | {showKana ? wordObj.reading.split(' ')[1] : wordObj.reading} 85 | 86 |

87 | {wordObj.displayMeanings.join(', ')} 88 |

89 |
90 |
91 | ))} 92 |
93 | ); 94 | }; 95 | 96 | export default SetDictionary; 97 | -------------------------------------------------------------------------------- /components/Legal/Privacy.tsx: -------------------------------------------------------------------------------- 1 | import PostWrapper from '@/components/reusable/PostWrapper'; 2 | import privacyPolicy from '@/static/legal/privacyPolicy'; 3 | 4 | const PrivacyPolicy = () => { 5 | return ; 6 | }; 7 | 8 | export default PrivacyPolicy; 9 | -------------------------------------------------------------------------------- /components/Legal/Security.tsx: -------------------------------------------------------------------------------- 1 | import PostWrapper from '@/components/reusable/PostWrapper'; 2 | import securityPolicy from '@/static/legal/securityPolicy'; 3 | 4 | const SecurityPolicy = () => { 5 | return ; 6 | }; 7 | 8 | export default SecurityPolicy; 9 | -------------------------------------------------------------------------------- /components/Legal/Terms.tsx: -------------------------------------------------------------------------------- 1 | import PostWrapper from '@/components/reusable/PostWrapper'; 2 | import termsOfService from '@/static/legal/termsOfService'; 3 | 4 | const TermsOfService = () => { 5 | return ; 6 | }; 7 | 8 | export default TermsOfService; 9 | -------------------------------------------------------------------------------- /components/MainMenu/Banner.tsx: -------------------------------------------------------------------------------- 1 | const Banner = () => { 2 | return ( 3 |

4 | KanaDojo 5 | 6 | かな道場 7 | 8 |

9 | ); 10 | }; 11 | 12 | export default Banner; 13 | -------------------------------------------------------------------------------- /components/MainMenu/Decorations.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useEffect, useState } from 'react'; 3 | import themeSets from '../../static/themes'; 4 | import fonts from '@/static/fonts'; 5 | import clsx from 'clsx'; 6 | import N5Kanji from '@/static/kanji/N5'; 7 | import N4Kanji from '@/static/kanji/N4'; 8 | import N3Kanji from '@/static/kanji/N3'; 9 | 10 | const kanjiList = [ 11 | ...N5Kanji.map(kanji => kanji.kanjiChar), 12 | ...N4Kanji.map(kanji => kanji.kanjiChar), 13 | ...N3Kanji.map(kanji => kanji.kanjiChar) 14 | ]; 15 | 16 | const shuffledKanjiList = kanjiList.sort(() => Math.random() - 0.5); 17 | 18 | // Tailwind animations 19 | const animations = [ 20 | 'motion-safe:animate-pulse' 21 | // 'animate-bounce', 22 | // 'animate-ping', 23 | // 'animate-spin', 24 | ]; 25 | 26 | // Get all available main colors from themes 27 | const getAllMainColors = () => { 28 | const colors = new Set(); 29 | /* themeSets.forEach(themeGroup => { 30 | themeGroup.themes.forEach(theme => { 31 | colors.add(theme.mainColor); 32 | if (theme.secondaryColor) colors.add(theme.secondaryColor); 33 | }); 34 | }); */ 35 | themeSets[2].themes.forEach(theme => { 36 | colors.add(theme.mainColor); 37 | if (theme.secondaryColor) colors.add(theme.secondaryColor); 38 | }); 39 | return Array.from(colors); 40 | }; 41 | 42 | const allMainColors = getAllMainColors(); 43 | 44 | // Component to render a single kanji character with random styles 45 | const KanjiCharacter = ({ char }: { char: string }) => { 46 | const [mounted, setMounted] = useState(false); 47 | const [styles, setStyles] = useState({ 48 | color: '', 49 | fontClass: '', 50 | animation: '' 51 | }); 52 | 53 | useEffect(() => { 54 | // Generate random styles on mount 55 | const randomColor = 56 | allMainColors[Math.floor(Math.random() * allMainColors.length)]; 57 | const randomFont = fonts[Math.floor(Math.random() * fonts.length)]; 58 | const randomAnimation = 59 | animations[Math.floor(Math.random() * animations.length)]; 60 | 61 | setStyles({ 62 | color: randomColor, 63 | fontClass: randomFont.font.className, 64 | animation: randomAnimation 65 | }); 66 | setMounted(true); 67 | }, []); 68 | 69 | if (!mounted) return null; 70 | 71 | return ( 72 | 82 | ); 83 | }; 84 | 85 | const Decorations = ({ expandDecorations }: { expandDecorations: boolean }) => { 86 | return ( 87 |
93 |
94 | {shuffledKanjiList.map((char, index) => ( 95 | 96 | ))} 97 |
98 |
99 | ); 100 | }; 101 | 102 | export default Decorations; 103 | -------------------------------------------------------------------------------- /components/PatchNotes.tsx: -------------------------------------------------------------------------------- 1 | import PostWrapper from '@/components/reusable/PostWrapper'; 2 | import patchNotes from '@/static/patchNotes'; 3 | 4 | const PatchNotes = () => { 5 | return ; 6 | }; 7 | 8 | export default PatchNotes; 9 | 10 | -------------------------------------------------------------------------------- /components/Progress/ProgressWithSidebar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import clsx from 'clsx'; 4 | import Sidebar from '@/components/reusable/Menu/Sidebar'; 5 | import Banner from '@/components/reusable/Menu/Banner'; 6 | import SimpleProgress from './SimpleProgress'; 7 | 8 | const ProgressWithSidebar = () => { 9 | return ( 10 |
11 | 12 |
19 | 20 | 21 |
22 |
23 | ); 24 | }; 25 | 26 | export default ProgressWithSidebar; -------------------------------------------------------------------------------- /components/Sandbox.tsx: -------------------------------------------------------------------------------- 1 | import fonts from '@/static/fonts'; 2 | import clsx from 'clsx'; 3 | 4 | const fontClassName = fonts[2].font.className; 5 | 6 | const Sandbox = () => { 7 | return ( 8 |
9 |
10 |

11 |
12 |
13 | ); 14 | }; 15 | 16 | export default Sandbox; 17 | -------------------------------------------------------------------------------- /components/Settings/Backup.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import * as React from 'react'; 3 | import { Button } from '@/components/ui/button'; 4 | import { applyBackup, createBackup, type BackupFile } from '@/lib/backup'; 5 | 6 | const Backup: React.FC = () => { 7 | const fileRef = React.useRef(null); 8 | const [message, setMessage] = React.useState(null); 9 | 10 | const onExport = () => { 11 | const data = createBackup(); 12 | const blob = new Blob([JSON.stringify(data, null, 2)], { 13 | type: 'application/json' 14 | }); 15 | const url = URL.createObjectURL(blob); 16 | const a = document.createElement('a'); 17 | a.href = url; 18 | a.download = 'kanadojo-backup.json'; 19 | a.click(); 20 | URL.revokeObjectURL(url); 21 | setMessage('Exported to kanadojo-backup.json'); 22 | }; 23 | 24 | const onFilePicked = async (file: File) => { 25 | try { 26 | const text = await file.text(); 27 | const parsed = JSON.parse(text) as BackupFile; 28 | const ok = applyBackup(parsed); 29 | setMessage(ok ? 'Imported backup successfully' : 'Import failed'); 30 | } catch { 31 | setMessage('Invalid file'); 32 | } finally { 33 | if (fileRef.current) fileRef.current.value = ''; 34 | } 35 | }; 36 | 37 | return ( 38 |
39 |
40 | 41 | 44 | ) => { 50 | const f = e.target.files?.[0]; 51 | if (f) onFilePicked(f); 52 | }} 53 | /> 54 |
55 | {message && ( 56 |

{message}

57 | )} 58 |

59 | Exports only preferences and stats. No account data is included. 60 |

61 |
62 | ); 63 | }; 64 | 65 | export default Backup; 66 | -------------------------------------------------------------------------------- /components/Settings/Fonts.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import clsx from 'clsx'; 3 | import { useState } from 'react'; 4 | import { useClick } from '@/hooks/useAudio'; 5 | import usePreferencesStore from '@/store/usePreferencesStore'; 6 | import { buttonBorderStyles } from '@/static/styles'; 7 | import fonts from '@/static/fonts'; 8 | import { Dice5 } from 'lucide-react'; 9 | import { Random } from 'random-js'; 10 | 11 | const random = new Random(); 12 | 13 | const Fonts = () => { 14 | const { playClick } = useClick(); 15 | 16 | const currentFont = usePreferencesStore(state => state.font); 17 | const setFont = usePreferencesStore(state => state.setFont); 18 | 19 | const [randomFont, setRandomFont] = useState( 20 | fonts[random.integer(0, fonts.length - 1)] 21 | ); 22 | 23 | return ( 24 |
25 | 45 | 46 |
49 | {fonts.map(fontObj => ( 50 | 79 | ))} 80 |
81 |
82 |

Hiragana:

83 |

84 | {'あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをん'.slice( 85 | 0, 86 | 20 87 | )} 88 |

89 |

Katakana:

90 |

91 | {'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメもヤユヨラリルレロワヲン'.slice( 92 | 0, 93 | 20 94 | )} 95 |

96 |

Kanji:

97 |

98 | 人日大小学 校生先円上下中外右左名前時分国 99 |

100 |

Sample sentence:

101 |

102 | 人類社会のすべての構成員の固有の尊厳と平等で譲ることのできない権利とを承認することは 103 |

104 |
105 |
106 | ); 107 | }; 108 | 109 | export default Fonts; 110 | -------------------------------------------------------------------------------- /components/Settings/HotkeyReference.tsx: -------------------------------------------------------------------------------- 1 | const HotkeyReference = ({ 2 | hotkeys, 3 | }: { 4 | hotkeys: { key: string; action: string }[]; 5 | }) => { 6 | return ( 7 |
8 |
Hotkey Reference
9 |
10 | 11 | 12 | 13 | 16 | 19 | 20 | 21 | 22 | {hotkeys.map((hotkey, index) => ( 23 | 27 | 32 | 33 | 34 | ))} 35 | 36 |
14 | Key 15 | 17 | Action 18 |
28 | 29 | {hotkey.key} 30 | 31 | {hotkey.action}
37 |
38 |
39 | ); 40 | }; 41 | 42 | export default HotkeyReference; 43 | -------------------------------------------------------------------------------- /components/Settings/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import clsx from 'clsx'; 3 | import Banner from '@/components/reusable/Menu/Banner'; 4 | import Themes from './Themes'; 5 | import Fonts from './Fonts'; 6 | import Behavior from './Behavior'; 7 | import Backup from './Backup'; 8 | import { 9 | Joystick, 10 | Sparkles, 11 | CaseSensitive, 12 | Blocks, 13 | Palette 14 | } from 'lucide-react'; 15 | import Sidebar from '@/components/reusable/Menu/Sidebar'; 16 | 17 | const Settings = () => { 18 | return ( 19 |
20 | 21 |
28 | 29 |
30 |
31 |

32 | 33 | Behavior 34 |

35 | 36 |
37 |
38 |

39 | 40 | Display 41 |

42 |

43 | 44 | Themes 45 |

46 | 47 |
48 |
49 |

50 | 51 | Fonts 52 |

53 | 54 |
55 |
56 |

57 | Backup 58 |

59 | 60 |
61 |
62 |

68 | 69 | Coming Soon... 70 |

71 |
72 |
73 |
74 |
75 | ); 76 | }; 77 | 78 | export default Settings; 79 | -------------------------------------------------------------------------------- /components/analytics/GoogleAnalytics.tsx: -------------------------------------------------------------------------------- 1 | import Script from 'next/script'; 2 | 3 | const GA_ID = process.env.GA_ID; 4 | 5 | export default function GoogleAnalytics() { 6 | if (!GA_ID) return null; 7 | 8 | return ( 9 | <> 10 |