feat: integrate basic i18n support using i18next and react-i18next

This commit is contained in:
yozuru 2025-04-19 03:45:34 +08:00
parent b171793040
commit 09b400d234
No known key found for this signature in database
13 changed files with 338 additions and 74 deletions

View file

@ -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({

80
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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 = [
<Container tight>
{
paragraphs.map((paragraph, index) => (
paragraphKeys.map((key, index) => (
<div class="paragraph">
<div class="counter">
<span>0{index + 1}</span> / 0{paragraphs.length}
<span>0{index + 1}</span> / 0{paragraphKeys.length}
</div>
<h2 class="title">{paragraph.title}</h2>
<p class="body">{paragraph.body}</p>
<h2 class="title">{t(`${key}.title`)}</h2>
<p class="body">{t(`${key}.body`, { count: count })}</p>
</div>
))
}
<button class="button" id="use-moodist"> Use Moodist</button>
<button class="button" id="use-moodist">{t('buttons.useMoodist')}</button>
</Container>
</section>

View file

@ -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: <BiSolidHeart />,
id: 'favorites',
sounds: favoriteSounds as Array<Sound>,
title: 'Favorites',
sounds: favoriteSounds,
title: t('categories.favorites'),
});
}
return [...favorites, ...categories];
}, [favoriteSounds, categories]);
return [...favs, ...categories];
}, [favoriteSounds, categories, t]);
return (
<SnackbarProvider>
<StoreConsumer>
<MediaControls />
<Container>
<div id="app" />
<Buttons />
<Categories categories={allCategories} />
</Container>
<I18nextProvider i18n={i18n}>
<SnackbarProvider>
<StoreConsumer>
<MediaControls />
<Container>
<div id="app" />
<Buttons />
<Categories categories={allCategories} />
</Container>
<Toolbar />
<SharedModal />
</StoreConsumer>
</SnackbarProvider>
<Toolbar />
<SharedModal />
</StoreConsumer>
</SnackbarProvider>
</I18nextProvider>
);
}

View file

@ -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() {
<span aria-hidden="true">
<BiPause />
</span>{' '}
Pause
{t('common.pause')}
</>
) : (
<>
<span aria-hidden="true">
<BiPlay />
</span>{' '}
Play
{t('common.play')}
</>
)}
</button>

30
src/i18n.ts Normal file
View file

@ -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;

29
src/i18n/utils.ts Normal file
View file

@ -0,0 +1,29 @@
import i18n from '@/i18n';
import type { TFunction } from 'i18next';
/**
* (t function)
* i18next
* @param lng - ( 'en', 'zh')使退
* @returns Promise<TFunction> - Promise
*/
export async function getTranslator(lng?: string): Promise<TFunction> {
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 || {});
}

View file

@ -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 });
---
<!doctype html>
<html lang="en">
<html lang={currentLocale}>
<head>
<meta charset="UTF-8" />
<meta content="width=device-width" name="viewport" />
@ -33,7 +36,7 @@ const description =
<meta content={title} property="og:title" />
<meta content={description} property="og:description" />
<meta content="Moodist" property="og:site_name" />
<meta content={t('site.ogSiteName')} property="og:site_name" />
<meta content="https://moodist.app" property="og:url" />
<meta content="website" property="og:type" />
<meta content="https://moodist.app/og.png" property="og:image" />

View file

@ -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!"
}
}
}

View file

@ -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发现您宁静与专注的新港湾"
}
}
}

View file

@ -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';
---
<Layout title="Moodist: Ambient Sounds for Focus and Calm">
<Layout>
<Donate />
<Hero />
<App client:load />
<App client:load locale={currentLocale} />
<About />
<Source />
<Footer />

20
src/pages/zh/index.astro Normal file
View file

@ -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';
---
<Layout>
<Donate />
<Hero />
<App client:load locale={currentLocale} />
<About />
<Source />
<Footer />
</Layout>