From 09b400d234268be8756bf9ef0d81cff8dc190efa Mon Sep 17 00:00:00 2001 From: yozuru Date: Sat, 19 Apr 2025 03:45:34 +0800 Subject: [PATCH 01/12] feat: integrate basic i18n support using i18next and react-i18next --- astro.config.mjs | 7 +++ package-lock.json | 80 ++++++++++++++++++++++++++- package.json | 2 + src/components/about.astro | 38 +++++-------- src/components/app/app.tsx | 83 +++++++++++++++++----------- src/components/buttons/play/play.tsx | 10 ++-- src/i18n.ts | 30 ++++++++++ src/i18n/utils.ts | 29 ++++++++++ src/layouts/layout.astro | 17 +++--- src/locales/en/translation.json | 45 +++++++++++++++ src/locales/zh/translation.json | 44 +++++++++++++++ src/pages/index.astro | 7 ++- src/pages/zh/index.astro | 20 +++++++ 13 files changed, 338 insertions(+), 74 deletions(-) create mode 100644 src/i18n.ts create mode 100644 src/i18n/utils.ts create mode 100644 src/locales/en/translation.json create mode 100644 src/locales/zh/translation.json create mode 100644 src/pages/zh/index.astro diff --git a/astro.config.mjs b/astro.config.mjs index 82f6cc6..71b125f 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'], + 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/src/components/about.astro b/src/components/about.astro index 72fd439..6b28965 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 })}

)) } - - +
diff --git a/src/components/app/app.tsx b/src/components/app/app.tsx index c5f890d..ceb5c18 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, useTranslation } from 'react-i18next'; +import i18n from '@/i18n'; import { useSoundStore } from '@/stores/sound'; @@ -17,11 +19,24 @@ 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; // 接收来自 Astro 的 locale +} + +export function App({ locale }: AppProps) { + const { t } = useTranslation(); // 获取 t 函数,以便翻译 "Favorites" + + useEffect(() => { + if (locale && i18n.language !== locale) { + i18n.changeLanguage(locale); + } + }, [locale]); + + const categoriesData = useMemo(() => sounds.categories, []); + const categories = categoriesData; // 暂时不翻译 const favorites = useSoundStore(useShallow(state => state.getFavorites())); const pause = useSoundStore(state => state.pause); @@ -29,18 +44,20 @@ 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]); + + // 暂时不翻译 sound labels + return favorites + .map(favoriteId => + favoriteSoundsData.find(sound => sound.id === favoriteId), + ) + .filter((s): s is Sound => s !== undefined); + }, [favorites, categoriesData]); useEffect(() => { const onChange = () => { @@ -72,33 +89,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, + title: t('categories.favorites'), }); } - - return [...favorites, ...categories]; - }, [favoriteSounds, categories]); + return [...favs, ...categories]; + }, [favoriteSounds, categories, t]); 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/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..3935317 --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,30 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; + +import enTranslation from './locales/en/translation.json'; +import zhTranslation from './locales/zh/translation.json'; + +const resources = { + en: { + translation: enTranslation, + }, + zh: { + translation: zhTranslation, + }, +}; + +i18n.use(initReactI18next).init({ + debug: true, + fallbackLng: 'en', + interpolation: { + escapeValue: false, + }, + lng: 'en', + react: { + useSuspense: false, + }, + + resources, +}); + +export default i18n; diff --git a/src/i18n/utils.ts b/src/i18n/utils.ts new file mode 100644 index 0000000..76a68b1 --- /dev/null +++ b/src/i18n/utils.ts @@ -0,0 +1,29 @@ +import i18n from '@/i18n'; +import type { TFunction } from 'i18next'; + +/** + * 获取指定语言的翻译函数 (t function)。 + * 这个函数是异步的,以防 i18next 需要异步加载资源或切换语言。 + * @param lng - 语言代码 (例如 'en', 'zh')。如果未提供,则使用当前或回退语言。 + * @returns Promise - 返回一个解析为翻译函数的 Promise。 + */ +export async function getTranslator(lng?: string): Promise { + const targetLng = + lng || i18n.language || (i18n.options.fallbackLng as string[])[0]; + + // 如果 i18n 实例的当前语言与目标语言不一致,则切换语言 + // 注意:changeLanguage 是异步的 + if (i18n.language !== targetLng) { + await i18n.changeLanguage(targetLng); + } + + return i18n.t; +} + +/** + * 获取当前支持的语言列表 + * @returns string[] + */ +export function getSupportedLangs(): string[] { + return Object.keys(i18n.options.resources || {}); +} diff --git a/src/layouts/layout.astro b/src/layouts/layout.astro index bbc64e6..05675e2 100644 --- a/src/layouts/layout.astro +++ b/src/layouts/layout.astro @@ -3,7 +3,9 @@ import { pwaInfo } from 'virtual:pwa-info'; // eslint-disable-line import { Reload } from '@/components/reload'; -import { count } from '@/lib/sounds'; +import { count as soundCount } from '@/lib/sounds'; + +import { getTranslator } from '@/i18n/utils'; import '@/styles/global.css'; @@ -11,15 +13,16 @@ interface Props { description?: string; title?: string; } +const currentLocale = Astro.currentLocale; +const t = await getTranslator(currentLocale); -const title = Astro.props.title || 'Moodist: Ambient Sounds for Focus and Calm'; -const description = - Astro.props.description || - `Moodist is a free and open-source ambient sound generator featuring ${count()} carefully curated sounds. Create your ideal atmosphere for relaxation, focus, or creativity with this versatile tool.`; +const count = soundCount(); +const title = Astro.props.title || t('site.title'); +const description = Astro.props.description || t('site.description', { count }); --- - + @@ -33,7 +36,7 @@ const description = - + diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json new file mode 100644 index 0000000..fbba755 --- /dev/null +++ b/src/locales/en/translation.json @@ -0,0 +1,45 @@ +{ + "site": { + "title": "Moodist: Ambient Sounds for Focus and Calm", + "description": "Moodist is a free and open-source ambient sound generator featuring {{count}} carefully curated sounds. Create your ideal atmosphere for relaxation, focus, or creativity with this versatile tool.", + "ogSiteName": "Moodist" + }, + "common": { + "play": "Play", + "pause": "Pause", + "close": "Close" + }, + "modals": { + "reload": { + "title": "New Content", + "description": "New content available, click on reload button to update.", + "reloadButton": "Reload" + } + }, + + "buttons": { + "playError": "Please first select a sound to play.", + "useMoodist": "Use Moodist" + }, + "categories": { + "favorites": "Favorites" + }, + "about": { + "section1": { + "title": "Free Ambient Sounds", + "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." + }, + "section2": { + "title": "Carefully Curated 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." + }, + "section3": { + "title": "Create Your Soundscape", + "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." + }, + "section4": { + "title": "Sounds for Every Moment", + "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!" + } + } +} diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json new file mode 100644 index 0000000..7429bce --- /dev/null +++ b/src/locales/zh/translation.json @@ -0,0 +1,44 @@ +{ + "site": { + "title": "Moodist:专注与平静的环境声音", + "description": "Moodist 是一个免费且开源的环境声音生成器,包含 {{count}} 种精心挑选的声音。使用这款多功能工具,为放松、专注或创造力营造理想的氛围。", + "ogSiteName": "Moodist" + }, + "common": { + "play": "播放", + "pause": "暂停", + "close": "关闭" + }, + "modals": { + "reload": { + "title": "发现新内容", + "description": "检测到可用新内容,点击“重新加载”按钮进行更新。", + "reloadButton": "重新加载" + } + }, + "buttons": { + "playError": "请先选择要播放的声音。", + "useMoodist": "使用 Moodist" + }, + "categories": { + "favorites": "收藏夹" + }, + "about": { + "section1": { + "title": "免费环境声音", + "body": "渴望从日常喧嚣中获得平静的休憩?需要完美的声音环境来提升专注力或助您安然入睡?Moodist 就是您的答案——免费且开源的环境声音生成器!无需订阅和注册,Moodist 为您免费解锁一个舒缓、沉浸式的音频体验世界。" + }, + "section2": { + "title": "精心挑选的声音", + "body": "探索包含 {{count}} 种精心策划声音的广阔音库。自然爱好者可以在潺潺溪流、规律的海浪拍岸或噼啪作响的温暖篝火中找到慰藉。城市景观在咖啡馆的轻柔嗡嗡声、火车有节奏的哐当声或交通的平静白噪音中栩栩如生。对于寻求更深层次专注或放松的人,Moodist 提供旨在改善您心境的双耳节拍和彩色噪音。" + }, + "section3": { + "title": "创建您的声音景观", + "body": "Moodist 的美妙之处在于其简洁和可定制性。没有复杂的菜单或令人困惑的选项——只需选择您想要的声音,调整音量平衡,然后点击播放。想将鸟儿的轻柔啁啾与舒缓的雨声混合?没问题!随心所欲地叠加任意数量的声音,创造您个性化的声音绿洲。" + }, + "section4": { + "title": "适合每一刻的声音", + "body": "无论您是想在漫长的一天后放松身心,在工作时提高注意力,还是哄自己进入宁静的睡眠,Moodist 都有完美的声音景观等着您。最棒的是?它完全免费且开源,因此您可以无任何附加条件地享受其益处。立即开始使用 Moodist,发现您宁静与专注的新港湾!" + } + } +} diff --git a/src/pages/index.astro b/src/pages/index.astro index 94e2adc..9d6296e 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,6 +1,5 @@ --- import Layout from '@/layouts/layout.astro'; - import Donate from '@/components/donate.astro'; import Hero from '@/components/hero.astro'; import About from '@/components/about.astro'; @@ -8,12 +7,14 @@ import Source from '@/components/source.astro'; import Footer from '@/components/footer.astro'; import { App } from '@/components/app'; + +const currentLocale = Astro.currentLocale || 'en'; --- - + - +
diff --git a/src/pages/zh/index.astro b/src/pages/zh/index.astro new file mode 100644 index 0000000..821c8bc --- /dev/null +++ b/src/pages/zh/index.astro @@ -0,0 +1,20 @@ +--- +import Layout from '@/layouts/layout.astro'; +import Donate from '@/components/donate.astro'; +import Hero from '@/components/hero.astro'; +import About from '@/components/about.astro'; +import Source from '@/components/source.astro'; +import Footer from '@/components/footer.astro'; +import { App } from '@/components/app'; + +const currentLocale = Astro.currentLocale || 'en'; +--- + + + + + + + +
+ From 0ab31a16f6efcdc16f3199a66aa72a6551954dc7 Mon Sep 17 00:00:00 2001 From: yozuru Date: Sat, 19 Apr 2025 04:18:23 +0800 Subject: [PATCH 02/12] feat: add language switcher component --- src/components/LanguageSwitcher.astro | 172 ++++++++++++++++++++++++++ src/layouts/layout.astro | 16 ++- src/locales/en/translation.json | 7 ++ src/locales/zh/translation.json | 7 ++ 4 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 src/components/LanguageSwitcher.astro diff --git a/src/components/LanguageSwitcher.astro b/src/components/LanguageSwitcher.astro new file mode 100644 index 0000000..fa1ebec --- /dev/null +++ b/src/components/LanguageSwitcher.astro @@ -0,0 +1,172 @@ +--- +import { getSupportedLangs, getTranslator } from '@/i18n/utils'; +import i18n from '@/i18n'; +import { IoChevronDown } from 'react-icons/io5'; + +const { url } = Astro; +// 使用 fallbackLng 作为回退值 +const currentLocale = + Astro.currentLocale || + (Array.isArray(i18n.options.fallbackLng) + ? i18n.options.fallbackLng[0] + : i18n.options.fallbackLng) || + 'en'; +const t = await getTranslator(currentLocale); + +const supportedLangs = getSupportedLangs(); +const defaultLocaleCode = 'en'; + +let basePath = url.pathname; +const prefix = `/${currentLocale}`; + +// 只有当当前语言不是硬编码的默认语言时,才需要尝试移除前缀 +if (currentLocale !== defaultLocaleCode && basePath.startsWith(prefix)) { + basePath = basePath.substring(prefix.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 (targetPath === '' && isDefaultLang) targetPath = '/'; + if (targetPath === '' && !isDefaultLang) targetPath = `/${langCode}`; + + return ( +
  • + + {t(`languages.${langCode}`) || langCode.toUpperCase()} + +
  • + ); + }) + } +
