├── .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 |
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 |
48 | {link.title}
49 | {link.description}
50 |
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 | playClick()}
52 | >
53 | {
59 | e.currentTarget.blur();
60 | addKanaGroupIndex(i + sliceRange[0]);
61 | }}
62 | />
63 | setFocusedRow(group.groupName)}
66 | initial={{ opacity: 0 }}
67 | animate={{ opacity: 1 }}
68 | transition={{ duration: 0.8, ease: 'linear' }}
69 | >
70 |
87 | {group.kana.join('・')}
88 |
89 |
104 | {group.romanji.join('・')}
105 |
106 |
107 |
108 | {!finalCharactersInEachGroup.includes(group.groupName) && (
109 |
110 | )}
111 |
112 | ))}
113 |
114 | {
122 | playClick();
123 | e.currentTarget.blur();
124 | addKanaGroupIndices(
125 | Array.from(
126 | { length: sliceRange[1] - sliceRange[0] },
127 | (_, i) => sliceRange[0] + i
128 | )
129 | );
130 | }}
131 | >
132 |
137 | select all {subgroup.slice(1).toLowerCase()}
138 |
139 |
140 |
141 | {/*
142 | playClick()}
148 | >
149 | inspect
150 |
151 |
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 |
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 |
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 |
80 | {char}
81 |
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 |
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 | Export
41 | fileRef.current?.click()}>
42 | Import
43 |
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 |
{
33 | playClick();
34 | const randomFont = fonts[random.integer(0, fonts.length - 1)];
35 | setRandomFont(randomFont);
36 | setFont(randomFont.name);
37 | }}
38 | >
39 |
40 | {randomFont.name === currentFont ? '\u2B24 ' : ''}
41 |
42 |
43 | Random Font
44 |
45 |
46 |
49 | {fonts.map(fontObj => (
50 | playClick()}
59 | >
60 | {
64 | setFont(fontObj.name);
65 | }}
66 | className='hidden'
67 | />
68 |
71 | {fontObj.name === currentFont ? '\u2B24 ' : ''}
72 | {fontObj.name}
73 | {fontObj.name === 'Zen Maru Gothic' && ' (default)'}
74 |
75 | かな道場
76 |
77 |
78 |
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 |
14 | Key
15 |
16 |
17 | Action
18 |
19 |
20 |
21 |
22 | {hotkeys.map((hotkey, index) => (
23 |
27 |
28 |
29 | {hotkey.key}
30 |
31 |
32 | {hotkey.action}
33 |
34 | ))}
35 |
36 |
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 |
14 |
26 |
27 |
33 |
34 | >
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/components/analytics/MSClarity.tsx:
--------------------------------------------------------------------------------
1 | import Script from 'next/script';
2 |
3 | const MS_CLARITY_ID = process.env.MS_CLARITY_ID;
4 |
5 | export default function MicrosoftClarity() {
6 | if (!MS_CLARITY_ID) return null;
7 |
8 | return (
9 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/components/reusable/AchievementBadge.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { motion } from 'motion/react';
4 | import clsx from 'clsx';
5 | import { Trophy } from 'lucide-react';
6 | import useAchievements from '@/hooks/useAchievements';
7 | import { useClick } from '@/hooks/useAudio';
8 |
9 | interface AchievementBadgeProps {
10 | onClick?: () => void;
11 | showNotificationDot?: boolean;
12 | size?: 'sm' | 'md' | 'lg';
13 | variant?: 'icon' | 'full';
14 | }
15 |
16 | const AchievementBadge = ({
17 | onClick,
18 | showNotificationDot = true,
19 | size = 'md',
20 | variant = 'icon'
21 | }: AchievementBadgeProps) => {
22 | const { playClick } = useClick();
23 | const { totalPoints, level, unlockedCount, hasUnseenNotifications } =
24 | useAchievements();
25 |
26 | const handleClick = () => {
27 | playClick();
28 | onClick?.();
29 | };
30 |
31 | const sizeClasses = {
32 | sm: 'p-2 text-sm',
33 | md: 'p-3 text-base',
34 | lg: 'p-4 text-lg'
35 | };
36 |
37 | const iconSizes = {
38 | sm: 16,
39 | md: 20,
40 | lg: 24
41 | };
42 |
43 | if (variant === 'full') {
44 | return (
45 |
57 |
58 |
59 | {showNotificationDot && hasUnseenNotifications && (
60 |
65 | )}
66 |
67 |
68 |
69 |
70 |
71 | Level {level}
72 |
73 |
74 | ({totalPoints} pts)
75 |
76 |
77 |
78 | {unlockedCount} achievements
79 |
80 |
81 |
82 | );
83 | }
84 |
85 | return (
86 |
98 |
99 |
100 | {/* Notification dot */}
101 | {showNotificationDot && hasUnseenNotifications && (
102 |
107 | )}
108 |
109 | {/* Level indicator */}
110 | {level > 1 && (
111 |
112 | {level}
113 |
114 | )}
115 |
116 | );
117 | };
118 |
119 | export default AchievementBadge;
120 |
--------------------------------------------------------------------------------
/components/reusable/AchievementIntegration.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect } from 'react';
4 | import useAchievementStore from '@/store/useAchievementStore';
5 |
6 | /**
7 | * Component to make achievement store available globally for integration
8 | * This is a workaround to allow the stats store to trigger achievement checks
9 | */
10 | const AchievementIntegration = () => {
11 | const achievementStore = useAchievementStore;
12 |
13 | useEffect(() => {
14 | // Make achievement store available globally for cross-store communication
15 | if (typeof window !== 'undefined') {
16 | (
17 | window as Window & { __achievementStore?: typeof achievementStore }
18 | ).__achievementStore = achievementStore;
19 | }
20 | return () => {
21 | if (typeof window !== 'undefined') {
22 | delete (
23 | window as Window & { __achievementStore?: typeof achievementStore }
24 | ).__achievementStore;
25 | }
26 | };
27 | }, [achievementStore]);
28 |
29 | return null; // This component doesn't render anything
30 | };
31 |
32 | export default AchievementIntegration;
33 |
--------------------------------------------------------------------------------
/components/reusable/AnimatedCard.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { motion } from "framer-motion";
3 | import React from "react";
4 |
5 | interface AnimatedCardProps {
6 | children: React.ReactNode;
7 | delay?: number;
8 | className?: string;
9 | }
10 |
11 | const AnimatedCard: React.FC = ({ children, delay = 0, className }) => {
12 | return (
13 |
24 | {children}
25 |
26 | );
27 | };
28 |
29 | export default AnimatedCard;
30 |
--------------------------------------------------------------------------------
/components/reusable/AudioButton.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { useJapaneseTTS } from '@/hooks/useJapaneseTTS';
3 | import { Volume2, Loader2 } from 'lucide-react';
4 | import clsx from 'clsx';
5 | import { buttonBorderStyles } from '@/static/styles';
6 | import usePreferencesStore from '@/store/usePreferencesStore';
7 |
8 | interface AudioButtonProps {
9 | text: string;
10 | className?: string;
11 | size?: 'sm' | 'md' | 'lg';
12 | variant?: 'default' | 'minimal' | 'icon-only';
13 | disabled?: boolean;
14 | onPlay?: () => void;
15 | onStop?: () => void;
16 | }
17 |
18 | const AudioButton: React.FC = ({
19 | text,
20 | className,
21 | size = 'md',
22 | variant = 'default',
23 | disabled = false,
24 | onPlay,
25 | onStop
26 | }) => {
27 | const { speak, stop, isPlaying, isSupported, refreshVoices } =
28 | useJapaneseTTS();
29 |
30 | // Get pronunciation settings from theme store
31 | const pronunciationEnabled = usePreferencesStore(
32 | state => state.pronunciationEnabled
33 | );
34 | const pronunciationSpeed = usePreferencesStore(
35 | state => state.pronunciationSpeed
36 | );
37 | const pronunciationPitch = usePreferencesStore(
38 | state => state.pronunciationPitch
39 | );
40 |
41 | const handleClick = async () => {
42 | if (disabled || !pronunciationEnabled) return;
43 |
44 | if (isPlaying) {
45 | stop();
46 | onStop?.();
47 | } else {
48 | onPlay?.();
49 |
50 | // Refresh voices before speaking
51 | if (typeof window !== 'undefined') {
52 | refreshVoices();
53 | // Small delay to ensure voices are loaded
54 | await new Promise(resolve => setTimeout(resolve, 100));
55 | }
56 |
57 | await speak(text, {
58 | rate: pronunciationSpeed,
59 | pitch: pronunciationPitch,
60 | volume: 0.8
61 | });
62 | }
63 | };
64 |
65 | const sizeClasses = {
66 | sm: 'p-2 text-sm',
67 | md: 'p-3 text-base',
68 | lg: 'p-4 text-lg'
69 | };
70 |
71 | const iconSizes = {
72 | sm: 16,
73 | md: 20,
74 | lg: 24
75 | };
76 |
77 | // Don't render if pronunciation is disabled
78 | if (!pronunciationEnabled) {
79 | return null;
80 | }
81 |
82 | // Show working button even if TTS support is not detected
83 | if (!isSupported) {
84 | return (
85 |
96 |
97 |
98 | );
99 | }
100 |
101 | const getIcon = () => {
102 | if (isPlaying) {
103 | return ;
104 | }
105 | return ;
106 | };
107 |
108 | if (variant === 'icon-only') {
109 | return (
110 |
123 | {getIcon()}
124 |
125 | );
126 | }
127 |
128 | if (variant === 'minimal') {
129 | return (
130 |
142 | {getIcon()}
143 | {isPlaying ? 'Stop' : 'Play'}
144 |
145 | );
146 | }
147 |
148 | // Default variant
149 | return (
150 |
165 | {getIcon()}
166 | {isPlaying ? 'Stop' : 'Play'} Audio
167 |
168 | );
169 | };
170 |
171 | export default AudioButton;
172 |
--------------------------------------------------------------------------------
/components/reusable/DevNotice.tsx:
--------------------------------------------------------------------------------
1 | // import {
2 | // Select,
3 | // SelectContent,
4 | // SelectGroup,
5 | // SelectItem,
6 | // SelectLabel,
7 | // SelectTrigger,
8 | // SelectValue
9 | // } from '@/components/ui/select';
10 |
11 | const DevNotice = () => {
12 | return (
13 |
14 | {/*
15 |
16 |
17 |
18 |
19 |
20 | Fruits
21 | Apple
22 | Banana
23 | Blueberry
24 | Grapes
25 | Pineapple
26 |
27 |
28 | */}
29 |
30 | );
31 | };
32 | export default DevNotice;
33 |
--------------------------------------------------------------------------------
/components/reusable/FuriganaText.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { ReactNode } from 'react';
3 | import usePreferencesStore from '@/store/usePreferencesStore';
4 |
5 | interface FuriganaTextProps {
6 | text: string;
7 | reading?: string;
8 | className?: string;
9 | furiganaClassName?: string;
10 | lang?: string;
11 | children?: ReactNode;
12 | }
13 |
14 | /**
15 | * Component for displaying Japanese text with optional furigana (reading annotations)
16 | * When furigana is enabled in settings, displays reading above the main text
17 | * When disabled, displays only the main text
18 | */
19 | const FuriganaText = ({
20 | text,
21 | reading,
22 | className = '',
23 | furiganaClassName = '',
24 | lang = 'ja',
25 | children
26 | }: FuriganaTextProps) => {
27 | const furiganaEnabled = usePreferencesStore(state => state.furiganaEnabled);
28 |
29 | // If children are provided, render them with optional furigana
30 | if (children) {
31 | if (furiganaEnabled && reading) {
32 | return (
33 |
34 | {children}
35 | {reading}
36 |
37 | );
38 | }
39 | return (
40 |
41 | {children}
42 |
43 | );
44 | }
45 |
46 | if (furiganaEnabled && reading) {
47 | const hiraganaReading = reading.includes(' ')
48 | ? reading.split(' ')[1]
49 | : reading;
50 |
51 | return (
52 |
53 | {text}
54 | {hiraganaReading}
55 |
56 | );
57 | }
58 | return (
59 |
60 | {text}
61 |
62 | );
63 | };
64 |
65 | export default FuriganaText;
66 |
--------------------------------------------------------------------------------
/components/reusable/Game/Animals.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import useStatsStore from '@/store/useStatsStore';
3 | // import { Star } from 'lucide-react';
4 | import clsx from 'clsx';
5 | import { animalIcons } from '@/static/icons';
6 |
7 | const Stars = () => {
8 | // const stars = useStatsStore(state => state.stars);
9 | const iconIndices = useStatsStore(state => state.iconIndices);
10 | const animalIconsToDisplay = iconIndices.map(index => animalIcons[index]);
11 |
12 | return (
13 |
14 |
15 | {/* {Array.from({ length: stars }, (_, index) => (
16 |
= 15
21 | ? 'motion-safe:animate-spin'
22 | : stars >= 10
23 | ? 'motion-safe:animate-bounce'
24 | : stars >= 5
25 | ? 'motion-safe:animate-pulse'
26 | : '',
27 |
28 | 'text-[var(--secondary-color)]'
29 | )}
30 | style={{
31 | animationDelay: `${index * 100}ms`
32 | }}
33 | />
34 | ))} */}
35 | {animalIconsToDisplay.map((Icon, index) => (
36 | = 20
41 | ? 'motion-safe:animate-ping'
42 | : iconIndices.length >= 15
43 | ? 'motion-safe:animate-spin'
44 | : iconIndices.length >= 10
45 | ? 'motion-safe:animate-bounce'
46 | : iconIndices.length >= 5
47 | ? 'motion-safe:animate-pulse'
48 | : ''
49 | )}
50 | style={{
51 | animationDelay: `${index * (iconIndices.length >= 20 ? 500 : 100)}ms`
52 | }}
53 | >
54 | {Icon}
55 |
56 | ))}
57 |
58 |
59 | );
60 | };
61 |
62 | export default Stars;
63 |
64 |
--------------------------------------------------------------------------------
/components/reusable/Game/GameIntel.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { SquareCheck, SquareX, Star, Coffee } from 'lucide-react';
3 | import { MousePointerClick, Keyboard, MousePointer } from 'lucide-react';
4 | import clsx from 'clsx';
5 | import { cardBorderStyles } from '@/static/styles';
6 | import useStatsStore from '@/store/useStatsStore';
7 | import { miniButtonBorderStyles } from '@/static/styles';
8 | import { ChartSpline } from 'lucide-react';
9 | import { useStopwatch } from 'react-timer-hook';
10 | import { useClick } from '@/hooks/useAudio';
11 | import useKanjiStore from '@/store/useKanjiStore';
12 | import useVocabStore from '@/store/useVocabStore';
13 | import { usePathname } from 'next/navigation';
14 | import { removeLocaleFromPath } from '@/lib/pathUtils';
15 |
16 | const GameIntel = ({
17 | gameMode,
18 | feedback
19 | }: {
20 | gameMode: string;
21 | feedback?: React.JSX.Element;
22 | }) => {
23 | const numCorrectAnswers = useStatsStore(state => state.numCorrectAnswers);
24 | const numWrongAnswers = useStatsStore(state => state.numWrongAnswers);
25 | const numStars = useStatsStore(state => state.stars);
26 |
27 | const totalTimeStopwatch = useStopwatch({ autoStart: false });
28 |
29 | const toggleStats = useStatsStore(state => state.toggleStats);
30 | const setNewTotalMilliseconds = useStatsStore(
31 | state => state.setNewTotalMilliseconds
32 | );
33 |
34 | const { playClick } = useClick();
35 |
36 | const pathname = usePathname();
37 | const pathWithoutLocale = removeLocaleFromPath(pathname);
38 | const trainingDojo = pathWithoutLocale.split('/')[1];
39 |
40 | const selectedKanjiSets = useKanjiStore(state => state.selectedKanjiSets);
41 | const selectedVocabSets = useVocabStore(state => state.selectedVocabSets);
42 |
43 | // useEffect(() => {
44 | // if (!isHidden) totalTimeStopwatch.start();
45 | // }, [isHidden]);
46 |
47 | return (
48 |
56 |
62 |
67 |
68 | {gameMode.toLowerCase() === 'pick' && (
69 |
70 | )}
71 | {gameMode.toLowerCase() === 'reverse pick' && (
72 |
73 | )}
74 | {gameMode.toLowerCase() === 'input' && (
75 |
76 | )}
77 | {gameMode.toLowerCase() === 'reverse input' && (
78 |
79 | )}
80 | {gameMode}
81 |
82 | {
90 | playClick();
91 | window.open('https://ko-fi.com/kanadojo', '_blank');
92 | }}
93 | >
94 |
95 |
96 |
97 |
98 |
99 |
106 |
107 |
112 |
113 |
114 | {numCorrectAnswers}
115 |
116 |
117 |
118 | {numWrongAnswers}
119 |
120 |
121 |
122 | {numStars}
123 |
124 |
125 |
{
133 | playClick();
134 | toggleStats();
135 | totalTimeStopwatch.pause();
136 | setNewTotalMilliseconds(totalTimeStopwatch.totalMilliseconds);
137 | }}
138 | >
139 |
140 |
141 |
142 |
143 |
144 | {feedback && (
145 |
146 | {feedback}
147 |
148 | )}
149 |
150 |
156 |
157 |
158 | selected sets:
159 |
160 |
161 | {trainingDojo === 'kanji'
162 | ? selectedKanjiSets.sort().join(', ').toLowerCase()
163 | : trainingDojo === 'vocabulary'
164 | ? selectedVocabSets.sort().join(', ').toLowerCase()
165 | : null}
166 |
167 |
168 |
169 | );
170 | };
171 |
172 | export default GameIntel;
173 |
--------------------------------------------------------------------------------
/components/reusable/Game/ProgressBar.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { useEffect } from 'react';
3 | import useStatsStore from '@/store/useStatsStore';
4 | // import useIndefiniteConfetti from '@/lib/hooks/useInfiniteConfetti';
5 | import { Random } from 'random-js';
6 | import { animalIconsLength } from '@/static/icons';
7 |
8 | const random = new Random();
9 |
10 | interface Checkpoint {
11 | position: number;
12 | label: string;
13 | }
14 |
15 | type CheckpointInput = number | Checkpoint;
16 |
17 | interface ProgressBarProps {
18 | value?: number;
19 | max?: number;
20 | checkpoints?: CheckpointInput[];
21 | }
22 |
23 | const ProgressBar = ({
24 | max = 20
25 | }: // checkpoints = [10, 25, 50, 75] // Default checkpoints at 25%, 50%, 75%
26 | ProgressBarProps) => {
27 | const score = useStatsStore(state => state.score);
28 | const setScore = useStatsStore(state => state.setScore);
29 |
30 | const stars = useStatsStore(state => state.stars);
31 | const setStars = useStatsStore(state => state.setStars);
32 |
33 | const addIconIndex = useStatsStore(state => state.addIconIndex);
34 |
35 | const percentage = (score / max) * 100;
36 |
37 | // const [active, setActive] = useState(false);
38 |
39 | // const emojiArray = ['🍹'];
40 |
41 | // useIndefiniteConfetti({ active, emojis: emojiArray });
42 |
43 | // console.log('active', active);
44 | // console.log('score', score);
45 | useEffect(() => {
46 | if (score >= max) {
47 | setScore(0);
48 | setStars(stars + 1);
49 | const newIconIndex = random.integer(0, animalIconsLength - 1);
50 | addIconIndex(newIconIndex);
51 | }
52 | }, [score]);
53 |
54 | return (
55 |
56 | {/* Progress Bar Background */}
57 |
58 | {/* Progress Indicator */}
59 |
63 | {/* Checkpoints */}
64 | {[25, 50, 75].map(cp => (
65 |
72 | ))}
73 |
74 |
75 | );
76 | };
77 |
78 | export default ProgressBar;
79 |
--------------------------------------------------------------------------------
/components/reusable/Game/ReturnFromGame.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { useEffect, useRef } from 'react';
3 | import clsx from 'clsx';
4 | import { Link } from '@/i18n/routing';
5 | import { useClick } from '@/hooks/useAudio';
6 | import { useStopwatch } from 'react-timer-hook';
7 | import useStatsStore from '@/store/useStatsStore';
8 | import { X } from 'lucide-react';
9 | import ProgressBar from './ProgressBar';
10 |
11 | const Return = ({ isHidden, href }: { isHidden: boolean; href: string }) => {
12 | const totalTimeStopwatch = useStopwatch({ autoStart: false });
13 | const saveSession = useStatsStore(state => state.saveSession);
14 |
15 | const { playClick } = useClick();
16 |
17 | useEffect(() => {
18 | if (!isHidden) totalTimeStopwatch.start();
19 | }, [isHidden]);
20 |
21 | const buttonRef = useRef(null);
22 |
23 | useEffect(() => {
24 | const handleKeyDown = (event: KeyboardEvent) => {
25 | if (event.key === 'Escape') {
26 | buttonRef.current?.click();
27 | } else if (event.code === 'Space' || event.key === ' ') {
28 | // event.preventDefault();
29 | }
30 | };
31 | window.addEventListener('keydown', handleKeyDown);
32 |
33 | return () => {
34 | window.removeEventListener('keydown', handleKeyDown);
35 | };
36 | }, []);
37 |
38 | return (
39 |
47 |
{
52 | playClick();
53 | saveSession();
54 | }}
55 | >
56 |
62 |
63 |
64 |
65 | );
66 | };
67 |
68 | export default Return;
69 |
--------------------------------------------------------------------------------
/components/reusable/Game/Stars.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import useStatsStore from '@/store/useStatsStore';
3 | import { Star } from 'lucide-react';
4 | import clsx from 'clsx';
5 |
6 | const Stars = () => {
7 | const stars = useStatsStore(state => state.stars);
8 |
9 | return (
10 |
11 |
12 | {Array.from({ length: stars }, (_, index) => (
13 | = 15
18 | ? 'motion-safe:animate-spin'
19 | : stars >= 10
20 | ? 'motion-safe:animate-bounce'
21 | : stars >= 5
22 | ? 'motion-safe:animate-pulse'
23 | : '',
24 |
25 | 'text-[var(--secondary-color)]'
26 | )}
27 | style={{
28 | animationDelay: `${index * 100}ms`
29 | }}
30 | />
31 | ))}
32 |
33 |
34 | );
35 | };
36 |
37 | export default Stars;
38 |
--------------------------------------------------------------------------------
/components/reusable/LanguageSelector.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useParams } from 'next/navigation';
4 | import { useRouter, usePathname } from '@/i18n/routing';
5 | import { localeNames, type Locale } from '@/i18n/config';
6 | import { routing } from '@/i18n/routing';
7 |
8 | export function LanguageSelector() {
9 | const params = useParams();
10 | const router = useRouter();
11 | const pathname = usePathname();
12 | const currentLocale = (params?.locale as Locale) || routing.defaultLocale;
13 |
14 | const changeLocale = (newLocale: Locale) => {
15 | router.replace(pathname, { locale: newLocale });
16 | };
17 |
18 | return (
19 | changeLocale(e.target.value as Locale)}
22 | className="px-3 py-2 border rounded-lg bg-white dark:bg-gray-800 dark:border-gray-700"
23 | aria-label="Select language"
24 | >
25 | {routing.locales.map((locale) => (
26 |
27 | {localeNames[locale]}
28 |
29 | ))}
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/components/reusable/Link.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Link as NextIntlLink } from '@/i18n/routing';
4 | import { ComponentProps } from 'react';
5 |
6 | type LinkProps = ComponentProps;
7 |
8 | export function Link(props: LinkProps) {
9 | return ;
10 | }
11 |
12 | export default Link;
13 |
--------------------------------------------------------------------------------
/components/reusable/Menu/Banner.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import clsx from 'clsx';
3 | import { usePathname } from 'next/navigation';
4 | import { removeLocaleFromPath } from '@/lib/pathUtils';
5 |
6 | const Banner = () => {
7 | const pathname = usePathname();
8 | const pathWithoutLocale = removeLocaleFromPath(pathname);
9 |
10 | const subheading =
11 | pathWithoutLocale === '/kana'
12 | ? 'Kana あ'
13 | : pathWithoutLocale === '/kanji'
14 | ? 'Kanji 字'
15 | : pathWithoutLocale === '/vocabulary'
16 | ? 'Vocabulary 語'
17 | : pathWithoutLocale === '/preferences'
18 | ? 'Preferences 設'
19 | : '';
20 | return (
21 |
22 |
23 | {subheading.split(' ')[1]}
24 |
25 | {subheading.split(' ')[0]}
26 |
27 | );
28 | };
29 |
30 | export default Banner;
31 |
--------------------------------------------------------------------------------
/components/reusable/Menu/DojoMenu.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import clsx from 'clsx';
3 | import TopBar from '@/components/reusable/Menu/TopBar';
4 | import { useState, useEffect } from 'react';
5 | import Sidebar from '@/components/reusable/Menu/Sidebar';
6 | import Info from '@/components/reusable/Menu/Info';
7 | import GameModes from '@/components/reusable/Menu/GameModes';
8 | import KanaCards from '@/components/Dojo/Kana/KanaCards';
9 | import Banner from '@/components/reusable/Menu/Banner';
10 | import CollectionSelector from '@/components/reusable/Menu/CollectionSelector';
11 | import KanjiCards from '@/components/Dojo/Kanji';
12 | import { usePathname } from 'next/navigation';
13 | import VocabCards from '@/components/Dojo/Vocab';
14 | import { removeLocaleFromPath } from '@/lib/pathUtils';
15 |
16 | const DojoMenu = () => {
17 | const pathname = usePathname();
18 | const pathWithoutLocale = removeLocaleFromPath(pathname);
19 |
20 | const [showGameModes, setShowGameModes] = useState(false);
21 |
22 | useEffect(() => {
23 | // clearKanji();
24 | // clearWords();
25 | }, []);
26 |
27 | return (
28 |
29 |
30 |
37 |
38 |
39 |
40 | {(pathWithoutLocale === '/kanji' || pathWithoutLocale === '/vocabulary') && (
41 |
42 | )}
43 |
48 | {showGameModes && }
49 |
50 | {pathWithoutLocale === '/kana' ? (
51 |
52 | ) : pathWithoutLocale === '/kanji' ? (
53 |
54 | ) : pathWithoutLocale === '/vocabulary' ? (
55 |
56 | ) : null}
57 |
58 |
59 | );
60 | };
61 |
62 | export default DojoMenu;
63 |
--------------------------------------------------------------------------------
/components/reusable/Menu/GameModes.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { Fragment } from 'react';
3 | import useKanaStore from '@/store/useKanaStore';
4 | import useKanjiStore from '@/store/useKanjiStore';
5 | import useVocabStore from '@/store/useVocabStore';
6 | import { MousePointerClick, Keyboard, CircleCheck, Circle } from 'lucide-react';
7 | import clsx from 'clsx';
8 | import { useClick } from '@/hooks/useAudio';
9 | import { usePathname } from 'next/navigation';
10 | import { useShallow } from 'zustand/react/shallow';
11 | import { removeLocaleFromPath } from '@/lib/pathUtils';
12 |
13 | const GameModes = () => {
14 | const pathname = usePathname();
15 | const pathWithoutLocale = removeLocaleFromPath(pathname);
16 |
17 | const { playClick } = useClick();
18 |
19 | const { selectedGameModeKana, setSelectedGameModeKana } = useKanaStore(
20 | useShallow(state => ({
21 | selectedGameModeKana: state.selectedGameModeKana,
22 | setSelectedGameModeKana: state.setSelectedGameModeKana
23 | }))
24 | );
25 |
26 | const { selectedGameModeKanji, setSelectedGameModeKanji } = useKanjiStore(
27 | useShallow(state => ({
28 | selectedGameModeKanji: state.selectedGameModeKanji,
29 | setSelectedGameModeKanji: state.setSelectedGameModeKanji
30 | }))
31 | );
32 |
33 | const selectedGameModeVocab = useVocabStore(
34 | useShallow(state => state.selectedGameModeVocab)
35 | );
36 |
37 | const selectedGameMode =
38 | pathWithoutLocale === '/kana'
39 | ? selectedGameModeKana
40 | : pathWithoutLocale === '/kanji'
41 | ? selectedGameModeKanji
42 | : pathWithoutLocale === '/vocabulary'
43 | ? selectedGameModeVocab
44 | : '';
45 |
46 | const setSelectedGameModeVocab = useVocabStore(
47 | useShallow(state => state.setSelectedGameModeVocab)
48 | );
49 |
50 | const setSelectedGameMode =
51 | pathWithoutLocale === '/kana'
52 | ? setSelectedGameModeKana
53 | : pathWithoutLocale === '/kanji'
54 | ? setSelectedGameModeKanji
55 | : pathWithoutLocale === '/vocabulary'
56 | ? setSelectedGameModeVocab
57 | : () => {};
58 |
59 | const gameModes = ['Pick', 'Reverse-Pick', 'Input', 'Reverse-Input'];
60 |
61 | return (
62 |
71 | {gameModes.map((gameMode, i) => (
72 |
73 | playClick()}
85 | >
86 | setSelectedGameMode(gameMode)}
90 | className='hidden'
91 | />
92 |
93 | {gameMode === selectedGameMode ? (
94 |
95 | ) : (
96 |
97 | )}
98 | {gameMode.split('-').join(' ')}
99 | {gameMode.toLowerCase() === 'pick' && (
100 |
104 | )}
105 | {gameMode.toLowerCase() === 'reverse-pick' && (
106 |
110 | )}
111 | {gameMode.toLowerCase() === 'input' && (
112 |
113 | )}
114 | {gameMode.toLowerCase() === 'reverse-input' && (
115 |
119 | )}
120 |
121 |
122 |
123 | {i < gameModes.length - 1 && (
124 |
131 | )}
132 |
133 | ))}
134 |
135 | );
136 | };
137 |
138 | export default GameModes;
139 |
--------------------------------------------------------------------------------
/components/reusable/Menu/Info.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import clsx from 'clsx';
3 | import { useState } from 'react';
4 | import { useClick } from '@/hooks/useAudio';
5 | import { cardBorderStyles } from '@/static/styles';
6 | import { ChevronUp } from 'lucide-react';
7 | import translationGen from '@/static/info';
8 | import { motion } from 'framer-motion';
9 | import { usePathname } from 'next/navigation';
10 | import { removeLocaleFromPath } from '@/lib/pathUtils';
11 | import { useTranslations } from 'next-intl';
12 |
13 | const Info = () => {
14 | const pathname = usePathname();
15 | const t = useTranslations('MenuInfo');
16 |
17 | // Remove locale from pathname (e.g., /en/kana -> /kana)
18 | const pathWithoutLocale = removeLocaleFromPath(pathname);
19 |
20 | // Get translations object, passing the translation function
21 | const translations = translationGen(t);
22 |
23 | // Get page data with fallback to home
24 | const pageData =
25 | translations[pathWithoutLocale as keyof typeof translations] ||
26 | translations['/'];
27 |
28 | // Provide default values to avoid destructuring undefined
29 | const { header, content } = pageData || { header: '', content: '' };
30 |
31 | const { playClick } = useClick();
32 |
33 | const [showInfo, setShowInfo] = useState(
34 | ['/kana', '/kanji', '/vocabulary', '/', '/sentences'].includes(
35 | pathWithoutLocale
36 | )
37 | ? true
38 | : false
39 | );
40 |
41 | return (
42 |
48 | {
54 | playClick();
55 | setShowInfo(showInfo => !showInfo);
56 | }}
57 | initial={{ opacity: 0 }}
58 | animate={{ opacity: 1 }}
59 | transition={{ duration: 0.4, ease: 'linear' }}
60 | >
61 |
71 | {header}
72 |
73 |
83 | {content}
84 |
85 |
86 | );
87 | };
88 |
89 | export default Info;
90 |
--------------------------------------------------------------------------------
/components/reusable/PostWrapper.tsx:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 | import ReactMarkdown from 'react-markdown';
3 | import remarkGfm from 'remark-gfm';
4 | import Banner from './Menu/Banner';
5 | import { buttonBorderStyles } from '@/static/styles';
6 | import { ChevronsLeft } from 'lucide-react';
7 | import { Link } from '@/i18n/routing';
8 |
9 | const PostWrapper = ({ textContent }: { textContent: string }) => {
10 | return (
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
(
22 |
23 | ),
24 | h2: props => (
25 |
26 | ),
27 | h3: props => (
28 |
29 | ),
30 | p: props => (
31 |
35 | ),
36 | ul: props => (
37 |
41 | ),
42 | ol: props => (
43 |
47 | ),
48 | li: props => ,
49 | a: props => ,
50 |
51 | table: props => (
52 |
56 | ),
57 | th: props => (
58 |
62 | ),
63 | td: props => (
64 |
68 | ),
69 | hr: props => (
70 |
71 | )
72 | }}
73 | >
74 | {textContent}
75 |
76 |
77 | );
78 | };
79 |
80 | export default PostWrapper;
81 |
--------------------------------------------------------------------------------
/components/reusable/SSRAudioButton.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { useState, useEffect } from 'react';
3 | import AudioButton from './AudioButton';
4 | import usePreferencesStore from '@/store/usePreferencesStore';
5 |
6 | interface SSRAudioButtonProps {
7 | text: string;
8 | className?: string;
9 | size?: 'sm' | 'md' | 'lg';
10 | variant?: 'default' | 'minimal' | 'icon-only';
11 | disabled?: boolean;
12 | onPlay?: () => void;
13 | onStop?: () => void;
14 | }
15 |
16 | const SSRAudioButton: React.FC = props => {
17 | const [isClient, setIsClient] = useState(false);
18 | const pronunciationEnabled = usePreferencesStore(
19 | state => state.pronunciationEnabled
20 | );
21 |
22 | useEffect(() => {
23 | setIsClient(true);
24 | }, []);
25 |
26 | // Don't render during SSR to prevent hydration mismatches
27 | if (!isClient) {
28 | return null;
29 | }
30 |
31 | // If pronunciation is disabled, show a placeholder
32 | if (!pronunciationEnabled) {
33 | return (
34 |
35 | Audio disabled
36 |
37 | );
38 | }
39 |
40 | return ;
41 | };
42 |
43 | export default SSRAudioButton;
44 |
--------------------------------------------------------------------------------
/components/reusable/Skeletons/Loader.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { useState, useEffect } from 'react';
3 | import BarLoader from 'react-spinners/BarLoader';
4 |
5 | const Loader = () => {
6 | const [loaderColor, setLoaderColor] = useState('#f1f7fb');
7 |
8 | useEffect(() => {
9 | // Ensure this runs only in a browser (not SSR)
10 | if (typeof window !== 'undefined') {
11 | const color = getComputedStyle(document.documentElement)
12 | .getPropertyValue('--main-color')
13 | .trim();
14 |
15 | if (color) {
16 | setLoaderColor(color);
17 | }
18 | }
19 | }, []); // Runs once when component mounts (client-side)
20 |
21 | return (
22 |
23 |
29 |
30 | );
31 | };
32 |
33 | export default Loader;
34 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-transparent text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--main-color)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background-color)] disabled:pointer-events-none disabled:opacity-60 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-[var(--main-color)] text-[var(--background-color)] shadow-[0_10px_30px_-12px_rgba(0,0,0,0.45)] hover:brightness-110 hover:shadow-[0_12px_36px_-16px_rgba(0,0,0,0.55)] active:brightness-95",
14 | destructive:
15 | "bg-red-500 text-white shadow-sm hover:bg-red-600 focus-visible:ring-red-500",
16 | outline:
17 | "border border-[var(--border-color)] bg-transparent text-[var(--main-color)] hover:bg-[var(--card-color)] hover:text-[var(--main-color)]",
18 | secondary:
19 | "border border-[var(--border-color)] bg-[var(--card-color)] text-[var(--main-color)] shadow-sm hover:bg-[var(--border-color)]",
20 | ghost:
21 | "bg-transparent text-[var(--main-color)] hover:bg-[var(--card-color)]",
22 | link:
23 | "text-[var(--main-color)] underline-offset-4 hover:text-[var(--secondary-color)] hover:underline",
24 | },
25 | size: {
26 | default: "h-10 px-5",
27 | sm: "h-8 rounded-md px-3 text-xs",
28 | lg: "h-12 rounded-xl px-8 text-base",
29 | icon: "h-10 w-10",
30 | },
31 | },
32 | defaultVariants: {
33 | variant: "default",
34 | size: "default",
35 | },
36 | }
37 | )
38 |
39 | export interface ButtonProps
40 | extends React.ButtonHTMLAttributes,
41 | VariantProps {
42 | asChild?: boolean
43 | }
44 |
45 | const Button = React.forwardRef(
46 | ({ className, variant, size, asChild = false, ...props }, ref) => {
47 | const Comp = asChild ? Slot : "button"
48 | return (
49 |
54 | )
55 | }
56 | )
57 | Button.displayName = "Button"
58 |
59 | export { Button, buttonVariants }
60 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { dirname } from "path";
2 | import { fileURLToPath } from "url";
3 | import { FlatCompat } from "@eslint/eslintrc";
4 |
5 | const __filename = fileURLToPath(import.meta.url);
6 | const __dirname = dirname(__filename);
7 |
8 | const compat = new FlatCompat({
9 | baseDirectory: __dirname,
10 | });
11 |
12 | const eslintConfig = [
13 | ...compat.extends("next/core-web-vitals", "next/typescript"),
14 | ];
15 |
16 | export default eslintConfig;
17 |
--------------------------------------------------------------------------------
/global.d.ts:
--------------------------------------------------------------------------------
1 | // Declaraciones de tipo para importaciones de CSS y otros módulos
2 | declare module '*.css' {
3 | const content: { [className: string]: string };
4 | export default content;
5 | }
6 |
7 | declare module '@fortawesome/fontawesome-svg-core/styles.css';
8 |
--------------------------------------------------------------------------------
/hooks/useAchievements.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useCallback } from 'react';
2 | import useAchievementStore, { type Achievement } from '@/store/useAchievementStore';
3 | import useStatsStore from '@/store/useStatsStore';
4 |
5 | interface UseAchievementsReturn {
6 | checkForNewAchievements: () => Achievement[];
7 | totalPoints: number;
8 | level: number;
9 | unlockedCount: number;
10 | hasUnseenNotifications: boolean;
11 | }
12 |
13 | /**
14 | * Hook to integrate achievements with the game flow
15 | * Automatically checks for new achievements when stats change
16 | */
17 | export const useAchievements = (): UseAchievementsReturn => {
18 | const stats = useStatsStore();
19 | const achievementStore = useAchievementStore();
20 |
21 | // Check for new achievements based on current stats
22 | const checkForNewAchievements = useCallback(() => {
23 | return achievementStore.checkAchievements(stats);
24 | }, [stats, achievementStore]);
25 |
26 | // Auto-check achievements when relevant stats change
27 | useEffect(() => {
28 | checkForNewAchievements();
29 | }, [
30 | stats.allTimeStats.totalCorrect,
31 | stats.allTimeStats.bestStreak,
32 | stats.allTimeStats.totalSessions,
33 | checkForNewAchievements
34 | ]);
35 |
36 | const unlockedCount = Object.keys(achievementStore.unlockedAchievements).length;
37 | const hasUnseenNotifications = useAchievementStore(state => state.hasUnseenNotifications);
38 |
39 | return {
40 | checkForNewAchievements,
41 | totalPoints: achievementStore.totalPoints,
42 | level: achievementStore.level,
43 | unlockedCount,
44 | hasUnseenNotifications
45 | };
46 | };
47 |
48 | /**
49 | * Hook specifically for triggering achievement checks after game actions
50 | */
51 | export const useAchievementTrigger = () => {
52 | const achievementStore = useAchievementStore();
53 | const stats = useStatsStore();
54 |
55 | const triggerAchievementCheck = useCallback(() => {
56 | const newAchievements = achievementStore.checkAchievements(stats);
57 | return newAchievements;
58 | }, [achievementStore, stats]);
59 |
60 | return { triggerAchievementCheck };
61 | };
62 |
63 | export default useAchievements;
--------------------------------------------------------------------------------
/hooks/useAudio.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { useCallback } from 'react';
3 | import useSound from 'use-sound';
4 | import { Random } from 'random-js';
5 | import usePreferencesStore from '@/store/usePreferencesStore';
6 |
7 | const random = new Random();
8 |
9 | const clickSoundUrls = [
10 | /* '/sounds/click/click9/click9_1.wav',
11 | '/sounds/click/click9/click9_2.wav',
12 | '/sounds/click/click9/click9_3.wav',
13 | '/sounds/click/click9/click9_4.wav',
14 | '/sounds/click/click9/click9_5.wav'
15 | */
16 | '/sounds/click/click4/click4_11.wav',
17 | '/sounds/click/click4/click4_22.wav',
18 | '/sounds/click/click4/click4_33.wav',
19 | '/sounds/click/click4/click4_44.wav',
20 | '/sounds/click/click4/click4_55.wav'
21 | ];
22 |
23 | export const useClick = () => {
24 | const silentMode = usePreferencesStore(state => state.silentMode);
25 |
26 | // Instead of mapping, call each useSound explicitly:
27 | const [play1] = useSound(clickSoundUrls[0], {
28 | volume: silentMode ? 0 : 1,
29 | interrupt: true
30 | });
31 | const [play2] = useSound(clickSoundUrls[1], {
32 | volume: silentMode ? 0 : 1,
33 | interrupt: true
34 | });
35 | const [play3] = useSound(clickSoundUrls[2], {
36 | volume: silentMode ? 0 : 1,
37 | interrupt: true
38 | });
39 | const [play4] = useSound(clickSoundUrls[3], {
40 | volume: silentMode ? 0 : 1,
41 | interrupt: true
42 | });
43 | const [play5] = useSound(clickSoundUrls[4], {
44 | volume: silentMode ? 0 : 1,
45 | interrupt: true
46 | });
47 |
48 | const playFns = [play1, play2, play3, play4, play5];
49 |
50 | const playClick = () => {
51 | const idx = random.integer(0, playFns.length - 1);
52 | playFns[idx]();
53 | };
54 |
55 | return { playClick };
56 | };
57 |
58 | export const useCorrect = () => {
59 | const silentMode = usePreferencesStore(state => state.silentMode);
60 |
61 | // This URL is static, so no need to memoize
62 | const successSoundUrl = '/sounds/correct.wav';
63 |
64 | const [play] = useSound(successSoundUrl, {
65 | volume: silentMode ? 0 : 0.7,
66 | interrupt: true
67 | });
68 |
69 | return { playCorrect: play };
70 | };
71 |
72 | export const useError = () => {
73 | const silentMode = usePreferencesStore(state => state.silentMode);
74 |
75 | // This URL is static, so no need to memoize
76 | const errorSoundUrl = '/sounds/error/error1/error1_1.wav';
77 |
78 | const [play] = useSound(errorSoundUrl, {
79 | volume: silentMode ? 0 : 1,
80 | interrupt: true
81 | });
82 |
83 | // Memoize the callback so it's not recreated every render
84 | const playErrorTwice = useCallback(() => {
85 | play();
86 | setTimeout(() => play(), 90);
87 | }, [play]);
88 |
89 | return {
90 | playError: play,
91 | playErrorTwice
92 | };
93 | };
94 |
95 | export const useLong = () => {
96 | const silentMode = usePreferencesStore(state => state.silentMode);
97 |
98 | // This URL is static, so no need to memoize
99 | const longSoundUrl = '/sounds/long.wav';
100 |
101 | const [play] = useSound(longSoundUrl, {
102 | volume: silentMode ? 0 : 0.2,
103 | interrupt: true
104 | });
105 |
106 | return { playLong: play };
107 | };
108 |
--------------------------------------------------------------------------------
/hooks/useGoalTimers.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useCallback, useRef } from 'react';
2 | import confetti from 'canvas-confetti';
3 |
4 | export interface GoalTimer {
5 | id: string;
6 | label: string;
7 | targetSeconds: number;
8 | reached: boolean;
9 | showAnimation?: boolean;
10 | playSound?: boolean;
11 | }
12 |
13 | interface UseGoalTimersOptions {
14 | enabled?: boolean;
15 | onGoalReached?: (goal: GoalTimer) => void;
16 | }
17 |
18 | export function useGoalTimers(
19 | currentSeconds: number,
20 | options: UseGoalTimersOptions = {}
21 | ) {
22 | const { enabled = true, onGoalReached } = options;
23 |
24 | const [goals, setGoals] = useState([]);
25 | const reachedGoalsRef = useRef>(new Set());
26 |
27 | // Add new goal
28 | const addGoal = useCallback((goal: Omit) => {
29 | const newGoal: GoalTimer = {
30 | ...goal,
31 | id: crypto.randomUUID(),
32 | reached: false,
33 | showAnimation: goal.showAnimation ?? true,
34 | playSound: goal.playSound ?? true,
35 | };
36 |
37 | setGoals(prev => [...prev, newGoal].sort((a, b) => a.targetSeconds - b.targetSeconds));
38 | return newGoal.id;
39 | }, []);
40 |
41 | // Remove goal
42 | const removeGoal = useCallback((goalId: string) => {
43 | setGoals(prev => prev.filter(g => g.id !== goalId));
44 | reachedGoalsRef.current.delete(goalId);
45 | }, []);
46 |
47 | // Clear all goals
48 | const clearGoals = useCallback(() => {
49 | setGoals([]);
50 | reachedGoalsRef.current.clear();
51 | }, []);
52 |
53 | // Reset goals (mark as not reached)
54 | const resetGoals = useCallback(() => {
55 | setGoals(prev => prev.map(g => ({ ...g, reached: false })));
56 | reachedGoalsRef.current.clear();
57 | }, []);
58 |
59 | // Trigger confetti animation
60 | const triggerConfetti = useCallback(() => {
61 | confetti({
62 | particleCount: 100,
63 | spread: 70,
64 | origin: { y: 0.6 }
65 | });
66 | }, []);
67 |
68 | // Play goal sound
69 | const playGoalSound = useCallback(() => {
70 | if (typeof Audio !== 'undefined') {
71 | const audio = new Audio('/sounds/correct.mp3');
72 | audio.volume = 0.5;
73 | audio.play().catch(() => {
74 | // Ignore errors if sound can't play
75 | });
76 | }
77 | }, []);
78 |
79 | // Check goals and trigger events
80 | useEffect(() => {
81 | if (!enabled || goals.length === 0) return;
82 |
83 | goals.forEach(goal => {
84 | // Only process if not reached before
85 | if (!goal.reached && !reachedGoalsRef.current.has(goal.id)) {
86 | if (currentSeconds >= goal.targetSeconds) {
87 | // Mark as reached
88 | setGoals(prev =>
89 | prev.map(g => g.id === goal.id ? { ...g, reached: true } : g)
90 | );
91 | reachedGoalsRef.current.add(goal.id);
92 |
93 | // Trigger effects
94 | if (goal.showAnimation) {
95 | triggerConfetti();
96 | }
97 |
98 | if (goal.playSound) {
99 | playGoalSound();
100 | }
101 |
102 | // Custom callback
103 | onGoalReached?.({ ...goal, reached: true });
104 | }
105 | }
106 | });
107 | }, [currentSeconds, goals, enabled, triggerConfetti, playGoalSound, onGoalReached]);
108 |
109 | // Get next unreached goal
110 | const nextGoal = goals.find(g => !g.reached);
111 |
112 | // Progress to next goal (0-100)
113 | const progressToNextGoal = nextGoal
114 | ? Math.min((currentSeconds / nextGoal.targetSeconds) * 100, 100)
115 | : 100;
116 |
117 | return {
118 | goals,
119 | addGoal,
120 | removeGoal,
121 | clearGoals,
122 | resetGoals,
123 | nextGoal,
124 | progressToNextGoal,
125 | reachedGoals: goals.filter(g => g.reached),
126 | pendingGoals: goals.filter(g => !g.reached),
127 | };
128 | }
129 |
--------------------------------------------------------------------------------
/hooks/useGridColumns.ts:
--------------------------------------------------------------------------------
1 | import { useMediaQuery } from 'react-responsive';
2 | import { useEffect, useState } from 'react';
3 |
4 | const useGridColumns = () => {
5 | const [isMounted, setIsMounted] = useState(false);
6 | const is2XL = useMediaQuery({ minWidth: 1536 }); // 4 cols
7 | const isLG = useMediaQuery({ minWidth: 1024 }); // 3 cols
8 | const isMD = useMediaQuery({ minWidth: 768 }); // 2 cols
9 |
10 | useEffect(() => {
11 | setIsMounted(true);
12 | }, []);
13 |
14 | // Return default value during SSR to avoid hydration mismatch
15 | if (!isMounted) {
16 | return 1;
17 | }
18 |
19 | return is2XL ? 3 : isLG ? 2 : isMD ? 1 : 1;
20 | };
21 |
22 | export default useGridColumns;
23 |
--------------------------------------------------------------------------------
/hooks/useInfiniteConfetti.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect, useRef } from 'react';
4 | import confetti from 'canvas-confetti';
5 |
6 | export interface UseIndefiniteConfettiOptions {
7 | active: boolean;
8 | emojis: string[];
9 | scalar?: number;
10 | duration?: number; // ms, for an auto-off style
11 | intervalMs?: number;
12 | particleBaseCount?: number;
13 | }
14 |
15 | function useFireworkEmojiConfetti({
16 | active,
17 | emojis,
18 | scalar = 2,
19 | duration,
20 | intervalMs = 250,
21 | particleBaseCount = 50
22 | }: UseIndefiniteConfettiOptions) {
23 | const intervalRef = useRef | null>(null);
24 |
25 | useEffect(() => {
26 | if (!active || !emojis.length) {
27 | if (intervalRef.current) {
28 | clearInterval(intervalRef.current);
29 | intervalRef.current = null;
30 | }
31 | return;
32 | }
33 |
34 | const shapes = emojis.map(emoji =>
35 | confetti.shapeFromText({ text: emoji, scalar })
36 | );
37 |
38 | // --- Firework style params
39 | const fireworkDuration = duration ?? 15_000;
40 | const animationEnd = Date.now() + fireworkDuration;
41 | const defaults = {
42 | startVelocity: 30,
43 | spread: 360,
44 | ticks: 60,
45 | zIndex: 0,
46 | shapes
47 | };
48 |
49 | function randomInRange(min: number, max: number) {
50 | return Math.random() * (max - min) + min;
51 | }
52 |
53 | const fireFn = () => {
54 | const timeLeft = animationEnd - Date.now();
55 |
56 | if (timeLeft <= 0) {
57 | if (intervalRef.current) {
58 | clearInterval(intervalRef.current);
59 | intervalRef.current = null;
60 | }
61 | return;
62 | }
63 |
64 | const particleCount = Math.round(
65 | particleBaseCount * (timeLeft / fireworkDuration)
66 | );
67 |
68 | // Two firework bursts per tick, different origins
69 | confetti({
70 | ...defaults,
71 | particleCount,
72 | origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }
73 | });
74 | confetti({
75 | ...defaults,
76 | particleCount,
77 | origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }
78 | });
79 | };
80 |
81 | fireFn();
82 | intervalRef.current = setInterval(fireFn, intervalMs);
83 |
84 | // Clean up
85 | return () => {
86 | if (intervalRef.current) {
87 | clearInterval(intervalRef.current);
88 | intervalRef.current = null;
89 | }
90 | };
91 | // eslint-disable-next-line react-hooks/exhaustive-deps
92 | }, [
93 | active,
94 | emojis.join(':'),
95 | scalar,
96 | duration,
97 | intervalMs,
98 | particleBaseCount
99 | ]);
100 | }
101 |
102 | export default useFireworkEmojiConfetti;
103 |
--------------------------------------------------------------------------------
/hooks/useStats.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 | import { useCallback } from 'react';
3 | import useStatsStore from '@/store/useStatsStore';
4 | import { useAchievementTrigger } from '@/hooks/useAchievements';
5 |
6 | const useStats = () => {
7 | const { triggerAchievementCheck } = useAchievementTrigger();
8 |
9 | const baseIncrementCorrectAnswers = useStatsStore(
10 | state => state.incrementCorrectAnswers
11 | );
12 | const baseIncrementWrongAnswers = useStatsStore(
13 | state => state.incrementWrongAnswers
14 | );
15 |
16 | // Wrap the increment functions to trigger achievement checks
17 | const incrementCorrectAnswers = useCallback(() => {
18 | baseIncrementCorrectAnswers();
19 | // Trigger achievement check after updating stats
20 | setTimeout(() => triggerAchievementCheck(), 100);
21 | }, [baseIncrementCorrectAnswers, triggerAchievementCheck]);
22 |
23 | const incrementWrongAnswers = useCallback(() => {
24 | baseIncrementWrongAnswers();
25 | // Trigger achievement check after updating stats
26 | setTimeout(() => triggerAchievementCheck(), 100);
27 | }, [baseIncrementWrongAnswers, triggerAchievementCheck]);
28 | const addCharacterToHistory = useStatsStore(
29 | state => state.addCharacterToHistory
30 | );
31 | const characterHistory = useStatsStore(state => state.characterHistory);
32 | const addCorrectAnswerTime = useStatsStore(
33 | state => state.addCorrectAnswerTime
34 | );
35 | const correctAnswerTimes = useStatsStore(state => state.correctAnswerTimes);
36 |
37 | const incrementCharacterScore = useStatsStore(
38 | state => state.incrementCharacterScore
39 | );
40 |
41 | return {
42 | incrementCorrectAnswers,
43 | incrementWrongAnswers,
44 | addCharacterToHistory,
45 | characterHistory,
46 | addCorrectAnswerTime,
47 | correctAnswerTimes,
48 | incrementCharacterScore
49 | };
50 | };
51 |
52 | export default useStats;
53 |
--------------------------------------------------------------------------------
/hooks/useTimer.ts:
--------------------------------------------------------------------------------
1 | import { useTimer } from 'react-timer-hook';
2 | import { useRef, useEffect } from 'react';
3 |
4 | export function useChallengeTimer(durationSeconds: number) {
5 | const durationRef = useRef(durationSeconds);
6 |
7 | // Update reference when duration changes
8 | useEffect(() => {
9 | durationRef.current = durationSeconds;
10 | }, [durationSeconds]);
11 |
12 | const expiryTimestamp = new Date();
13 | expiryTimestamp.setSeconds(expiryTimestamp.getSeconds() + durationSeconds);
14 |
15 | const {
16 | seconds,
17 | minutes,
18 | isRunning,
19 | start,
20 | pause,
21 | resume,
22 | restart,
23 | } = useTimer({ expiryTimestamp, autoStart: false });
24 |
25 | const resetTimer = () => {
26 | const newExpiry = new Date();
27 | newExpiry.setSeconds(newExpiry.getSeconds() + durationRef.current);
28 | restart(newExpiry, false);
29 | };
30 |
31 | return {
32 | seconds,
33 | minutes,
34 | isRunning,
35 | startTimer: start,
36 | pauseTimer: pause,
37 | resumeTimer: resume,
38 | resetTimer,
39 | timeLeft: minutes * 60 + seconds,
40 | };
41 | }
--------------------------------------------------------------------------------
/hooks/useTranslation.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useTranslations as useNextIntlTranslations } from 'next-intl';
4 | import { useParams } from 'next/navigation';
5 | import type { Locale } from '@/i18n/config';
6 |
7 | export function useTranslation() {
8 | const params = useParams();
9 | const locale = (params?.locale as Locale) || 'en';
10 | const t = useNextIntlTranslations();
11 |
12 | return {
13 | locale,
14 | t
15 | };
16 | }
17 |
--------------------------------------------------------------------------------
/i18n/config.ts:
--------------------------------------------------------------------------------
1 | export const locales = ['en', 'es'] as const;
2 | export type Locale = (typeof locales)[number];
3 |
4 | export const defaultLocale: Locale = 'en';
5 |
6 | export const localeNames: Record = {
7 | en: 'English',
8 | es: 'Español'
9 | };
10 |
11 | export const localeLabels: Record = {
12 | en: 'EN',
13 | es: 'ES'
14 | };
15 |
--------------------------------------------------------------------------------
/i18n/request.ts:
--------------------------------------------------------------------------------
1 | import {getRequestConfig} from "next-intl/server"
2 | import {routing} from './routing';
3 |
4 | export default getRequestConfig(async ({locale}) =>{
5 | // Ensure locale is always defined and valid
6 | const validLocale = locale && routing.locales.includes(locale as any)
7 | ? locale
8 | : routing.defaultLocale;
9 |
10 | // fetch localized translation of the displayed content
11 | const messages = (await import(`../translations/${validLocale}.json`)).default
12 |
13 | return {
14 | locale: validLocale,
15 | messages,
16 | }
17 | })
--------------------------------------------------------------------------------
/i18n/routing.ts:
--------------------------------------------------------------------------------
1 | import {defineRouting} from 'next-intl/routing';
2 | import {createNavigation} from 'next-intl/navigation';
3 |
4 | export const routing = defineRouting({
5 | locales: ['en', 'es'],
6 | defaultLocale: 'en'
7 | });
8 |
9 | // Lightweight wrappers around Next.js' navigation APIs
10 | // that will consider the routing configuration
11 | export const {Link, redirect, usePathname, useRouter, getPathname} =
12 | createNavigation(routing);
13 |
--------------------------------------------------------------------------------
/lib/backup.ts:
--------------------------------------------------------------------------------
1 | // Helpers to export/import user preferences and stats (client-side only)
2 |
3 | import usePreferencesStore from '@/store/usePreferencesStore';
4 | import useStatsStore from '@/store/useStatsStore';
5 |
6 | // JSON-safe type
7 | export type JSONValue =
8 | | string
9 | | number
10 | | boolean
11 | | null
12 | | { [k: string]: JSONValue }
13 | | JSONValue[];
14 |
15 | export type BackupFile = {
16 | version: string;
17 | createdAt: string;
18 | theme?: Record;
19 | stats?: Record;
20 | };
21 |
22 | function isPlainObject(v: unknown): v is Record {
23 | return typeof v === 'object' && v !== null && !Array.isArray(v);
24 | }
25 |
26 | function toJSONValue(v: unknown): JSONValue | undefined {
27 | const t = typeof v;
28 | if (v === null) return null;
29 | if (t === 'string' || t === 'number' || t === 'boolean')
30 | return v as JSONValue;
31 | if (Array.isArray(v)) {
32 | const arr: JSONValue[] = [];
33 | for (const item of v) {
34 | const j = toJSONValue(item);
35 | if (j !== undefined) arr.push(j);
36 | }
37 | return arr;
38 | }
39 | if (isPlainObject(v)) {
40 | const obj: { [k: string]: JSONValue } = {};
41 | for (const [k, val] of Object.entries(v)) {
42 | const j = toJSONValue(val);
43 | if (j !== undefined) obj[k] = j;
44 | }
45 | return obj;
46 | }
47 | // Skip functions, symbols, undefined, etc.
48 | return undefined;
49 | }
50 |
51 | // Keep only keys from current state that are non-functions and exist in source
52 | function filterToKnownKeys(
53 | current: T,
54 | source: Record
55 | ): Partial {
56 | const result: Record = {};
57 | for (const [k, v] of Object.entries(current as Record)) {
58 | if (typeof v === 'function') continue;
59 | if (k in source) result[k] = source[k];
60 | }
61 | return result as Partial;
62 | }
63 |
64 | function getAppVersion(): string {
65 | type G = typeof globalThis & {
66 | process?: { env?: Record };
67 | };
68 | const env = (globalThis as G).process?.env;
69 | return env?.NEXT_PUBLIC_APP_VERSION ?? 'dev';
70 | }
71 |
72 | export function createBackup(): BackupFile {
73 | const themeState = usePreferencesStore.getState();
74 | const statsState = useStatsStore.getState();
75 |
76 | return {
77 | version: getAppVersion(),
78 | createdAt: new Date().toISOString(),
79 | theme: toJSONValue(themeState) as Record,
80 | stats: toJSONValue(statsState) as Record
81 | };
82 | }
83 |
84 | export function applyBackup(data: BackupFile): boolean {
85 | try {
86 | if (data.theme) {
87 | const current = usePreferencesStore.getState();
88 | const picked = filterToKnownKeys(current, data.theme);
89 | usePreferencesStore.setState(picked);
90 | }
91 | if (data.stats) {
92 | const current = useStatsStore.getState();
93 | const picked = filterToKnownKeys(current, data.stats);
94 | useStatsStore.setState(picked);
95 | }
96 | return true;
97 | } catch (err) {
98 | console.error('[backup] apply failed', err);
99 | return false;
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/lib/flattenKanaGroup.ts:
--------------------------------------------------------------------------------
1 |
2 | import { kana } from '@/static/kana';
3 |
4 | export type KanaCharacter = {
5 | kana: string;
6 | romaji: string;
7 | group: string;
8 | };
9 |
10 | export function flattenKanaGroups(indices: number[]): KanaCharacter[] {
11 | return indices.flatMap((i) => {
12 | const group = kana[i];
13 | if (!group) return [];
14 | return group.kana.map((char, idx) => ({
15 | kana: char,
16 | romaji: group.romanji[idx],
17 | group: group.groupName,
18 | }));
19 | });
20 | }
21 |
--------------------------------------------------------------------------------
/lib/fontawesome.ts:
--------------------------------------------------------------------------------
1 | // lib/fontawesome.ts
2 | import { library, config } from '@fortawesome/fontawesome-svg-core';
3 | import { faXTwitter, faDiscord } from '@fortawesome/free-brands-svg-icons';
4 |
5 | // Prevent Font Awesome from dynamically adding CSS (needed for SSR)
6 | config.autoAddCss = false;
7 |
8 | // Manually add only the icons you use (tree-shaking ready)
9 | library.add(faXTwitter, faDiscord);
10 |
--------------------------------------------------------------------------------
/lib/generateKanaQuestions.ts:
--------------------------------------------------------------------------------
1 | export type KanaCharacter = {
2 | kana: string;
3 | romaji: string;
4 | type: string;
5 | group: string;
6 | };
7 |
8 | export function generateKanaQuestion(selectedKana: KanaCharacter[]): KanaCharacter {
9 | if (selectedKana.length === 0) {
10 | throw new Error('No kana selected');
11 | }
12 |
13 | const randomIndex = Math.floor(Math.random() * selectedKana.length);
14 | return selectedKana[randomIndex];
15 | }
16 |
--------------------------------------------------------------------------------
/lib/helperFunctions.ts:
--------------------------------------------------------------------------------
1 | type KeyStats = {
2 | correct: number;
3 | wrong: number;
4 | };
5 |
6 | type Data = {
7 | [key: string]: KeyStats;
8 | };
9 |
10 | type HighestCounts = {
11 | highestCorrectChars: string[]; // Now returns an array of keys (for ties)
12 | highestCorrectCharsValue: number;
13 |
14 | highestWrongChars: string[]; // Now returns an array of keys (for ties)
15 | highestWrongCharsValue: number;
16 | };
17 |
18 | export function findHighestCounts(data: Data): HighestCounts {
19 | let maxCorrectKeys: string[] = [];
20 | let maxCorrectValue = 2;
21 |
22 | let maxWrongKeys: string[] = [];
23 | let maxWrongValue = 2;
24 |
25 | for (const key in data) {
26 | const { correct, wrong } = data[key];
27 |
28 | // Check for highest correct (handles ties)
29 | if (correct > maxCorrectValue) {
30 | maxCorrectValue = correct;
31 | maxCorrectKeys = [key]; // Reset array with new highest key
32 | } else if (correct === maxCorrectValue) {
33 | maxCorrectKeys.push(key); // Add to array if tied
34 | }
35 |
36 | // Check for highest wrong (handles ties)
37 | if (wrong > maxWrongValue) {
38 | maxWrongValue = wrong;
39 | maxWrongKeys = [key]; // Reset array with new highest key
40 | } else if (wrong === maxWrongValue) {
41 | maxWrongKeys.push(key); // Add to array if tied
42 | }
43 | }
44 |
45 | return {
46 | highestCorrectChars: maxCorrectKeys,
47 | highestCorrectCharsValue: maxCorrectValue,
48 |
49 | highestWrongChars: maxWrongKeys,
50 | highestWrongCharsValue: maxWrongValue
51 | };
52 | }
53 |
54 | export function chunkArray(
55 | array: { name: string; start: number; end: number; id: string }[],
56 | chunkSize: number
57 | ) {
58 | const result = [];
59 | for (let i = 0; i < array.length; i += chunkSize) {
60 | result.push(array.slice(i, i + chunkSize));
61 | }
62 | return result;
63 | }
64 |
--------------------------------------------------------------------------------
/lib/interfaces.ts:
--------------------------------------------------------------------------------
1 | export interface IGroup {
2 | group: 'jlpt' | 'joyo';
3 | }
4 |
5 | export interface ISubgroup extends IGroup {
6 | subgroup:
7 | | 'n5'
8 | | 'n4'
9 | | 'n3'
10 | | 'n2'
11 | | 'n1'
12 | | 'grade-1'
13 | | 'grade-2'
14 | | 'grade-3'
15 | | 'grade-4'
16 | | 'grade-5'
17 | | 'grade-6';
18 | }
19 |
20 | export interface IWordClass extends ISubgroup {
21 | wordClass: 'nouns' | 'verbs' | 'adjectives' | 'adverbs';
22 | }
23 |
24 | export interface ISet extends IWordClass {
25 | set: string;
26 | }
27 |
28 | export interface IWord {
29 | word: string;
30 | reading: string;
31 | displayMeanings: string[];
32 | meanings: string[];
33 | }
34 |
--------------------------------------------------------------------------------
/lib/keyMappings.ts:
--------------------------------------------------------------------------------
1 | export const pickGameKeyMappings: Record = {
2 | Digit1: 0,
3 | Numpad1: 0,
4 | Digit2: 1,
5 | Numpad2: 1,
6 | Digit3: 2,
7 | Numpad3: 2
8 | };
9 |
--------------------------------------------------------------------------------
/lib/pathUtils.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Removes the locale prefix from a pathname
3 | * @param pathname - The full pathname (e.g., /en/kana or /es/kanji)
4 | * @returns The pathname without locale (e.g., /kana or /kanji)
5 | */
6 | export function removeLocaleFromPath(pathname: string): string {
7 | // Remove locale prefix (e.g., /en/kana -> /kana, /es/ -> /)
8 | return pathname.replace(/^\/[a-z]{2}(\/|$)/, '/');
9 | }
10 |
11 | /**
12 | * Gets the locale from a pathname
13 | * @param pathname - The full pathname (e.g., /en/kana)
14 | * @returns The locale (e.g., 'en') or null if not found
15 | */
16 | export function getLocaleFromPath(pathname: string): string | null {
17 | const match = pathname.match(/^\/([a-z]{2})(\/|$)/);
18 | return match ? match[1] : null;
19 | }
20 |
21 | /**
22 | * Builds a path with locale prefix
23 | * @param pathname - The pathname without locale (e.g., /kana)
24 | * @param locale - The locale to add (e.g., 'en')
25 | * @returns The pathname with locale (e.g., /en/kana)
26 | */
27 | export function addLocaleToPath(pathname: string, locale: string): string {
28 | // Remove leading slash if present, then add locale
29 | const cleanPath = pathname.startsWith('/') ? pathname.slice(1) : pathname;
30 | return `/${locale}${cleanPath ? '/' + cleanPath : ''}`;
31 | }
32 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import createMiddleware from 'next-intl/middleware';
2 | import {routing} from './i18n/routing';
3 |
4 | export default createMiddleware(routing);
5 |
6 | export const config = {
7 | // Match all pathnames except for
8 | // - … if they start with `/api`, `/_next` or `/_vercel`
9 | // - … the ones containing a dot (e.g. `favicon.ico`)
10 | matcher: ['/((?!api|_next|_vercel|.*\\..*).*)']
11 | };
12 |
--------------------------------------------------------------------------------
/next-sitemap.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next-sitemap').IConfig} */
2 | module.exports = {
3 | siteUrl: process.env.SITE_URL || 'https://kanadojo.com', // Replace with your site's URL
4 | generateRobotsTxt: true, // This line enables robots.txt generation
5 | // You can add other sitemap configuration options here
6 | // For example:
7 | changefreq: 'daily',
8 | priority: 0.8
9 | // exclude: ['/private/*'],
10 | // robotsTxtOptions: {
11 | // additionalSitemaps: [
12 | // 'https://example.com/server-sitemap.xml', // If you have a separate server-side sitemap
13 | // ],
14 | // policies: [
15 | // { userAgent: 'Googlebot', allow: '/' },
16 | // { userAgent: 'AhrefsBot', disallow: ['/'] }, // Example: Disallow a specific bot
17 | // ],
18 | // },
19 | };
20 |
--------------------------------------------------------------------------------
/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from 'next';
2 | import createNextIntlPlugin from 'next-intl/plugin'
3 |
4 | const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
5 |
6 | const nextConfig: NextConfig = {
7 | /* config options here */
8 | };
9 |
10 | export default withNextIntl(nextConfig);
11 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "kanadojo",
3 | "version": "0.1.1",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "postbuild": "next-sitemap",
9 | "start": "next start",
10 | "lint": "next lint",
11 | "format": "prettier --write .",
12 | "format:check": "prettier --check .",
13 | "lint:fix": "eslint . --ext .ts,.tsx --fix"
14 | },
15 | "dependencies": {
16 | "@fortawesome/fontawesome-svg-core": "^6.7.2",
17 | "@fortawesome/free-brands-svg-icons": "^6.7.2",
18 | "@fortawesome/free-regular-svg-icons": "^6.7.2",
19 | "@fortawesome/free-solid-svg-icons": "^6.7.2",
20 | "@fortawesome/react-fontawesome": "^0.2.2",
21 | "@radix-ui/react-select": "^2.2.5",
22 | "@radix-ui/react-slot": "^1.2.3",
23 | "@tailwindcss/postcss": "^4.0.17",
24 | "@vercel/analytics": "^1.5.0",
25 | "@vercel/speed-insights": "^1.2.0",
26 | "canvas-confetti": "^1.9.3",
27 | "class-variance-authority": "^0.7.1",
28 | "clsx": "^2.1.1",
29 | "framer-motion": "^12.23.24",
30 | "lucide-react": "^0.484.0",
31 | "motion": "^12.7.4",
32 | "next": "^15.3.0",
33 | "next-intl": "^4.3.12",
34 | "next-scroll-restoration": "^0.0.4",
35 | "next-sitemap": "^4.2.3",
36 | "random-js": "^2.1.0",
37 | "react": "^19.0.0",
38 | "react-dom": "^19.0.0",
39 | "react-markdown": "^10.1.0",
40 | "react-responsive": "^10.0.1",
41 | "react-spinners": "^0.15.0",
42 | "react-timer-hook": "^4.0.5",
43 | "remark-gfm": "^4.0.1",
44 | "tailwind-merge": "^3.2.0",
45 | "tw-animate-css": "^1.2.5",
46 | "use-sound": "^5.0.0",
47 | "zustand": "^5.0.3"
48 | },
49 | "devDependencies": {
50 | "@eslint/eslintrc": "^3",
51 | "@types/node": "^22",
52 | "@types/react": "^19",
53 | "@types/react-dom": "^19",
54 | "autoprefixer": "^10.4.21",
55 | "eslint": "^9",
56 | "eslint-config-next": "15.2.4",
57 | "postcss": "^8.5.3",
58 | "tailwindcss": "^4.1.4",
59 | "typescript": "^5"
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: {
3 | "@tailwindcss/postcss": {},
4 | },
5 | };
6 | export default config;
--------------------------------------------------------------------------------
/public/googlec56b4e4a94af22ad.html:
--------------------------------------------------------------------------------
1 | google-site-verification: googlec56b4e4a94af22ad.html
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "KanaDojo かな道場",
3 | "short_name": "KanaDojo",
4 | "description": "KanaDojo is a fun, minimalist, aesthetic platform for learning and practicing Japanese online.",
5 | "start_url": "/",
6 | "display": "standalone",
7 | "background_color": "#ffffff",
8 | "theme_color": "#000000",
9 | "icons": [
10 | {
11 | "src": "/favicon.ico",
12 | "sizes": "any",
13 | "type": "image/x-icon"
14 | }
15 | ]
16 | }
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # *
2 | User-agent: *
3 | Allow: /
4 |
5 | # Host
6 | Host: https://kanadojo.com
7 |
8 | # Sitemaps
9 | Sitemap: https://kanadojo.com/sitemap.xml
10 |
--------------------------------------------------------------------------------
/public/sitemap-0.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | https://kanadojo.com 2025-10-18T16:19:18.906Z daily 0.8
4 | https://kanadojo.com/patch-notes 2025-10-18T16:19:18.907Z daily 0.8
5 | https://kanadojo.com/privacy 2025-10-18T16:19:18.907Z daily 0.8
6 | https://kanadojo.com/sandbox 2025-10-18T16:19:18.907Z daily 0.8
7 | https://kanadojo.com/settings 2025-10-18T16:19:18.907Z daily 0.8
8 | https://kanadojo.com/security 2025-10-18T16:19:18.907Z daily 0.8
9 | https://kanadojo.com/terms 2025-10-18T16:19:18.907Z daily 0.8
10 | https://kanadojo.com/preferences 2025-10-18T16:19:18.907Z daily 0.8
11 | https://kanadojo.com/kana 2025-10-18T16:19:18.907Z daily 0.8
12 | https://kanadojo.com/kanji 2025-10-18T16:19:18.907Z daily 0.8
13 | https://kanadojo.com/vocabulary 2025-10-18T16:19:18.907Z daily 0.8
14 | https://kanadojo.com/academy 2025-10-18T16:19:18.907Z daily 0.8
15 | https://kanadojo.com/academy/hiragana-101 2025-10-18T16:19:18.907Z daily 0.8
16 |
--------------------------------------------------------------------------------
/public/sitemap.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | https://kanadojo.com/sitemap-0.xml
4 |
--------------------------------------------------------------------------------
/public/sounds/click/click4/click4_11.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lingdojo/kana-dojo/44ebf70a2981236531feed05d39a636f46f518e6/public/sounds/click/click4/click4_11.wav
--------------------------------------------------------------------------------
/public/sounds/click/click4/click4_22.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lingdojo/kana-dojo/44ebf70a2981236531feed05d39a636f46f518e6/public/sounds/click/click4/click4_22.wav
--------------------------------------------------------------------------------
/public/sounds/click/click4/click4_33.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lingdojo/kana-dojo/44ebf70a2981236531feed05d39a636f46f518e6/public/sounds/click/click4/click4_33.wav
--------------------------------------------------------------------------------
/public/sounds/click/click4/click4_44.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lingdojo/kana-dojo/44ebf70a2981236531feed05d39a636f46f518e6/public/sounds/click/click4/click4_44.wav
--------------------------------------------------------------------------------
/public/sounds/click/click4/click4_55.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lingdojo/kana-dojo/44ebf70a2981236531feed05d39a636f46f518e6/public/sounds/click/click4/click4_55.wav
--------------------------------------------------------------------------------
/public/sounds/click/click4/click4_66.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lingdojo/kana-dojo/44ebf70a2981236531feed05d39a636f46f518e6/public/sounds/click/click4/click4_66.wav
--------------------------------------------------------------------------------
/public/sounds/click/click9/click9_1.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lingdojo/kana-dojo/44ebf70a2981236531feed05d39a636f46f518e6/public/sounds/click/click9/click9_1.wav
--------------------------------------------------------------------------------
/public/sounds/click/click9/click9_2.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lingdojo/kana-dojo/44ebf70a2981236531feed05d39a636f46f518e6/public/sounds/click/click9/click9_2.wav
--------------------------------------------------------------------------------
/public/sounds/click/click9/click9_3.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lingdojo/kana-dojo/44ebf70a2981236531feed05d39a636f46f518e6/public/sounds/click/click9/click9_3.wav
--------------------------------------------------------------------------------
/public/sounds/click/click9/click9_4.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lingdojo/kana-dojo/44ebf70a2981236531feed05d39a636f46f518e6/public/sounds/click/click9/click9_4.wav
--------------------------------------------------------------------------------
/public/sounds/click/click9/click9_5.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lingdojo/kana-dojo/44ebf70a2981236531feed05d39a636f46f518e6/public/sounds/click/click9/click9_5.wav
--------------------------------------------------------------------------------
/public/sounds/correct.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lingdojo/kana-dojo/44ebf70a2981236531feed05d39a636f46f518e6/public/sounds/correct.wav
--------------------------------------------------------------------------------
/public/sounds/error/error1/error1_1.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lingdojo/kana-dojo/44ebf70a2981236531feed05d39a636f46f518e6/public/sounds/error/error1/error1_1.wav
--------------------------------------------------------------------------------
/public/sounds/error/error2/error2_1.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lingdojo/kana-dojo/44ebf70a2981236531feed05d39a636f46f518e6/public/sounds/error/error2/error2_1.wav
--------------------------------------------------------------------------------
/public/sounds/long.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lingdojo/kana-dojo/44ebf70a2981236531feed05d39a636f46f518e6/public/sounds/long.wav
--------------------------------------------------------------------------------
/public/wallpapers/neonretrocarcity.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lingdojo/kana-dojo/44ebf70a2981236531feed05d39a636f46f518e6/public/wallpapers/neonretrocarcity.jpg
--------------------------------------------------------------------------------
/static/about.ts:
--------------------------------------------------------------------------------
1 |
2 | const about = `Welcome to KanaDojo – Your Japanese Training Ground!
3 | If you've ever studied Japanese, you know that learning the language isn't just about memorizing words and grammar rules—it's about practice, repetition, and real-world application. That's exactly what KanaDojo is all about!
4 |
5 | Just like a real dojo, KanaDojo is a place for training, discipline, and mastery—but instead of martial arts, we're here to help you sharpen your Japanese language skills. Whether you're a complete beginner or an intermediate learner looking to level up, you're in the right place.
6 |
7 | Why "Dojo"? Because Practice Makes Perfect!
8 | A dojo (道場) is not just a classroom—it's a training hall where students practice their skills until they become second nature. Learning Japanese works the same way. It's not enough to simply read textbooks or watch anime with subtitles—you need structured, consistent training to become truly fluent.
9 |
10 | At KanaDojo, we focus on active learning. This means:
11 |
12 | ✅ Hands-on exercises that make you use what you’ve learned
13 |
14 | ✅ Interactive drills to strengthen your reading, writing, and listening skills
15 |
16 | ✅ Engaging lessons designed to make Japanese feel natural and intuitive
17 |
18 | ✅ A step-by-step approach so you can build your skills with confidence
19 |
20 | What You’ll Find at KanaDojo
21 | 🔹 Hiragana & Katakana Mastery – Learn the Japanese writing system the right way
22 |
23 | 🔹 Kanji Training – Build a solid foundation with structured kanji exercises
24 |
25 | 🔹 Grammar Workouts – Practical grammar lessons without unnecessary jargon
26 |
27 | 🔹 Listening & Speaking Practice – Tips and drills to improve your real-world communication skills
28 |
29 | 🔹 Japanese Culture & Insights – Because language and culture go hand in hand!
30 |
31 | We believe that learning Japanese should be an exciting journey, not a frustrating chore. That’s why we emphasize practice, engagement, and real progress—so you can go from struggling to confident in no time.
32 |
33 | Are You Ready to Train?
34 | No matter where you are in your Japanese learning journey, KanaDojo is here to help you become stronger, more skilled, and more confident in your abilities. Whether you're preparing for the JLPT, planning a trip to Japan, or just passionate about the language, we're here to guide you every step of the way.
35 |
36 | So, tighten your obi (belt), step onto the tatami (training mat), and let’s train together. The path to Japanese fluency starts NOW!
37 |
38 | 🚀 Start your training today with KanaDojo 🚀`;
39 |
40 | export default about;
--------------------------------------------------------------------------------
/static/icons.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Bird,
3 | Bug,
4 | Cat,
5 | Dog,
6 | Fish,
7 | Origami,
8 | PawPrint,
9 | Rabbit,
10 | Rat,
11 | Snail,
12 | Squirrel,
13 | Turtle,
14 | Worm
15 | } from 'lucide-react';
16 |
17 | export const animalIcons = [
18 | ,
19 | ,
20 | ,
21 | ,
22 | ,
23 | ,
24 | ,
25 | ,
26 | ,
27 | ,
28 | ,
29 | ,
30 |
31 | ];
32 |
33 | export const animalIconsLength = animalIcons.length;
--------------------------------------------------------------------------------
/static/legal/privacyPolicy.ts:
--------------------------------------------------------------------------------
1 | const privacyPolicy = `
2 | # Privacy Policy
3 |
4 | **Effective Date:** 04/03/2025 (March 4th, 2025)
5 |
6 | Welcome to KanaDojo ("Website", "we", "us", "our").
7 |
8 | The following document outlines the privacy policy for our Website. By using our Website, you automatically agree to the terms of this policy.
9 |
10 |
11 | ## 1. Information We Collect
12 | Our Website does not offer user accounts or require any personal information directly from users. Therefore, we do not collect or store any personal data/information on our servers. However, we use **Google Analytics**, which automatically collects certain information about visitors.
13 |
14 | Google Analytics may collect data such as:
15 |
16 | - Your device type, browser, and operating system
17 | - IP address (anonymized where applicable)
18 | - Pages visited on our Website
19 | - The duration of your visit
20 | - Your general geographic location (country/city level)
21 | - Referral sources (e.g., search engines, other websites)
22 |
23 | This information helps us better understand our audience, improve our Website, and enhance user experience.
24 |
25 |
26 | ## 2. How We Use Your Information
27 | The data collected via Google Analytics is used for:
28 |
29 | - Analyzing trends and user behavior
30 | - Improving the structure, content, and features of the Website
31 | - Measuring traffic and engagement
32 |
33 | We do not sell, trade, or transfer your data to any outside parties.
34 |
35 |
36 | ## 3. Google Analytics and Third-Party Services
37 | Google Analytics processes collected data under Google's terms and policies. You can learn more about Google's privacy practices here: [Google Privacy Policy](https://policies.google.com/privacy).
38 |
39 | If you wish to opt out of Google Analytics tracking, you can do so by:
40 |
41 | - Using the **Google Analytics Opt-out Browser Add-on**: [Opt Out](https://tools.google.com/dlpage/gaoptout)
42 | - Adjusting your browser settings to block cookies
43 |
44 |
45 | ## 4. Cookies
46 | Google Analytics and similar services may use **cookies** (small files stored on your device) to track user interactions.
47 |
48 | You can manage or disable cookies by adjusting your browser settings. However, disabling cookies may affect certain Website functionalities.
49 |
50 |
51 | ## 5. Data Security
52 | We prioritize data protection and take reasonable measures to safeguard collected information. However, since our Website does not collect any personal data directly, we do not store or process any user data independently.
53 |
54 |
55 | ## 6. Links to Third-Party Websites
56 | Our Website may contain links to external sites. We are not responsible for the privacy practices of these third-party websites. Please review their privacy policies before providing any information.
57 |
58 |
59 | ## 7. Children's Privacy
60 | Our Website is not directed at children under 13, and we do not knowingly collect data from minors. If you believe a child has provided information through Google Analytics, please contact us, and we will take appropriate action.
61 |
62 |
63 | ## 8. Changes to This Privacy Policy
64 | We may update this Privacy Policy periodically. Changes will be posted on this page with the updated **Effective Date**. Please review this policy from time to time.
65 |
66 |
67 | ## 9. Contact Information
68 | If you have any questions about this Privacy Policy, you can contact us at:
69 |
70 | langdojo.dev@outlook.com
71 |
72 |
73 | **Thank you for using KanaDojo!** 🚀
74 |
75 | `;
76 |
77 | export default privacyPolicy;
78 |
--------------------------------------------------------------------------------
/static/legal/securityPolicy.ts:
--------------------------------------------------------------------------------
1 | const securityPolicy = `
2 | # Security Policy
3 |
4 | **Effective Date:** 04/03/2025 (March 4th, 2025)
5 |
6 | At **KanaDojo**, we take security seriously and strive to protect our website and users from potential threats. Although our website does not collect personal user data directly, we implement security measures to safeguard our platform.
7 |
8 |
9 | ## 1. Scope
10 |
11 | This Security Policy applies to the **KanaDojo** website (kanadojo.com), including its infrastructure, content, and third-party services used (such as Google Analytics).
12 |
13 |
14 | ## 2. Website Security Practices
15 |
16 | To ensure the security of our website, we:
17 |
18 | - Use **HTTPS (SSL/TLS encryption)** to protect data transmitted between the website and users.
19 | - Regularly update our **software, dependencies, and third-party services** to mitigate security vulnerabilities.
20 | - Monitor for unauthorized access, potential threats, and security issues.
21 | - Restrict administrative access to trusted personnel only.
22 |
23 |
24 | ## 3. Data Security & Privacy
25 |
26 | We do not collect personal data directly. However, we use **Google Analytics**, which gathers anonymous usage information (e.g., page visits, browser type, and geographic location).
27 |
28 | To enhance data security:
29 |
30 | - We **do not store sensitive or personally identifiable information**.
31 | - Google Analytics data is handled under [Google’s Privacy Policy](https://policies.google.com/privacy).
32 | - We allow users to **opt out of Google Analytics tracking** using browser settings or the [Google Analytics Opt-Out Add-on](https://tools.google.com/dlpage/gaoptout).
33 |
34 |
35 | ## 4. Handling Security Vulnerabilities
36 |
37 | If you discover a security vulnerability on our website, we encourage responsible disclosure:
38 |
39 | ### 📩 How to Report a Security Issue
40 |
41 | If you believe you have found a security issue, please contact us immediately via:
42 |
43 | - 📧 **Email:** langdojo.dev@outlook.com
44 | - 📩 **Bug Report Form:** kanadojo.com/report
45 |
46 | We appreciate **ethical hacking and responsible disclosure** and will investigate reported vulnerabilities promptly.
47 |
48 |
49 | ## 5. Third-Party Services & Dependencies
50 |
51 | We utilize third-party services (e.g., Google Analytics) that have their **own security policies**. While we take precautions to only use reputable services, we cannot guarantee the security of third-party platforms.
52 |
53 | Users visiting external links (e.g., third-party resources) should review those websites’ security policies.
54 |
55 |
56 | ## 6. Future Improvements
57 |
58 | We are committed to improving our security measures by:
59 |
60 | - Periodically reviewing security best practices.
61 | - Updating dependencies and performing security audits.
62 | - Monitoring and mitigating emerging threats to ensure a safe browsing experience.
63 |
64 |
65 | ## 7. Changes to This Security Policy
66 |
67 | We may update this Security Policy from time to time. Any changes will be **published on this page with an updated effective date**. Please review this policy periodically to stay informed about our security practices.
68 |
69 |
70 | ## 8. Contact Information
71 |
72 | For any questions or concerns about this Security Policy, you can reach us at:
73 |
74 | 📧 **Email:** *langdojo.dev@outlook.com*
75 |
76 | 🌍 **Website:** kanadojo.com/contact
77 |
78 | ---
79 |
80 | **Thank you for helping KanaDojo maintain a safe and secure website! 🚀**
81 | `;
82 |
83 | export default securityPolicy;
84 |
--------------------------------------------------------------------------------
/static/legal/termsOfService.ts:
--------------------------------------------------------------------------------
1 | const termsOfService = `
2 | # Terms of Service
3 |
4 | **Effective Date:** 04/03/2025 (March 4th, 2025)
5 |
6 | Welcome to **KanaDojo** (the "Website"). By accessing or using our Website, you automatically agree to comply with and be bound by these Terms of Service ("Terms"). If you do not agree to these Terms, please do not use our Website.
7 |
8 |
9 | ## 1. General Use
10 | - **Eligibility:** You must be at least 13 years old to use this Website.
11 | - **Purpose:** Our Website is designed to provide educational materials for learning Japanese. Use of the Website must comply with all applicable laws and these Terms.
12 | **Prohibited Activities.** You agree not to:
13 | - Use the Website for illegal or unauthorized activities.
14 | - Disrupt, damage, or interfere with the Website’s functionality.
15 |
16 | ## 2. Content Disclaimer
17 | - All content on this Website is provided for **educational purposes only**.
18 | - We do not guarantee the accuracy, completeness, or reliability of the materials.
19 | - The Website’s content is subject to change without notice.
20 |
21 |
22 | ## 3. Intellectual Property
23 | - **Ownership:** All text, images, graphics, and materials on the Website belong to **KanaDojo** unless otherwise stated.
24 | - **Restrictions:** You may not copy, distribute, modify, or reproduce our content without permission.
25 |
26 | ## 4. Third-Party Links
27 | - Our Website may contain links to third-party websites.
28 | - We are not responsible for the content or security of external websites.
29 | - Visiting such websites is at your own risk.
30 |
31 |
32 | ## 5. Disclaimer of Warranties
33 | - Our Website is provided **"as is"** and **"as available"** without warranties of any kind.
34 | - We do not guarantee that the Website will be error-free or uninterrupted.
35 | - We are not responsible for **any losses** resulting from your use of the Website.
36 |
37 |
38 | ## 6. Limitation of Liability
39 | - **KanaDojo** is not liable for damages, **direct or indirect**, arising from your use of the Website.
40 | - We are not responsible for **data loss, system failures, or security breaches** related to using third-party services like Google Analytics.
41 |
42 |
43 | ## 7. Changes to These Terms
44 | We may update these Terms from time to time. Changes will be posted on this page with an updated **Effective Date**. Continued use of the Website after changes means that you automatically accept the revised Terms.
45 |
46 |
47 | ## 8. Contact Information
48 | If you have any questions or concerns about these Terms, contact us at:
49 |
50 | 📧 **Email:** langdojo.dev@outlook.com
51 | 🌍 **Website:** kanadojo.com/contact
52 |
53 |
54 | **Thank you for using KanaDojo!** 🚀
55 | `;
56 |
57 | export default termsOfService;
58 |
--------------------------------------------------------------------------------
/static/patchNotes.ts:
--------------------------------------------------------------------------------
1 | const patchNotes = `
2 | # KanaDojo v0.0.42, pre-alpha release (April 15, 2025 patch)
3 | ## Improvements,
4 | - Added square, 4x4 background to the Kanji characters in the Kanji set dictionaries to help get a better feel of the stroke order and how the character is written,
5 | # Bug Fixes,
6 | - Fixed scroll restoration in the Kanji and Vocabulary character selection menus,
7 |
8 | `;
9 |
10 | export default patchNotes;
11 |
--------------------------------------------------------------------------------
/static/styles.ts:
--------------------------------------------------------------------------------
1 | import clsx from 'clsx';
2 |
3 | export const cardBorderStyles = clsx(
4 | 'rounded-xl bg-[var(--card-color)]'
5 | );
6 |
7 | export const buttonBorderStyles = clsx(
8 | 'rounded-xl bg-[var(--card-color)] hover:cursor-pointer',
9 | 'duration-250',
10 | 'transition-all ease-in-out',
11 | 'hover:bg-[var(--border-color)]'
12 | // 'active:scale-85 md:active:scale-90 active:duration-300',
13 | // 'border-b-4 border-[var(--border-color)] '
14 | );
15 |
16 | export const miniButtonBorderStyles = clsx(
17 | 'rounded-xl bg-[var(--background-color)] hover:cursor-pointer',
18 | 'duration-250',
19 | 'transition-all ease-in-out',
20 | 'hover:bg-[var(--border-color)]'
21 | // 'active:scale-95 md:active:scale-98 active:duration-300',
22 | // 'border-b-4 border-[var(--border-color)] '
23 | );
24 |
--------------------------------------------------------------------------------
/static/unitSets.ts:
--------------------------------------------------------------------------------
1 | import N5Kanji from './kanji/N5';
2 | import N4Kanji from './kanji/N4';
3 | import N3Kanji from './kanji/N3';
4 | import N2Kanji from './kanji/N2';
5 | import N1Kanji from './kanji/N1';
6 |
7 | import N5Vocab from './vocab/n5/nouns';
8 | import N4Vocab from './vocab/n4/nouns';
9 | import N3Vocab from './vocab/n3/nouns';
10 | import N2Vocab from './vocab/n2/nouns';
11 |
12 | export const N5KanjiLength = N5Kanji.length;
13 | export const N4KanjiLength = N4Kanji.length;
14 | export const N3KanjiLength = N3Kanji.length;
15 | export const N2KanjiLength = N2Kanji.length;
16 | export const N1KanjiLength = N1Kanji.length;
17 |
18 | export const N5VocabLength = N5Vocab.length;
19 | export const N4VocabLength = N4Vocab.length;
20 | export const N3VocabLength = N3Vocab.length;
21 | export const N2VocabLength = N2Vocab.length;
22 |
--------------------------------------------------------------------------------
/store/useKanaStore.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 |
3 | interface IKanaState {
4 | selectedGameModeKana: string;
5 | kanaGroupIndices: number[];
6 | setSelectedGameModeKana: (mode: string) => void;
7 | addKanaGroupIndex: (kanaGroupIndex: number) => void;
8 | addKanaGroupIndices: (kanaGroupIndices: number[]) => void;
9 | }
10 |
11 | const sameArray = (a: number[], b: number[]) =>
12 | a.length === b.length && a.every((v, i) => v === b[i]);
13 |
14 | const toggleNumber = (arr: number[], v: number): number[] => {
15 | const present = arr.includes(v);
16 | if (present) {
17 | const next = arr.filter(i => i !== v);
18 | return next.length === arr.length ? arr : next;
19 | } else {
20 | return [...arr, v];
21 | }
22 | };
23 |
24 | const toggleNumbers = (arr: number[], input: number[]): number[] => {
25 | if (!input.length) return arr;
26 |
27 | const dedupInput: number[] = [];
28 | const seenIn = new Set();
29 | for (const v of input) {
30 | if (!seenIn.has(v)) {
31 | seenIn.add(v);
32 | dedupInput.push(v);
33 | }
34 | }
35 |
36 | const current = new Set(arr);
37 | const incoming = new Set(dedupInput);
38 |
39 | const allPresent = dedupInput.every(v => current.has(v));
40 | if (allPresent) {
41 | let changed = false;
42 | const next = arr.filter(v => {
43 | const drop = incoming.has(v);
44 | if (drop) changed = true;
45 | return !drop;
46 | });
47 | return changed ? next : arr;
48 | }
49 |
50 | let changed = false;
51 | const next = arr.slice();
52 | for (const v of dedupInput) {
53 | if (!current.has(v)) {
54 | next.push(v);
55 | current.add(v);
56 | changed = true;
57 | }
58 | }
59 | return changed ? next : arr;
60 | };
61 |
62 | const useKanaStore = create(set => ({
63 | selectedGameModeKana: 'Pick',
64 | kanaGroupIndices: [],
65 | setSelectedGameModeKana: gameMode => set({ selectedGameModeKana: gameMode }),
66 |
67 | addKanaGroupIndex: kanaGroupIndex =>
68 | set(state => {
69 | const next = toggleNumber(state.kanaGroupIndices, kanaGroupIndex);
70 | return sameArray(next, state.kanaGroupIndices)
71 | ? state
72 | : { kanaGroupIndices: next };
73 | }),
74 |
75 | addKanaGroupIndices: kanaGroupIndices =>
76 | set(state => {
77 | const next = toggleNumbers(state.kanaGroupIndices, kanaGroupIndices);
78 | return sameArray(next, state.kanaGroupIndices)
79 | ? state
80 | : { kanaGroupIndices: next };
81 | }),
82 | }));
83 |
84 | export default useKanaStore;
85 |
--------------------------------------------------------------------------------
/store/useKanjiStore.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 |
3 | export interface IKanjiObj {
4 | id: number;
5 | kanjiChar: string;
6 | onyomi: string[];
7 | kunyomi: string[];
8 | displayMeanings: string[];
9 | fullDisplayMeanings: string[];
10 | meanings: string[];
11 | }
12 |
13 | interface IKanjiState {
14 | selectedGameModeKanji: string;
15 | selectedKanjiObjs: IKanjiObj[];
16 | selectedKanjiCollection: 'n5' | 'n4' | 'n3' | 'n2' | 'n1';
17 | selectedKanjiSets: string[];
18 | setSelectedGameModeKanji: (mode: string) => void;
19 | addKanjiObj: (kanji: IKanjiObj) => void;
20 | addKanjiObjs: (kanjis: IKanjiObj[]) => void;
21 | clearKanjiObjs: () => void;
22 | setSelectedKanjiCollection: (collection: 'n5' | 'n4' | 'n3' | 'n2' | 'n1') => void;
23 | setSelectedKanjiSets: (sets: string[]) => void;
24 | clearKanjiSets: () => void;
25 | }
26 |
27 | const sameKanjiArray = (a: IKanjiObj[], b: IKanjiObj[]) =>
28 | a.length === b.length && a.every((v, i) => v.kanjiChar === b[i].kanjiChar);
29 |
30 | const toggleKanji = (array: IKanjiObj[], kanjiObj: IKanjiObj): IKanjiObj[] => {
31 | if (!kanjiObj || !kanjiObj.kanjiChar) return array;
32 | const kanjiIndex = array.findIndex(
33 | item => item.kanjiChar === kanjiObj.kanjiChar
34 | );
35 | if (kanjiIndex >= 0) {
36 | if (array.length === 1) return [];
37 | return array.slice(0, kanjiIndex).concat(array.slice(kanjiIndex + 1));
38 | }
39 | return [...array, kanjiObj];
40 | };
41 |
42 | const toggleKanjis = (
43 | array: IKanjiObj[],
44 | kanjiObjects: IKanjiObj[]
45 | ): IKanjiObj[] => {
46 | if (!kanjiObjects.length) return array;
47 |
48 | const dedupIncoming: IKanjiObj[] = [];
49 | const seen = new Set();
50 | for (const obj of kanjiObjects) {
51 | const c = obj?.kanjiChar;
52 | if (!c) continue;
53 | if (!seen.has(c)) {
54 | seen.add(c);
55 | dedupIncoming.push(obj);
56 | }
57 | }
58 | if (!dedupIncoming.length) return array;
59 |
60 | const currentChars = new Set(array.map(item => item.kanjiChar));
61 | const incomingChars = new Set(dedupIncoming.map(item => item.kanjiChar));
62 |
63 | const allPresent = dedupIncoming.every(obj =>
64 | currentChars.has(obj.kanjiChar)
65 | );
66 | if (allPresent) {
67 | let changed = false;
68 | const next = array.filter(item => {
69 | const drop = incomingChars.has(item.kanjiChar);
70 | if (drop) changed = true;
71 | return !drop;
72 | });
73 | return changed ? next : array;
74 | }
75 |
76 | let changed = false;
77 | const next = array.slice();
78 | for (const obj of dedupIncoming) {
79 | if (!currentChars.has(obj.kanjiChar)) {
80 | next.push(obj);
81 | currentChars.add(obj.kanjiChar);
82 | changed = true;
83 | }
84 | }
85 | return changed ? next : array;
86 | };
87 |
88 | const useKanjiStore = create(set => ({
89 | selectedGameModeKanji: 'Pick',
90 | selectedKanjiObjs: [],
91 | selectedKanjiCollection: 'n5',
92 | selectedKanjiSets: [],
93 |
94 | setSelectedGameModeKanji: gameMode =>
95 | set({ selectedGameModeKanji: gameMode }),
96 |
97 | addKanjiObj: kanjiObj =>
98 | set(state => {
99 | const next = toggleKanji(state.selectedKanjiObjs, kanjiObj);
100 | return sameKanjiArray(next, state.selectedKanjiObjs)
101 | ? state
102 | : { selectedKanjiObjs: next };
103 | }),
104 |
105 | addKanjiObjs: kanjiObjects =>
106 | set(state => {
107 | const next = toggleKanjis(state.selectedKanjiObjs, kanjiObjects);
108 | return sameKanjiArray(next, state.selectedKanjiObjs)
109 | ? state
110 | : { selectedKanjiObjs: next };
111 | }),
112 |
113 | clearKanjiObjs: () => set({ selectedKanjiObjs: [] }),
114 |
115 | setSelectedKanjiCollection: collection =>
116 | set({ selectedKanjiCollection: collection }),
117 |
118 | setSelectedKanjiSets: sets => set({ selectedKanjiSets: sets }),
119 |
120 | clearKanjiSets: () => set({ selectedKanjiSets: [] })
121 | }));
122 |
123 | export default useKanjiStore;
124 |
--------------------------------------------------------------------------------
/store/useOnboardingStore.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 | import { persist } from 'zustand/middleware';
3 |
4 | interface OnboardingState {
5 | hasSeenWelcome: boolean;
6 | setHasSeenWelcome: (hasSeenWelcome: boolean) => void;
7 | }
8 |
9 | const useOnboardingStore = create()(
10 | persist(
11 | (set) => ({
12 | hasSeenWelcome: false,
13 | setHasSeenWelcome: (hasSeenWelcome: boolean) => set({ hasSeenWelcome }),
14 | }),
15 | {
16 | name: 'welcome-storage',
17 | version: 0,
18 | }
19 | )
20 | );
21 |
22 | export default useOnboardingStore;
--------------------------------------------------------------------------------
/store/usePreferencesStore.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 | import { persist } from 'zustand/middleware';
3 |
4 | interface PreferencesState {
5 | displayKana: boolean;
6 | setDisplayKana: (displayKana: boolean) => void;
7 |
8 | theme: string;
9 | setTheme: (theme: string) => void;
10 |
11 | font: string;
12 | setFont: (fontName: string) => void;
13 |
14 | silentMode: boolean;
15 | setSilentMode: (silent: boolean) => void;
16 |
17 | hotkeysOn: boolean;
18 | setHotkeys: (hotkeys: boolean) => void;
19 |
20 | // Pronunciation settings
21 | pronunciationEnabled: boolean;
22 | setPronunciationEnabled: (enabled: boolean) => void;
23 |
24 | pronunciationSpeed: number;
25 | setPronunciationSpeed: (speed: number) => void;
26 |
27 | pronunciationPitch: number;
28 | setPronunciationPitch: (pitch: number) => void;
29 |
30 | // Voice selection
31 | pronunciationVoiceName: string | null;
32 | setPronunciationVoiceName: (name: string | null) => void;
33 |
34 | furiganaEnabled: boolean;
35 | setFuriganaEnabled: (enabled: boolean) => void;
36 | }
37 |
38 | const usePreferencesStore = create()(
39 | persist(
40 | set => ({
41 | displayKana: false,
42 | setDisplayKana: displayKana => set({ displayKana }),
43 | theme: 'light',
44 | setTheme: theme => set({ theme }),
45 | font: 'Zen Maru Gothic',
46 | setFont: fontName => set({ font: fontName }),
47 | silentMode: false,
48 | setSilentMode: silent => set({ silentMode: silent }),
49 | hotkeysOn: true,
50 | setHotkeys: hotkeys => set({ hotkeysOn: hotkeys }),
51 |
52 | // Pronunciation settings
53 | pronunciationEnabled: true,
54 | setPronunciationEnabled: enabled =>
55 | set({ pronunciationEnabled: enabled }),
56 | pronunciationSpeed: 0.8,
57 | setPronunciationSpeed: speed => set({ pronunciationSpeed: speed }),
58 | pronunciationPitch: 1.0,
59 | setPronunciationPitch: pitch => set({ pronunciationPitch: pitch }),
60 | pronunciationVoiceName: null,
61 | setPronunciationVoiceName: name => set({ pronunciationVoiceName: name }),
62 | furiganaEnabled: true,
63 | setFuriganaEnabled: enabled => set({ furiganaEnabled: enabled })
64 | }),
65 |
66 | {
67 | name: 'theme-storage'
68 | }
69 | )
70 | );
71 |
72 | export default usePreferencesStore;
73 |
--------------------------------------------------------------------------------
/store/useVocabStore.ts:
--------------------------------------------------------------------------------
1 | import { create } from 'zustand';
2 |
3 | export interface IWordObj {
4 | word: string;
5 | reading: string;
6 | displayMeanings: string[];
7 | meanings: string[];
8 | }
9 |
10 | interface IFormState {
11 | selectedGameModeVocab: string;
12 | selectedWordObjs: IWordObj[];
13 | setSelectedGameModeVocab: (mode: string) => void;
14 | addWordObj: (kanji: IWordObj) => void;
15 | addWordObjs: (kanjis: IWordObj[]) => void;
16 | clearWordObjs: () => void;
17 |
18 | selectedVocabCollection: string;
19 | setSelectedVocabCollection: (collection: string) => void;
20 |
21 | selectedVocabSets: string[];
22 | setSelectedVocabSets: (sets: string[]) => void;
23 | clearVocabSets: () => void;
24 | }
25 |
26 | const useVocabStore = create(set => ({
27 | selectedGameModeVocab: 'Pick',
28 | selectedWordObjs: [],
29 | setSelectedGameModeVocab: gameMode =>
30 | set({ selectedGameModeVocab: gameMode }),
31 | addWordObj: wordObj =>
32 | set(state => ({
33 | selectedWordObjs: state.selectedWordObjs
34 | .map(wordObj => wordObj.word)
35 | .includes(wordObj.word)
36 | ? state.selectedWordObjs.filter(
37 | currentWordObj => currentWordObj.word !== wordObj.word
38 | )
39 | : [...state.selectedWordObjs, wordObj]
40 | })),
41 | addWordObjs: wordObjs =>
42 | set(state => ({
43 | selectedWordObjs: wordObjs.every(currentWordObj =>
44 | state.selectedWordObjs
45 | .map(currentWordObj => currentWordObj.word)
46 | .includes(currentWordObj.word)
47 | )
48 | ? state.selectedWordObjs.filter(
49 | currentWordObj =>
50 | !wordObjs
51 | .map(currentWordObj => currentWordObj.word)
52 | .includes(currentWordObj.word)
53 | )
54 | : [...new Set([...state.selectedWordObjs, ...wordObjs])]
55 | })),
56 | clearWordObjs: () => {
57 | set(() => ({
58 | selectedWordObjs: []
59 | }));
60 | },
61 |
62 | selectedVocabCollection: 'n5',
63 | setSelectedVocabCollection: collection =>
64 | set({ selectedVocabCollection: collection }),
65 | selectedVocabSets: [],
66 | setSelectedVocabSets: sets => set({ selectedVocabSets: sets }),
67 | clearVocabSets: () => {
68 | set(() => ({
69 | selectedVocabSets: []
70 | }));
71 | }
72 | }));
73 |
74 | export default useVocabStore;
75 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | content: [
3 | './app/**/*.{ts,tsx}',
4 | './components/**/*.{ts,tsx}',
5 | './static/styles.ts',
6 | './static/info.tsx',
7 | ],
8 | theme: {
9 | extend: {
10 | keyframes: {
11 | aurora: {
12 | '0%, 100%': { backgroundPosition: '0% 50%' },
13 | '50%': { backgroundPosition: '100% 50%' }
14 | }
15 | },
16 | animation: {
17 | aurora: 'aurora 6s ease-in-out infinite'
18 | }
19 | }
20 | },
21 | plugins: []
22 | };
23 |
--------------------------------------------------------------------------------
/todo.txt:
--------------------------------------------------------------------------------
1 | + Plan arrangement for translations to be stored in a single JSON document for each language or variant.
2 |
3 | /translation/en.json
4 |
5 | + Move all hardcoded English (original) text content to the `English text content` file.
6 |
7 | /translations/es.json
8 |
9 | + Begin the translation of all english text content.
10 |
11 | /i18n/{request.ts,routing.ts,navigation.ts}, /middleware.ts
12 |
13 | + Set-up locale-based routing with next-intl
14 |
15 | For Every component and view replace harcoded text with the useTranslation('query') function accordingly in order to render the `actual and existing translated text`
16 |
17 | + One can choose a particular view or component to replace harcoded text with its according translation function.
18 |
--------------------------------------------------------------------------------
/translations/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "MenuInfo": {
3 | "greeting" : "Welcome to KanaDojo!",
4 | "description": "KanaDojo is a fun, minimalist, aesthetic platform for learning and practicing Japanese online.",
5 | "instructions": "To begin, select a dojo and start training now!"
6 | }
7 | }
--------------------------------------------------------------------------------
/translations/es.json:
--------------------------------------------------------------------------------
1 | {
2 | "MenuInfo": {
3 | "greeting" : "¡Bienvenido a KanaDojo!",
4 | "description": "KanaDojo es una plataforma divertida, estética y minimalista para aprender y practicar el idioma Japonés en linea.",
5 | "instructions": "Para empezar, coge un dojo. ¡Y comienza a entrenar ahora!"
6 | }
7 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "tailwind.config.js"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------