diff --git a/.stylelintrc.json b/.stylelintrc.json index c2d8e41..b92f43c 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -5,18 +5,22 @@ "stylelint-config-html", "stylelint-prettier/recommended" ], - "rules": { "import-notation": "string", "selector-class-pattern": null, "no-descending-specificity": null }, - "overrides": [ { - "files": ["*.astro"], + "files": ["*.astro", "**/*.astro"], "rules": { - "prettier/prettier": null + "prettier/prettier": null, + "selector-pseudo-class-no-unknown": [ + true, + { + "ignorePseudoClasses": ["global"] + } + ] } } ] diff --git a/astro.config.mjs b/astro.config.mjs index 82f6cc6..e82d97b 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -4,6 +4,13 @@ import react from '@astrojs/react'; import AstroPWA from '@vite-pwa/astro'; export default defineConfig({ + i18n: { + defaultLocale: 'en', + locales: ['en', 'zh-CN', 'zh-TW', 'ja'], + routing: { + prefixDefaultLocale: false, + }, + }, integrations: [ react(), AstroPWA({ diff --git a/package-lock.json b/package-lock.json index 30ad9d0..868201b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,10 +24,12 @@ "focus-trap-react": "10.2.3", "framer-motion": "10.16.4", "howler": "2.2.4", + "i18next": "25.0.0", "js-confetti": "0.12.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-hotkeys-hook": "3.2.1", + "react-i18next": "15.4.1", "react-icons": "4.11.0", "react-wrap-balancer": "1.1.0", "uuid": "10.0.0", @@ -2042,9 +2044,10 @@ "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==" }, "node_modules/@babel/runtime": { - "version": "7.23.1", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.1.tgz", - "integrity": "sha512-hC2v6p8ZSI/W0HUzh3V8C5g+NwSKzKPtJwSpTjwl0o297GP9+ZLQSkdvHz46CM3LqyoXxq+5G9komY+eSqSO0g==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", + "license": "MIT", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -17486,6 +17489,15 @@ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==" }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/html-tags": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", @@ -17570,6 +17582,37 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/i18next": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.0.0.tgz", + "integrity": "sha512-POPvwjOPR1GQvRnbikTMPEhQD+ekd186MHE6NtVxl3Lby+gPp0iq60eCqGrY6wfRnp1lejjFNu0EKs1afA322w==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.10" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -22247,6 +22290,28 @@ "react-dom": ">=16.8.1" } }, + "node_modules/react-i18next": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.4.1.tgz", + "integrity": "sha512-ahGab+IaSgZmNPYXdV1n+OYky95TGpFwnKRflX/16dY04DsYYKHtVLjeny7sBSCREEcoMbAgSkFiGLF5g5Oofw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/react-icons": { "version": "4.11.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.11.0.tgz", @@ -26633,6 +26698,15 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/watchpack": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz", diff --git a/package.json b/package.json index a06e5b1..4ce1440 100644 --- a/package.json +++ b/package.json @@ -40,10 +40,12 @@ "focus-trap-react": "10.2.3", "framer-motion": "10.16.4", "howler": "2.2.4", + "i18next": "25.0.0", "js-confetti": "0.12.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-hotkeys-hook": "3.2.1", + "react-i18next": "15.4.1", "react-icons": "4.11.0", "react-wrap-balancer": "1.1.0", "uuid": "10.0.0", diff --git a/public/fonts/lxgw-wenkai-lite/LXGWWenKaiLite-Light.woff2 b/public/fonts/lxgw-wenkai-lite/LXGWWenKaiLite-Light.woff2 new file mode 100644 index 0000000..a0141c0 Binary files /dev/null and b/public/fonts/lxgw-wenkai-lite/LXGWWenKaiLite-Light.woff2 differ diff --git a/public/fonts/lxgw-wenkai-lite/LXGWWenKaiLite-Medium.woff2 b/public/fonts/lxgw-wenkai-lite/LXGWWenKaiLite-Medium.woff2 new file mode 100644 index 0000000..50ef448 Binary files /dev/null and b/public/fonts/lxgw-wenkai-lite/LXGWWenKaiLite-Medium.woff2 differ diff --git a/public/fonts/lxgw-wenkai-lite/LXGWWenKaiLite-Regular.woff2 b/public/fonts/lxgw-wenkai-lite/LXGWWenKaiLite-Regular.woff2 new file mode 100644 index 0000000..c6056e2 Binary files /dev/null and b/public/fonts/lxgw-wenkai-lite/LXGWWenKaiLite-Regular.woff2 differ diff --git a/src/components/about.astro b/src/components/about.astro index 72fd439..2c4e2bc 100644 --- a/src/components/about.astro +++ b/src/components/about.astro @@ -2,26 +2,18 @@ import { Container } from '@/components/container'; import { count as soundCount } from '@/lib/sounds'; +import { getTranslator } from '@/i18n/utils'; + +const currentLocale = Astro.currentLocale; +const t = await getTranslator(currentLocale); const count = soundCount(); -const paragraphs = [ - { - body: 'Craving a calming escape from the daily grind? Do you need the perfect soundscape to boost your focus or lull you into peaceful sleep? Look no further than Moodist, your free and open-source ambient sound generator! Ditch the subscriptions and registrations – with Moodist, you unlock a world of soothing and immersive audio experiences, entirely for free.', - title: 'Free Ambient Sounds', - }, - { - body: `Dive into an expansive library of ${count} carefully curated sounds. Nature lovers will find solace in the gentle murmur of streams, the rhythmic crash of waves, or the crackling warmth of a campfire. Cityscapes come alive with the soft hum of cafes, the rhythmic clatter of trains, or the calming white noise of traffic. And for those seeking deeper focus or relaxation, Moodist offers binaural beats and color noise designed to enhance your state of mind.`, - title: 'Carefully Curated Sounds', - }, - { - body: 'The beauty of Moodist lies in its simplicity and customization. No complex menus or confusing options – just choose your desired sounds, adjust the volume balance, and hit play. Want to blend the gentle chirping of birds with the soothing sound of rain? No problem! Layer as many sounds as you like to create your personalized soundscape oasis.', - title: 'Create Your Soundscape', - }, - { - body: "Whether you're looking to unwind after a long day, enhance your focus during work, or lull yourself into a peaceful sleep, Moodist has the perfect soundscape waiting for you. The best part? It's completely free and open-source, so you can enjoy its benefits without any strings attached. Start using Moodist today and discover your new haven of tranquility and focus!", - title: 'Sounds for Every Moment', - }, +const paragraphKeys = [ + 'about.section1', + 'about.section2', + 'about.section3', + 'about.section4', ]; --- @@ -30,19 +22,17 @@ const paragraphs = [ { - paragraphs.map((paragraph, index) => ( + paragraphKeys.map((key, index) => (
- 0{index + 1} / 0{paragraphs.length} + 0{index + 1} / 0{paragraphKeys.length}
- -

{paragraph.title}

-

{paragraph.body}

+

{t(`${key}.title`)}

+

{t(`${key}.body`, { count: count })}

)) } - - +
@@ -70,8 +60,7 @@ const paragraphs = [ & .paragraph { padding: 30px 0; background: linear-gradient( - transparent, - var(--color-neutral-50) 10%, + transparent var(--color-neutral-50) 10%, var(--color-neutral-50) 90%, transparent ); diff --git a/src/components/app/app.tsx b/src/components/app/app.tsx index c5f890d..edcc45f 100644 --- a/src/components/app/app.tsx +++ b/src/components/app/app.tsx @@ -2,6 +2,8 @@ import { useMemo, useEffect } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { BiSolidHeart } from 'react-icons/bi/index'; import { Howler } from 'howler'; +import { I18nextProvider } from 'react-i18next'; +import i18n from '@/i18n'; import { useSoundStore } from '@/stores/sound'; @@ -17,11 +19,18 @@ import { MediaControls } from '@/components/media-controls'; import { sounds } from '@/data/sounds'; import { FADE_OUT } from '@/constants/events'; -import type { Sound } from '@/data/types'; +import type { Sound, Category as CategoryType } from '@/data/types'; import { subscribe } from '@/lib/event'; -export function App() { - const categories = useMemo(() => sounds.categories, []); +interface AppProps { + locale: string; +} + +export function App({ locale }: AppProps) { + if (locale && i18n.language !== locale) { + i18n.changeLanguage(locale); + } + const categoriesData = useMemo(() => sounds.categories, []); const favorites = useSoundStore(useShallow(state => state.getFavorites())); const pause = useSoundStore(state => state.pause); @@ -29,18 +38,19 @@ export function App() { const unlock = useSoundStore(state => state.unlock); const favoriteSounds = useMemo(() => { - const favoriteSounds = categories + const allFlatSounds = categoriesData .map(category => category.sounds) - .flat() - .filter(sound => favorites.includes(sound.id)); - - /** - * Reorder based on the order of favorites - */ - return favorites.map(favorite => - favoriteSounds.find(sound => sound.id === favorite), + .flat(); + const favoriteSoundsData = allFlatSounds.filter(sound => + favorites.includes(sound.id), ); - }, [favorites, categories]); + + return favorites + .map(favoriteId => + favoriteSoundsData.find(sound => sound.id === favoriteId), + ) + .filter((s): s is Sound => s !== undefined); + }, [favorites, categoriesData]); useEffect(() => { const onChange = () => { @@ -72,33 +82,33 @@ export function App() { }, [pause, lock, unlock]); const allCategories = useMemo(() => { - const favorites = []; - + const favs: CategoryType[] = []; if (favoriteSounds.length) { - favorites.push({ + favs.push({ icon: , id: 'favorites', - sounds: favoriteSounds as Array, - title: 'Favorites', + sounds: favoriteSounds, + titleKey: 'sounds.favorites.title', }); } - - return [...favorites, ...categories]; - }, [favoriteSounds, categories]); + return [...favs, ...categoriesData]; + }, [favoriteSounds, categoriesData]); return ( - - - - -
- - - + + + + + +
+ + + - - - - + + + + + ); } diff --git a/src/components/buttons/play/play.tsx b/src/components/buttons/play/play.tsx index cd4afa9..ce7c15b 100644 --- a/src/components/buttons/play/play.tsx +++ b/src/components/buttons/play/play.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect } from 'react'; import { BiPause, BiPlay } from 'react-icons/bi/index'; import { useHotkeys } from 'react-hotkeys-hook'; +import { useTranslation } from 'react-i18next'; import { useSoundStore } from '@/stores/sound'; import { useSnackbar } from '@/contexts/snackbar'; @@ -9,6 +10,7 @@ import { cn } from '@/helpers/styles'; import styles from './play.module.css'; export function PlayButton() { + const { t } = useTranslation(); const isPlaying = useSoundStore(state => state.isPlaying); const pause = useSoundStore(state => state.pause); const toggle = useSoundStore(state => state.togglePlay); @@ -20,10 +22,10 @@ export function PlayButton() { const handleToggle = useCallback(() => { if (locked) return; - if (noSelected) return showSnackbar('Please first select a sound to play.'); + if (noSelected) return showSnackbar(t('buttons.playError')); toggle(); - }, [showSnackbar, toggle, noSelected, locked]); + }, [showSnackbar, toggle, noSelected, locked, t]); useEffect(() => { if (isPlaying && noSelected) pause(); @@ -42,14 +44,14 @@ export function PlayButton() { {' '} - Pause + {t('common.pause')} ) : ( <> {' '} - Play + {t('common.play')} )} diff --git a/src/components/buttons/unselect/unselect.tsx b/src/components/buttons/unselect/unselect.tsx index 8934128..eac4840 100644 --- a/src/components/buttons/unselect/unselect.tsx +++ b/src/components/buttons/unselect/unselect.tsx @@ -11,13 +11,15 @@ import { fade, mix, slideX } from '@/lib/motion'; import styles from './unselect.module.css'; +import { useTranslation } from 'react-i18next'; + export function UnselectButton() { const noSelected = useSoundStore(state => state.noSelected()); const restoreHistory = useSoundStore(state => state.restoreHistory); const hasHistory = useSoundStore(state => !!state.history); const unselectAll = useSoundStore(state => state.unselectAll); const locked = useSoundStore(state => state.locked); - + const { t } = useTranslation(); const variants = { ...mix(fade(), slideX(15)), exit: { opacity: 0 }, @@ -45,16 +47,16 @@ export function UnselectButton() { showDelay={0} content={ hasHistory - ? 'Restore unselected sounds.' - : 'Unselect all sounds.' + ? t('unselect.restore.tooltip') + : t('unselect.tooltip') } >
diff --git a/src/components/categories/donate/donate.tsx b/src/components/categories/donate/donate.tsx index 93a1774..a36ae1d 100644 --- a/src/components/categories/donate/donate.tsx +++ b/src/components/categories/donate/donate.tsx @@ -1,10 +1,12 @@ import { FaCoffee } from 'react-icons/fa/index'; - +import { useTranslation } from 'react-i18next'; import { SpecialButton } from '@/components/special-button'; import styles from './donate.module.css'; export function Donate() { + const { t } = useTranslation(); + return (
@@ -15,14 +17,14 @@ export function Donate() {
- Support Me + {t('donate.section-title')}
-

Help me keep Moodist ad-free.

+

{t('donate.section-desc')}

- Donate Today + {t('donate.section-button')}
); diff --git a/src/components/donate.astro b/src/components/donate.astro index f32e970..7b1e3a8 100644 --- a/src/components/donate.astro +++ b/src/components/donate.astro @@ -1,17 +1,21 @@ --- +import { getTranslator } from '@/i18n/utils'; import { Container } from './container'; + +const currentLocale = Astro.currentLocale || 'en'; +const t = await getTranslator(currentLocale); ---

- Enjoy Moodist?{' '} + {t('donate.prompt')}{' '} - Support with a donation! + {t('donate.link-text')}

diff --git a/src/components/footer.astro b/src/components/footer.astro index bfed23f..671970a 100644 --- a/src/components/footer.astro +++ b/src/components/footer.astro @@ -1,12 +1,18 @@ --- +import { getTranslator } from '@/i18n/utils'; import { Container } from './container'; + +const currentLocale = Astro.currentLocale || 'en'; +const t = await getTranslator(currentLocale); ---
-

- Created by Maze ✦ -

+

Maze ✦', + })} + />

@@ -15,17 +21,18 @@ import { Container } from './container'; display: flex; align-items: center; height: 100px; + } - & p { - font-size: var(--font-sm); - color: var(--color-foreground-subtle); - text-align: center; + .footer p { + margin: 0; + font-size: var(--font-sm); + color: var(--color-foreground-subtle); + text-align: center; + } - & a { - font-weight: 500; - color: var(--color-foreground); - text-decoration: none; - } - } + .footer p :global(a) { + font-weight: 500; + color: var(--color-foreground); + text-decoration: none; } diff --git a/src/components/hero.astro b/src/components/hero.astro index c7f787f..c1c880a 100644 --- a/src/components/hero.astro +++ b/src/components/hero.astro @@ -1,11 +1,13 @@ --- import { BsSoundwave } from 'react-icons/bs/index'; - +import { getTranslator } from '@/i18n/utils'; import { Container } from './container'; import { CipherText } from './cipher'; - import { count as soundCount } from '@/lib/sounds'; +const currentLocale = Astro.currentLocale || 'en'; +const t = await getTranslator(currentLocale); + const count = soundCount(); --- @@ -15,7 +17,7 @@ const count = soundCount();

- Ambient SoundsFor Focus and Calm + {t('hero.title-line1')}{t('hero.title-line2')}

- Free and . + {t('hero.desc-prefix')} + + .

- {count} Sounds + {t('hero.sounds-count', { count: count })}

diff --git a/src/components/language-switcher.astro b/src/components/language-switcher.astro new file mode 100644 index 0000000..26a6379 --- /dev/null +++ b/src/components/language-switcher.astro @@ -0,0 +1,171 @@ +--- +import { getSupportedLangs, getTranslator } from '@/i18n/utils'; +import { IoChevronDown } from 'react-icons/io5'; + +const { url } = Astro; +const defaultLocaleCode = 'en'; // 明确默认语言代码 + +const currentLocale = Astro.currentLocale || defaultLocaleCode; +const t = await getTranslator(currentLocale); + +const supportedLangs = getSupportedLangs(); // 如 ['en', 'zh-CN-CN', 'zh-CN-TW', 'ja'] + +let basePath = url.pathname; +const currentLangPrefix = `/${currentLocale}`; + +if ( + currentLocale !== defaultLocaleCode && + basePath.startsWith(currentLangPrefix) +) { + basePath = basePath.substring(currentLangPrefix.length) || '/'; // 获取前缀后的部分,空则为根 +} +if (basePath !== '/' && !basePath.startsWith('/')) { + basePath = '/' + basePath; +} + +const currentLangName = + t(`languages.${currentLocale}`) || currentLocale.toUpperCase(); +--- + +
+
+ + {currentLangName} + + +
    + { + supportedLangs.map(langCode => { + if (langCode === currentLocale) return null; + + const isDefaultLang = langCode === defaultLocaleCode; + let targetPath = isDefaultLang ? basePath : `/${langCode}${basePath}`; + targetPath = targetPath.replace('//', '/'); + if (isDefaultLang && targetPath === '') targetPath = '/'; + if (!isDefaultLang && targetPath === `/${langCode}/`) + targetPath = `/${langCode}`; + + return ( +
  • + + {t(`languages.${langCode}`) || langCode.toUpperCase()} + +
  • + ); + }) + } +
+
+
+ + diff --git a/src/components/modal/modal.tsx b/src/components/modal/modal.tsx index c4cf8b5..a62f2bf 100644 --- a/src/components/modal/modal.tsx +++ b/src/components/modal/modal.tsx @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import { IoClose } from 'react-icons/io5/index'; import FocusTrap from 'focus-trap-react'; +import { useTranslation } from 'react-i18next'; import { Portal } from '@/components/portal'; @@ -29,6 +30,7 @@ export function Modal({ show, wide, }: ModalProps) { + const { t } = useTranslation(); const variants = { modal: mix(fade(), slideY(20)), overlay: fade(), @@ -38,7 +40,6 @@ export function Modal({ if (show && lockBody) { document.body.style.overflowY = 'hidden'; } else if (lockBody) { - // Wait for transition to finish before allowing scrollbar to return setTimeout(() => { document.body.style.overflowY = 'auto'; }, TRANSITION_DURATION); @@ -85,7 +86,11 @@ export function Modal({ transition={{ duration: TRANSITION_DURATION / 1000 }} variants={variants.modal} > - {children} diff --git a/src/components/modals/binaural/binaural.tsx b/src/components/modals/binaural/binaural.tsx index 70512b4..ced84e2 100644 --- a/src/components/modals/binaural/binaural.tsx +++ b/src/components/modals/binaural/binaural.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useRef, useCallback } from 'react'; - +import { useTranslation } from 'react-i18next'; import { Modal } from '@/components/modal'; import { Slider } from '@/components/slider'; @@ -14,15 +14,46 @@ interface Preset { baseFrequency: number; beatFrequency: number; name: string; + translationKey: string; } const presets: Preset[] = [ - { baseFrequency: 100, beatFrequency: 2, name: 'Delta (Deep Sleep) 2 Hz' }, - { baseFrequency: 100, beatFrequency: 5, name: 'Theta (Meditation) 5 Hz' }, - { baseFrequency: 100, beatFrequency: 10, name: 'Alpha (Relaxation) 10 Hz' }, - { baseFrequency: 100, beatFrequency: 20, name: 'Beta (Focus) 20 Hz' }, - { baseFrequency: 100, beatFrequency: 40, name: 'Gamma (Cognition) 40 Hz' }, - { baseFrequency: 440, beatFrequency: 10, name: 'Custom' }, + { + baseFrequency: 100, + beatFrequency: 2, + name: 'Delta (Deep Sleep) 2 Hz', + translationKey: 'modals.generators.presets.delta', + }, + { + baseFrequency: 100, + beatFrequency: 5, + name: 'Theta (Meditation) 5 Hz', + translationKey: 'modals.generators.presets.theta', + }, + { + baseFrequency: 100, + beatFrequency: 10, + name: 'Alpha (Relaxation) 10 Hz', + translationKey: 'modals.generators.presets.alpha', + }, + { + baseFrequency: 100, + beatFrequency: 20, + name: 'Beta (Focus) 20 Hz', + translationKey: 'modals.generators.presets.beta', + }, + { + baseFrequency: 100, + beatFrequency: 40, + name: 'Gamma (Cognition) 40 Hz', + translationKey: 'modals.generators.presets.gamma', + }, + { + baseFrequency: 440, + beatFrequency: 10, + name: 'Custom', + translationKey: 'modals.generators.presets.custom', + }, ]; function computeBinauralBeatOscillatorFrequencies( @@ -36,6 +67,7 @@ function computeBinauralBeatOscillatorFrequencies( } export function BinauralModal({ onClose, show }: BinauralProps) { + const { t } = useTranslation(); const [baseFrequency, setBaseFrequency] = useState(440); // Default to A4 note const [beatFrequency, setBeatFrequency] = useState(10); // Default to 10 Hz difference const [volume, setVolume] = useState(0.5); // Default volume at 50% @@ -145,15 +177,14 @@ export function BinauralModal({ onClose, show }: BinauralProps) { }, [selectedPreset]); const handlePresetChange = (e: React.ChangeEvent) => { - const selected = e.target.value; - setSelectedPreset(selected); + const selectedName = e.target.value; + setSelectedPreset(selectedName); - if (selected === 'Custom') { - // Allow user to input custom frequencies + if (selectedName === 'Custom') { return; } - const preset = presets.find(p => p.name === selected); + const preset = presets.find(p => p.name === selectedName); if (preset) { setBaseFrequency(preset.baseFrequency); setBeatFrequency(preset.beatFrequency); @@ -163,17 +194,17 @@ export function BinauralModal({ onClose, show }: BinauralProps) { return (
-

Binaural Beat

-

Binaural beat generator.

+

{t('modals.binaural.title')}

+

{t('modals.binaural.description')}