+
+
+ + diff --git a/src/layouts/layout.astro b/src/layouts/layout.astro index 05675e2..606dccf 100644 --- a/src/layouts/layout.astro +++ b/src/layouts/layout.astro @@ -8,7 +8,7 @@ import { count as soundCount } from '@/lib/sounds'; import { getTranslator } from '@/i18n/utils'; import '@/styles/global.css'; - +import LanguageSwitcher from '@/components/LanguageSwitcher.astro'; interface Props { description?: string; title?: string; @@ -46,8 +46,22 @@ const description = Astro.props.description || t('site.description', { count }); {pwaInfo && } +
+ +
+ diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index fbba755..34e028a 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -4,6 +4,13 @@ "description": "Moodist is a free and open-source ambient sound generator featuring {{count}} carefully curated sounds. Create your ideal atmosphere for relaxation, focus, or creativity with this versatile tool.", "ogSiteName": "Moodist" }, + "languages": { + "en": "English", + "zh": "简体中文" + }, + "languageSwitcher": { + "label": "Language selection" + }, "common": { "play": "Play", "pause": "Pause", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 7429bce..af78eb3 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -4,6 +4,13 @@ "description": "Moodist 是一个免费且开源的环境声音生成器,包含 {{count}} 种精心挑选的声音。使用这款多功能工具,为放松、专注或创造力营造理想的氛围。", "ogSiteName": "Moodist" }, + "languages": { + "en": "English", + "zh": "简体中文" + }, + "languageSwitcher": { + "label": "语言选择" + }, "common": { "play": "播放", "pause": "暂停", From c8273371e0de55748686f373f7c96a6064ea34e8 Mon Sep 17 00:00:00 2001 From: yozuru Date: Sat, 19 Apr 2025 20:15:35 +0800 Subject: [PATCH 03/12] fix: resolve hydration mismatch error for i18n --- src/components/app/app.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/components/app/app.tsx b/src/components/app/app.tsx index ceb5c18..30f5bca 100644 --- a/src/components/app/app.tsx +++ b/src/components/app/app.tsx @@ -27,13 +27,10 @@ interface AppProps { } export function App({ locale }: AppProps) { - const { t } = useTranslation(); // 获取 t 函数,以便翻译 "Favorites" - - useEffect(() => { - if (locale && i18n.language !== locale) { - i18n.changeLanguage(locale); - } - }, [locale]); + if (locale && i18n.language !== locale) { + i18n.changeLanguage(locale); + } + const { t } = useTranslation(); const categoriesData = useMemo(() => sounds.categories, []); const categories = categoriesData; // 暂时不翻译 From 10f59a55a62cf7e3a211c7186acec66767512f42 Mon Sep 17 00:00:00 2001 From: yozuru Date: Sat, 19 Apr 2025 23:52:34 +0800 Subject: [PATCH 04/12] feat: internationalize sound data and buttons --- src/components/about.astro | 7 +- src/components/app/app.tsx | 14 +- src/components/buttons/play/play.tsx | 4 +- src/components/buttons/unselect/unselect.tsx | 12 +- .../categories/category/category.tsx | 13 +- .../sounds/sound/favorite/favorite.tsx | 19 +- src/components/sounds/sound/range/range.tsx | 13 +- src/components/sounds/sound/sound.tsx | 35 ++-- src/components/sounds/sounds.tsx | 14 +- src/data/sounds/animals.tsx | 35 ++-- src/data/sounds/binaural.tsx | 13 +- src/data/sounds/nature.tsx | 27 +-- src/data/sounds/noise.tsx | 9 +- src/data/sounds/places.tsx | 35 ++-- src/data/sounds/rain.tsx | 19 +- src/data/sounds/things.tsx | 35 ++-- src/data/sounds/transport.tsx | 15 +- src/data/sounds/urban.tsx | 17 +- src/data/types.d.ts | 4 +- src/i18n.ts | 29 ++- src/locales/en/translation.json | 167 ++++++++++++++++-- src/locales/zh/translation.json | 166 +++++++++++++++-- 22 files changed, 512 insertions(+), 190 deletions(-) diff --git a/src/components/about.astro b/src/components/about.astro index 6b28965..c7a309e 100644 --- a/src/components/about.astro +++ b/src/components/about.astro @@ -32,7 +32,9 @@ const paragraphKeys = [
)) } - +
@@ -60,8 +62,7 @@ const paragraphKeys = [ & .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 30f5bca..edcc45f 100644 --- a/src/components/app/app.tsx +++ b/src/components/app/app.tsx @@ -2,7 +2,7 @@ import { useMemo, useEffect } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { BiSolidHeart } from 'react-icons/bi/index'; import { Howler } from 'howler'; -import { I18nextProvider, useTranslation } from 'react-i18next'; +import { I18nextProvider } from 'react-i18next'; import i18n from '@/i18n'; import { useSoundStore } from '@/stores/sound'; @@ -23,17 +23,14 @@ import type { Sound, Category as CategoryType } from '@/data/types'; import { subscribe } from '@/lib/event'; interface AppProps { - locale: string; // 接收来自 Astro 的 locale + locale: string; } export function App({ locale }: AppProps) { if (locale && i18n.language !== locale) { i18n.changeLanguage(locale); } - const { t } = useTranslation(); - const categoriesData = useMemo(() => sounds.categories, []); - const categories = categoriesData; // 暂时不翻译 const favorites = useSoundStore(useShallow(state => state.getFavorites())); const pause = useSoundStore(state => state.pause); @@ -48,7 +45,6 @@ export function App({ locale }: AppProps) { favorites.includes(sound.id), ); - // 暂时不翻译 sound labels return favorites .map(favoriteId => favoriteSoundsData.find(sound => sound.id === favoriteId), @@ -92,11 +88,11 @@ export function App({ locale }: AppProps) { icon: , id: 'favorites', sounds: favoriteSounds, - title: t('categories.favorites'), + titleKey: 'sounds.favorites.title', }); } - return [...favs, ...categories]; - }, [favoriteSounds, categories, t]); + return [...favs, ...categoriesData]; + }, [favoriteSounds, categoriesData]); return ( diff --git a/src/components/buttons/play/play.tsx b/src/components/buttons/play/play.tsx index ce7c15b..21b686f 100644 --- a/src/components/buttons/play/play.tsx +++ b/src/components/buttons/play/play.tsx @@ -44,14 +44,14 @@ export function PlayButton() { {' '} - {t('common.pause')} + {t('buttons.pause.label')} ) : ( <> {' '} - {t('common.play')} + {t('buttons.play.label')} )} diff --git a/src/components/buttons/unselect/unselect.tsx b/src/components/buttons/unselect/unselect.tsx index 8934128..7682138 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('buttons.unselect.restore.tooltip') + : t('buttons.unselect.tooltip') } >
diff --git a/src/components/sounds/sound/favorite/favorite.tsx b/src/components/sounds/sound/favorite/favorite.tsx index 580bb72..b09f1e4 100644 --- a/src/components/sounds/sound/favorite/favorite.tsx +++ b/src/components/sounds/sound/favorite/favorite.tsx @@ -1,5 +1,6 @@ import { BiHeart, BiSolidHeart } from 'react-icons/bi/index'; import { AnimatePresence, motion } from 'framer-motion'; +import { useTranslation } from 'react-i18next'; import { useSoundStore } from '@/stores/sound'; import { cn } from '@/helpers/styles'; @@ -16,7 +17,8 @@ interface FavoriteProps { } export function Favorite({ id, label }: FavoriteProps) { - const isFavorite = useSoundStore(state => state.sounds[id].isFavorite); + const { t } = useTranslation(); + const isFavorite = useSoundStore(state => state.sounds[id]?.isFavorite); const toggleFavorite = useSoundStore(state => state.toggleFavorite); const handleToggle = async () => { @@ -36,18 +38,21 @@ export function Favorite({ id, label }: FavoriteProps) { }; const variants = fade(); - const handleKeyDown = useKeyboardButton(handleToggle); + const ariaLabel = isFavorite + ? t('buttons.favorite.remove.aria-label', { label: label }) + : t('buttons.favorite.add.aria-label', { label: label }); + + if (useSoundStore.getState().sounds[id] === undefined) { + return null; + } + return ( diff --git a/src/data/sounds/animals.tsx b/src/data/sounds/animals.tsx index cb7dcf1..b72bc3f 100644 --- a/src/data/sounds/animals.tsx +++ b/src/data/sounds/animals.tsx @@ -24,103 +24,104 @@ import type { Category } from '../types'; export const animals: Category = { icon: , id: 'animals', + // 修改 sounds: [ { icon: , id: 'birds', - label: 'Birds', + labelKey: 'sounds.animals.birds', src: '/sounds/animals/birds.mp3', }, { icon: , id: 'seagulls', - label: 'Seagulls', + labelKey: 'sounds.animals.seagulls', src: '/sounds/animals/seagulls.mp3', }, { icon: , id: 'crickets', - label: 'Crickets', + labelKey: 'sounds.animals.crickets', src: '/sounds/animals/crickets.mp3', }, { icon: , id: 'wolf', - label: 'Wolf', + labelKey: 'sounds.animals.wolf', src: '/sounds/animals/wolf.mp3', }, { icon: , id: 'owl', - label: 'Owl', + labelKey: 'sounds.animals.owl', src: '/sounds/animals/owl.mp3', }, { icon: , id: 'frog', - label: 'Frog', + labelKey: 'sounds.animals.frog', src: '/sounds/animals/frog.mp3', }, { icon: , id: 'dog-barking', - label: 'Dog Barking', + labelKey: 'sounds.animals.dog-barking', src: '/sounds/animals/dog-barking.mp3', }, { icon: , id: 'horse-galopp', - label: 'Horse Galopp', + labelKey: 'sounds.animals.horse-galopp', src: '/sounds/animals/horse-galopp.mp3', }, { icon: , id: 'cat-purring', - label: 'Cat Purring', + labelKey: 'sounds.animals.cat-purring', src: '/sounds/animals/cat-purring.mp3', }, { icon: , id: 'crows', - label: 'Crows', + labelKey: 'sounds.animals.crows', src: '/sounds/animals/crows.mp3', }, { icon: , id: 'whale', - label: 'Whale', + labelKey: 'sounds.animals.whale', src: '/sounds/animals/whale.mp3', }, { icon: , id: 'beehive', - label: 'Beehive', + labelKey: 'sounds.animals.beehive', src: '/sounds/animals/beehive.mp3', }, { icon: , id: 'woodpecker', - label: 'Woodpecker', + labelKey: 'sounds.animals.woodpecker', src: '/sounds/animals/woodpecker.mp3', }, { icon: , id: 'chickens', - label: 'Chickens', + labelKey: 'sounds.animals.chickens', src: '/sounds/animals/chickens.mp3', }, { icon: , id: 'cows', - label: 'Cows', + labelKey: 'sounds.animals.cows', src: '/sounds/animals/cows.mp3', }, { icon: , id: 'sheep', - label: 'Sheep', + labelKey: 'sounds.animals.sheep', src: '/sounds/animals/sheep.mp3', }, ], - title: 'Animals', + titleKey: 'sounds.animals.title', }; diff --git a/src/data/sounds/binaural.tsx b/src/data/sounds/binaural.tsx index ffe96de..37a3556 100644 --- a/src/data/sounds/binaural.tsx +++ b/src/data/sounds/binaural.tsx @@ -6,37 +6,38 @@ import type { Category } from '../types'; export const binaural: Category = { icon: , id: 'binaural', + // 修改 sounds: [ { icon: , id: 'binaural-delta', - label: 'Delta', + labelKey: 'sounds.binaural.binaural-delta', src: '/sounds/binaural/binaural-delta.wav', }, { icon: , id: 'binaural-theta', - label: 'Theta', + labelKey: 'sounds.binaural.binaural-theta', src: '/sounds/binaural/binaural-theta.wav', }, { icon: , id: 'binaural-alpha', - label: 'Alpha', + labelKey: 'sounds.binaural.binaural-alpha', src: '/sounds/binaural/binaural-alpha.wav', }, { icon: , id: 'binaural-beta', - label: 'Beta', + labelKey: 'sounds.binaural.binaural-beta', src: '/sounds/binaural/binaural-beta.wav', }, { icon: , id: 'binaural-gamma', - label: 'Gamma', + labelKey: 'sounds.binaural.binaural-gamma', src: '/sounds/binaural/binaural-gamma.wav', }, ], - title: 'Binaural Beats', + titleKey: 'sounds.binaural.title', }; diff --git a/src/data/sounds/nature.tsx b/src/data/sounds/nature.tsx index f90d2ea..b638870 100644 --- a/src/data/sounds/nature.tsx +++ b/src/data/sounds/nature.tsx @@ -14,79 +14,80 @@ import type { Category } from '../types'; export const nature: Category = { icon: , id: 'nature', + // 修改 sounds: [ { icon: , id: 'river', - label: 'River', + labelKey: 'sounds.nature.river', src: '/sounds/nature/river.mp3', }, { icon: , id: 'waves', - label: 'Waves', + labelKey: 'sounds.nature.waves', src: '/sounds/nature/waves.mp3', }, { icon: , id: 'campfire', - label: 'Campfire', + labelKey: 'sounds.nature.campfire', src: '/sounds/nature/campfire.mp3', }, { icon: , id: 'wind', - label: 'Wind', + labelKey: 'sounds.nature.wind', src: '/sounds/nature/wind.mp3', }, { icon: , id: 'howling-wind', - label: 'Howling Wind', + labelKey: 'sounds.nature.howling-wind', src: '/sounds/nature/howling-wind.mp3', }, { icon: , id: 'wind-in-trees', - label: 'Wind in Trees', + labelKey: 'sounds.nature.wind-in-trees', src: '/sounds/nature/wind-in-trees.mp3', }, { icon: , id: 'waterfall', - label: 'Waterfall', + labelKey: 'sounds.nature.waterfall', src: '/sounds/nature/waterfall.mp3', }, { icon: , id: 'walk-in-snow', - label: 'Walk in Snow', + labelKey: 'sounds.nature.walk-in-snow', src: '/sounds/nature/walk-in-snow.mp3', }, { icon: , id: 'walk-on-leaves', - label: 'Walk on Leaves', + labelKey: 'sounds.nature.walk-on-leaves', src: '/sounds/nature/walk-on-leaves.mp3', }, { icon: , id: 'walk-on-gravel', - label: 'Walk on Gravel', + labelKey: 'sounds.nature.walk-on-gravel', src: '/sounds/nature/walk-on-gravel.mp3', }, { icon: , id: 'droplets', - label: 'Droplets', + labelKey: 'sounds.nature.droplets', src: '/sounds/nature/droplets.mp3', }, { icon: , id: 'jungle', - label: 'Jungle', + labelKey: 'sounds.nature.jungle', src: '/sounds/nature/jungle.mp3', }, ], - title: 'Nature', + titleKey: 'sounds.nature.title', }; diff --git a/src/data/sounds/noise.tsx b/src/data/sounds/noise.tsx index 5042001..279c935 100644 --- a/src/data/sounds/noise.tsx +++ b/src/data/sounds/noise.tsx @@ -6,25 +6,26 @@ import type { Category } from '../types'; export const noise: Category = { icon: , id: 'noise', + // 修改 sounds: [ { icon: , id: 'white-noise', - label: 'White Noise', + labelKey: 'sounds.noise.white-noise', src: '/sounds/noise/white-noise.wav', }, { icon: , id: 'pink-noise', - label: 'Pink Noise', + labelKey: 'sounds.noise.pink-noise', src: '/sounds/noise/pink-noise.wav', }, { icon: , id: 'brown-noise', - label: 'Brown Noise', + labelKey: 'sounds.noise.brown-noise', src: '/sounds/noise/brown-noise.wav', }, ], - title: 'Noise', + titleKey: 'sounds.noise.title', }; diff --git a/src/data/sounds/places.tsx b/src/data/sounds/places.tsx index b9c771a..88037ca 100644 --- a/src/data/sounds/places.tsx +++ b/src/data/sounds/places.tsx @@ -21,103 +21,104 @@ import type { Category } from '../types'; export const places: Category = { icon: , id: 'places', + // 修改 sounds: [ { icon: , id: 'cafe', - label: 'Cafe', + labelKey: 'sounds.places.cafe', src: '/sounds/places/cafe.mp3', }, { icon: , id: 'airport', - label: 'Airport', + labelKey: 'sounds.places.airport', src: '/sounds/places/airport.mp3', }, { icon: , id: 'church', - label: 'Church', + labelKey: 'sounds.places.church', src: '/sounds/places/church.mp3', }, { icon: , id: 'temple', - label: 'Temple', + labelKey: 'sounds.places.temple', src: '/sounds/places/temple.mp3', }, { icon: , id: 'construction-site', - label: 'Construction Site', + labelKey: 'sounds.places.construction-site', src: '/sounds/places/construction-site.mp3', }, { icon: , id: 'underwater', - label: 'Underwater', + labelKey: 'sounds.places.underwater', src: '/sounds/places/underwater.mp3', }, { icon: , id: 'crowded-bar', - label: 'Crowded Bar', + labelKey: 'sounds.places.crowded-bar', src: '/sounds/places/crowded-bar.mp3', }, { icon: , id: 'night-village', - label: 'Night Village', + labelKey: 'sounds.places.night-village', src: '/sounds/places/night-village.mp3', }, { icon: , id: 'subway-station', - label: 'Subway Station', + labelKey: 'sounds.places.subway-station', src: '/sounds/places/subway-station.mp3', }, { icon: , id: 'office', - label: 'Office', + labelKey: 'sounds.places.office', src: '/sounds/places/office.mp3', }, { icon: , id: 'supermarket', - label: 'Supermarket', + labelKey: 'sounds.places.supermarket', src: '/sounds/places/supermarket.mp3', }, { icon: , id: 'carousel', - label: 'Carousel', + labelKey: 'sounds.places.carousel', src: '/sounds/places/carousel.mp3', }, { icon: , id: 'laboratory', - label: 'Laboratory', + labelKey: 'sounds.places.laboratory', src: '/sounds/places/laboratory.mp3', }, { icon: , id: 'laundry-room', - label: 'Laundry Room', + labelKey: 'sounds.places.laundry-room', src: '/sounds/places/laundry-room.mp3', }, { icon: , id: 'restaurant', - label: 'Restaurant', + labelKey: 'sounds.places.restaurant', src: '/sounds/places/restaurant.mp3', }, { icon: , id: 'library', - label: 'Library', + labelKey: 'sounds.places.library', src: '/sounds/places/library.mp3', }, ], - title: 'Places', + titleKey: 'sounds.places.title', }; diff --git a/src/data/sounds/rain.tsx b/src/data/sounds/rain.tsx index 0a23a69..a5637ae 100644 --- a/src/data/sounds/rain.tsx +++ b/src/data/sounds/rain.tsx @@ -13,55 +13,56 @@ import type { Category } from '../types'; export const rain: Category = { icon: , id: 'rain', + // 修改 sounds: [ { icon: , id: 'light-rain', - label: 'Light Rain', + labelKey: 'sounds.rain.light-rain', src: '/sounds/rain/light-rain.mp3', }, { icon: , id: 'heavy-rain', - label: 'Heavy Rain', + labelKey: 'sounds.rain.heavy-rain', src: '/sounds/rain/heavy-rain.mp3', }, { icon: , id: 'thunder', - label: 'Thunder', + labelKey: 'sounds.rain.thunder', src: '/sounds/rain/thunder.mp3', }, { icon: , id: 'rain-on-window', - label: 'Rain on Window', + labelKey: 'sounds.rain.rain-on-window', src: '/sounds/rain/rain-on-window.mp3', }, { icon: , id: 'rain-on-car-roof', - label: 'Rain on Car Roof', + labelKey: 'sounds.rain.rain-on-car-roof', src: '/sounds/rain/rain-on-car-roof.mp3', }, { icon: , id: 'rain-on-umbrella', - label: 'Rain on Umbrella', + labelKey: 'sounds.rain.rain-on-umbrella', src: '/sounds/rain/rain-on-umbrella.mp3', }, { icon: , id: 'rain-on-tent', - label: 'Rain on Tent', + labelKey: 'sounds.rain.rain-on-tent', src: '/sounds/rain/rain-on-tent.mp3', }, { icon: , id: 'rain-on-leaves', - label: 'Rain on Leaves', + labelKey: 'sounds.rain.rain-on-leaves', src: '/sounds/rain/rain-on-leaves.mp3', }, ], - title: 'Rain', + titleKey: 'sounds.rain.title', }; diff --git a/src/data/sounds/things.tsx b/src/data/sounds/things.tsx index f5bb59f..a87f3d8 100644 --- a/src/data/sounds/things.tsx +++ b/src/data/sounds/things.tsx @@ -17,103 +17,104 @@ import type { Category } from '../types'; export const things: Category = { icon: , id: 'things', + // 修改 sounds: [ { icon: , id: 'keyboard', - label: 'Keyboard', + labelKey: 'sounds.things.keyboard', src: '/sounds/things/keyboard.mp3', }, { icon: , id: 'typewriter', - label: 'Typewriter', + labelKey: 'sounds.things.typewriter', src: '/sounds/things/typewriter.mp3', }, { icon: , id: 'paper', - label: 'Paper', + labelKey: 'sounds.things.paper', src: '/sounds/things/paper.mp3', }, { icon: , id: 'clock', - label: 'Clock', + labelKey: 'sounds.things.clock', src: '/sounds/things/clock.mp3', }, { icon: , id: 'wind-chimes', - label: 'Wind Chimes', + labelKey: 'sounds.things.wind-chimes', src: '/sounds/things/wind-chimes.mp3', }, { icon: , id: 'singing-bowl', - label: 'Singing Bowl', + labelKey: 'sounds.things.singing-bowl', src: '/sounds/things/singing-bowl.mp3', }, { icon: , id: 'ceiling-fan', - label: 'Ceiling Fan', + labelKey: 'sounds.things.ceiling-fan', src: '/sounds/things/ceiling-fan.mp3', }, { icon: , id: 'dryer', - label: 'Dryer', + labelKey: 'sounds.things.dryer', src: '/sounds/things/dryer.mp3', }, { icon: , id: 'slide-projector', - label: 'Slide Projector', + labelKey: 'sounds.things.slide-projector', src: '/sounds/things/slide-projector.mp3', }, { icon: , id: 'boiling-water', - label: 'Boiling Water', + labelKey: 'sounds.things.boiling-water', src: '/sounds/things/boiling-water.mp3', }, { icon: , id: 'bubbles', - label: 'Bubbles', + labelKey: 'sounds.things.bubbles', src: '/sounds/things/bubbles.mp3', }, { icon: , id: 'tuning-radio', - label: 'Tuning Radio', + labelKey: 'sounds.things.tuning-radio', src: '/sounds/things/tuning-radio.mp3', }, { icon: , id: 'morse-code', - label: 'Morse Code', + labelKey: 'sounds.things.morse-code', src: '/sounds/things/morse-code.mp3', }, { icon: , id: 'washing-machine', - label: 'Washing Machine', + labelKey: 'sounds.things.washing-machine', src: '/sounds/things/washing-machine.mp3', }, { icon: , id: 'vinyl-effect', - label: 'Vinyl Effect', + labelKey: 'sounds.things.vinyl-effect', src: '/sounds/things/vinyl-effect.mp3', }, { icon: , id: 'windshield-wipers', - label: 'Windshield Wipers', + labelKey: 'sounds.things.windshield-wipers', src: '/sounds/things/windshield-wipers.mp3', }, ], - title: 'Things', + titleKey: 'sounds.things.title', }; diff --git a/src/data/sounds/transport.tsx b/src/data/sounds/transport.tsx index 5273332..e93ba4b 100644 --- a/src/data/sounds/transport.tsx +++ b/src/data/sounds/transport.tsx @@ -8,43 +8,44 @@ import type { Category } from '../types'; export const transport: Category = { icon: , id: 'transport', + // 修改 sounds: [ { icon: , id: 'train', - label: 'Train', + labelKey: 'sounds.transport.train', src: '/sounds/transport/train.mp3', }, { icon: , id: 'inside-a-train', - label: 'Inside a Train', + labelKey: 'sounds.transport.inside-a-train', src: '/sounds/transport/inside-a-train.mp3', }, { icon: , id: 'airplane', - label: 'Airplane', + labelKey: 'sounds.transport.airplane', src: '/sounds/transport/airplane.mp3', }, { icon: , id: 'submarine', - label: 'Submarine', + labelKey: 'sounds.transport.submarine', src: '/sounds/transport/submarine.mp3', }, { icon: , id: 'sailboat', - label: 'Sailboat', + labelKey: 'sounds.transport.sailboat', src: '/sounds/transport/sailboat.mp3', }, { icon: , id: 'rowing-boat', - label: 'Rowing Boat', + labelKey: 'sounds.transport.rowing-boat', src: '/sounds/transport/rowing-boat.mp3', }, ], - title: 'Transport', + titleKey: 'sounds.transport.title', }; diff --git a/src/data/sounds/urban.tsx b/src/data/sounds/urban.tsx index 59cf3eb..4022695 100644 --- a/src/data/sounds/urban.tsx +++ b/src/data/sounds/urban.tsx @@ -9,49 +9,50 @@ import type { Category } from '../types'; export const urban: Category = { icon: , id: 'urban', + // 修改 sounds: [ { icon: , id: 'highway', - label: 'Highway', + labelKey: 'sounds.urban.highway', src: '/sounds/urban/highway.mp3', }, { icon: , id: 'road', - label: 'Road', + labelKey: 'sounds.urban.road', src: '/sounds/urban/road.mp3', }, { icon: , id: 'ambulance-siren', - label: 'Ambulance Siren', + labelKey: 'sounds.urban.ambulance-siren', src: '/sounds/urban/ambulance-siren.mp3', }, { icon: , id: 'busy-street', - label: 'Busy Street', + labelKey: 'sounds.urban.busy-street', src: '/sounds/urban/busy-street.mp3', }, { icon: , id: 'crowd', - label: 'Crowd', + labelKey: 'sounds.urban.crowd', src: '/sounds/urban/crowd.mp3', }, { icon: , id: 'traffic', - label: 'Traffic', + labelKey: 'sounds.urban.traffic', src: '/sounds/urban/traffic.mp3', }, { icon: , id: 'fireworks', - label: 'Fireworks', + labelKey: 'sounds.urban.fireworks', src: '/sounds/urban/fireworks.mp3', }, ], - title: 'Urban', + titleKey: 'sounds.urban.title', }; diff --git a/src/data/types.d.ts b/src/data/types.d.ts index 515d516..d467e0a 100644 --- a/src/data/types.d.ts +++ b/src/data/types.d.ts @@ -1,7 +1,7 @@ export interface Sound { icon: React.ReactNode; id: string; - label: string; + labelKey: string; src: string; } @@ -11,7 +11,7 @@ export interface Category { icon: React.ReactNode; id: string; sounds: Sounds; - title: string; + titleKey: string; } export type Categories = Array; diff --git a/src/i18n.ts b/src/i18n.ts index 3935317..5b76db5 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -16,15 +16,32 @@ const resources = { i18n.use(initReactI18next).init({ debug: true, fallbackLng: 'en', - interpolation: { - escapeValue: false, - }, + interpolation: { escapeValue: false }, lng: 'en', - react: { - useSuspense: false, - }, + missingKeyHandler: (lngs, ns, key, fallbackValue, updateMissing, options) => { + const resolvedLng = lngs && lngs.length > 0 ? lngs[0] : i18n.language; + const value = i18n.getResource(resolvedLng, ns || 'translation', key); + if (typeof value === 'object' && value !== null) { + console.warn( + `i18next: Key '${key}' in namespace '${ + ns || 'translation' + }' resolved to an object, but expected a string. Check your t() call.`, + options, + ); + } else { + console.warn( + `i18next: Missing key '${key}' in namespace '${ + ns || 'translation' + }' for language(s) '${lngs.join(', ')}'.`, + ); + } + + return fallbackValue || key; + }, + react: { useSuspense: false }, resources, + returnObjects: true, }); export default i18n; diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 34e028a..52c7f52 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -8,14 +8,6 @@ "en": "English", "zh": "简体中文" }, - "languageSwitcher": { - "label": "Language selection" - }, - "common": { - "play": "Play", - "pause": "Pause", - "close": "Close" - }, "modals": { "reload": { "title": "New Content", @@ -23,13 +15,33 @@ "reloadButton": "Reload" } }, - "buttons": { - "playError": "Please first select a sound to play.", - "useMoodist": "Use Moodist" - }, - "categories": { - "favorites": "Favorites" + "play": { + "label": "Play", + "error": "Please select a sound to play." + }, + "favorite": { + "add": { + "aria-label": "Add {{label}} Sound to Favorites" + }, + "remove": { + "aria-label": "Remove {{label}} Sound from Favorites" + } + }, + "unselect": { + "tooltip": "Unselect all sounds.", + "aria-label": "Unselect All Sounds", + "restore": { + "tooltip": "Restore unselected sounds.", + "aria-label": "Restore Unselected Sounds" + } + }, + "pause": { + "label": "Pause" + }, + "use-moodist": { + "label": "Use Moodist" + } }, "about": { "section1": { @@ -48,5 +60,132 @@ "title": "Sounds for Every Moment", "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!" } + }, + "volume": { + "aria-label": "{{label}} sound volume" + }, + "sounds": { + "show-less": "Show Less", + "show-more": "Show More", + "aria-label": "{{name}} sound", + "favorites": { + "title": "Favorites" + }, + "animals": { + "title": "Animals", + "birds": "Birds", + "seagulls": "Seagulls", + "crickets": "Crickets", + "wolf": "Wolf", + "owl": "Owl", + "frog": "Frog", + "dog-barking": "Dog Barking", + "horse-galopp": "Horse Galopp", + "cat-purring": "Cat Purring", + "crows": "Crows", + "whale": "Whale", + "beehive": "Beehive", + "woodpecker": "Woodpecker", + "chickens": "Chickens", + "cows": "Cows", + "sheep": "Sheep" + }, + "binaural": { + "title": "Binaural Beats", + "binaural-delta": "Delta", + "binaural-theta": "Theta", + "binaural-alpha": "Alpha", + "binaural-beta": "Beta", + "binaural-gamma": "Gamma" + }, + "nature": { + "title": "Nature", + "river": "River", + "waves": "Waves", + "campfire": "Campfire", + "wind": "Wind", + "howling-wind": "Howling Wind", + "wind-in-trees": "Wind in Trees", + "waterfall": "Waterfall", + "walk-in-snow": "Walk in Snow", + "walk-on-leaves": "Walk on Leaves", + "walk-on-gravel": "Walk on Gravel", + "droplets": "Droplets", + "jungle": "Jungle" + }, + "noise": { + "title": "Noise", + "white-noise": "White Noise", + "pink-noise": "Pink Noise", + "brown-noise": "Brown Noise" + }, + "places": { + "title": "Places", + "cafe": "Cafe", + "airport": "Airport", + "church": "Church", + "temple": "Temple", + "construction-site": "Construction Site", + "underwater": "Underwater", + "crowded-bar": "Crowded Bar", + "night-village": "Night Village", + "subway-station": "Subway Station", + "office": "Office", + "supermarket": "Supermarket", + "carousel": "Carousel", + "laboratory": "Laboratory", + "laundry-room": "Laundry Room", + "restaurant": "Restaurant", + "library": "Library" + }, + "rain": { + "title": "Rain", + "light-rain": "Light Rain", + "heavy-rain": "Heavy Rain", + "thunder": "Thunder", + "rain-on-window": "Rain on Window", + "rain-on-car-roof": "Rain on Car Roof", + "rain-on-umbrella": "Rain on Umbrella", + "rain-on-tent": "Rain on Tent", + "rain-on-leaves": "Rain on Leaves" + }, + "things": { + "title": "Things", + "keyboard": "Keyboard", + "typewriter": "Typewriter", + "paper": "Paper", + "clock": "Clock", + "wind-chimes": "Wind Chimes", + "singing-bowl": "Singing Bowl", + "ceiling-fan": "Ceiling Fan", + "dryer": "Dryer", + "slide-projector": "Slide Projector", + "boiling-water": "Boiling Water", + "bubbles": "Bubbles", + "tuning-radio": "Tuning Radio", + "morse-code": "Morse Code", + "washing-machine": "Washing Machine", + "vinyl-effect": "Vinyl Effect", + "windshield-wipers": "Windshield Wipers" + }, + "transport": { + "title": "Transport", + "train": "Train", + "inside-a-train": "Inside a Train", + "airplane": "Airplane", + "submarine": "Submarine", + "sailboat": "Sailboat", + "rowing-boat": "Rowing Boat" + }, + "urban": { + "title": "Urban", + "highway": "Highway", + "road": "Road", + "ambulance-siren": "Ambulance Siren", + "busy-street": "Busy Street", + "crowd": "Crowd", + "traffic": "Traffic", + "fireworks": "Fireworks" + } } } diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index af78eb3..9394c11 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -8,14 +8,6 @@ "en": "English", "zh": "简体中文" }, - "languageSwitcher": { - "label": "语言选择" - }, - "common": { - "play": "播放", - "pause": "暂停", - "close": "关闭" - }, "modals": { "reload": { "title": "发现新内容", @@ -24,11 +16,32 @@ } }, "buttons": { - "playError": "请先选择要播放的声音。", - "useMoodist": "使用 Moodist" - }, - "categories": { - "favorites": "收藏夹" + "play": { + "label": "播放", + "error": "请先选择要播放的声音。" + }, + "favorite": { + "add": { + "aria-label": "将 {{label}} 声音添加到收藏夹" + }, + "remove": { + "aria-label": "从收藏夹移除 {{label}} 声音" + } + }, + "unselect": { + "tooltip": "取消选择所有声音。", + "aria-label": "取消选择所有声音", + "restore": { + "tooltip": "恢复上次选择的声音。", + "aria-label": "恢复上次选择的声音" + } + }, + "pause": { + "label": "暂停" + }, + "use-moodist": { + "label": "使用 Moodist" + } }, "about": { "section1": { @@ -47,5 +60,132 @@ "title": "适合每一刻的声音", "body": "无论您是想在漫长的一天后放松身心,在工作时提高注意力,还是哄自己进入宁静的睡眠,Moodist 都有完美的声音景观等着您。最棒的是?它完全免费且开源,因此您可以无任何附加条件地享受其益处。立即开始使用 Moodist,发现您宁静与专注的新港湾!" } + }, + "volume": { + "aria-label": "{{label}} 声音音量" + }, + "sounds": { + "show-less": "收起", + "show-more": "显示更多", + "aria-label": "{{name}} 声音", + "favorites": { + "title": "收藏夹" + }, + "animals": { + "title": "动物", + "birds": "鸟鸣", + "seagulls": "海鸥", + "crickets": "蟋蟀", + "wolf": "狼嚎", + "owl": "猫头鹰", + "frog": "蛙鸣", + "dog-barking": "狗吠", + "horse-galopp": "马蹄", + "cat-purring": "猫打呼", + "crows": "乌鸦", + "whale": "鲸鱼", + "beehive": "蜂巢", + "woodpecker": "啄木鸟", + "chickens": "鸡", + "cows": "牛", + "sheep": "羊" + }, + "binaural": { + "title": "双耳节拍", + "binaural-delta": "Delta 波", + "binaural-theta": "Theta 波", + "binaural-alpha": "Alpha 波", + "binaural-beta": "Beta 波", + "binaural-gamma": "Gamma 波" + }, + "nature": { + "title": "自然", + "river": "河流", + "waves": "海浪", + "campfire": "篝火", + "wind": "风声", + "howling-wind": "呼啸的风", + "wind-in-trees": "林间风声", + "waterfall": "瀑布", + "walk-in-snow": "雪地行走", + "walk-on-leaves": "踩树叶", + "walk-on-gravel": "踩砾石", + "droplets": "水滴", + "jungle": "丛林" + }, + "noise": { + "title": "噪音", + "white-noise": "白噪音", + "pink-noise": "粉红噪音", + "brown-noise": "布朗噪音" + }, + "places": { + "title": "场所", + "cafe": "咖啡馆", + "airport": "机场", + "church": "教堂", + "temple": "寺庙", + "construction-site": "建筑工地", + "underwater": "水下", + "crowded-bar": "拥挤的酒吧", + "night-village": "夜晚村庄", + "subway-station": "地铁站", + "office": "办公室", + "supermarket": "超市", + "carousel": "旋转木马", + "laboratory": "实验室", + "laundry-room": "洗衣房", + "restaurant": "餐厅", + "library": "图书馆" + }, + "rain": { + "title": "雨声", + "light-rain": "小雨", + "heavy-rain": "大雨", + "thunder": "雷声", + "rain-on-window": "雨打窗户", + "rain-on-car-roof": "雨打车顶", + "rain-on-umbrella": "雨打雨伞", + "rain-on-tent": "雨打帐篷", + "rain-on-leaves": "雨打树叶" + }, + "things": { + "title": "物品", + "keyboard": "键盘", + "typewriter": "打字机", + "paper": "纸张", + "clock": "时钟", + "wind-chimes": "风铃", + "singing-bowl": "颂钵", + "ceiling-fan": "吊扇", + "dryer": "烘干机", + "slide-projector": "幻灯机", + "boiling-water": "沸水", + "bubbles": "气泡", + "tuning-radio": "调谐收音机", + "morse-code": "摩尔斯电码", + "washing-machine": "洗衣机", + "vinyl-effect": "黑胶唱片", + "windshield-wipers": "雨刮器" + }, + "transport": { + "title": "交通", + "train": "火车", + "inside-a-train": "火车内部", + "airplane": "飞机", + "submarine": "潜水艇", + "sailboat": "帆船", + "rowing-boat": "划艇" + }, + "urban": { + "title": "城市", + "highway": "高速公路", + "road": "马路", + "ambulance-siren": "救护车警报", + "busy-street": "繁忙街道", + "crowd": "人群", + "traffic": "交通", + "fireworks": "烟花" + } } } From e6dd34e31dba05e68ac66664c9fdc27285b273d2 Mon Sep 17 00:00:00 2001 From: yozuru Date: Sun, 20 Apr 2025 04:30:45 +0800 Subject: [PATCH 05/12] feat: internationalize toolbar menu items and associated Modals --- src/components/modals/binaural/binaural.tsx | 74 ++++++--- src/components/modals/breathing/breathing.tsx | 6 +- .../modals/breathing/exercise/exercise.tsx | 33 ++-- .../modals/isochronic/isochronic.tsx | 67 ++++++-- src/components/modals/presets/list/list.tsx | 57 +++++-- src/components/modals/presets/new/new.tsx | 11 +- src/components/modals/presets/presets.tsx | 5 +- .../modals/share-link/share-link.tsx | 29 ++-- src/components/modals/shared/shared.tsx | 46 +++--- src/components/modals/shortcuts/shortcuts.tsx | 93 +++++------ .../modals/sleep-timer/sleep-timer.tsx | 80 +++++---- .../toolbar/menu/items/binaural.tsx | 10 +- .../toolbar/menu/items/breathing-exercise.tsx | 6 +- .../toolbar/menu/items/countdown.tsx | 6 +- src/components/toolbar/menu/items/donate.tsx | 8 +- .../toolbar/menu/items/isochronic.tsx | 12 +- src/components/toolbar/menu/items/notepad.tsx | 5 +- .../toolbar/menu/items/pomodoro.tsx | 6 +- src/components/toolbar/menu/items/presets.tsx | 5 +- src/components/toolbar/menu/items/share.tsx | 5 +- .../toolbar/menu/items/shortcuts.tsx | 6 +- src/components/toolbar/menu/items/shuffle.tsx | 6 +- .../toolbar/menu/items/sleep-timer.tsx | 5 +- src/components/toolbar/menu/items/source.tsx | 6 +- src/components/toolbar/menu/items/todo.tsx | 6 +- src/components/toolbar/menu/menu.tsx | 12 +- .../toolbox/countdown/countdown.tsx | 52 +++--- src/components/toolbox/notepad/notepad.tsx | 34 ++-- src/components/toolbox/pomodoro/pomodoro.tsx | 25 +-- .../toolbox/pomodoro/setting/setting.tsx | 24 +-- src/components/toolbox/todo/form/form.tsx | 7 +- src/components/toolbox/todo/todo.tsx | 7 +- .../toolbox/todo/todos/todo/todo.tsx | 18 ++- src/components/toolbox/todo/todos/todos.tsx | 6 +- src/locales/en/translation.json | 153 +++++++++++++++++- src/locales/zh/translation.json | 153 +++++++++++++++++- 36 files changed, 779 insertions(+), 305 deletions(-) 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')}