From b69dcbb52c53f39dddd12303bb2355c5d02bf2f4 Mon Sep 17 00:00:00 2001 From: goddonebianu Date: Fri, 31 Oct 2025 12:28:28 +0800 Subject: [PATCH] add i18n --- messages/en-US/alphabet.json | 13 ++ messages/en-US/home.json | 33 +++ messages/en-US/memorize/choose.json | 4 + messages/en-US/memorize/edit.json | 6 + messages/en-US/memorize/main.json | 10 + messages/en-US/memorize/start.json | 7 + messages/en-US/navbar.json | 5 + messages/en-US/srt-player.json | 10 + messages/en-US/text-speaker.json | 5 + messages/en-US/translator.json | 12 ++ messages/zh-CN/alphabet.json | 13 ++ messages/zh-CN/home.json | 33 +++ messages/zh-CN/memorize/choose.json | 4 + messages/zh-CN/memorize/edit.json | 6 + messages/zh-CN/memorize/main.json | 10 + messages/zh-CN/memorize/start.json | 7 + messages/zh-CN/navbar.json | 5 + messages/zh-CN/srt-player.json | 10 + messages/zh-CN/text-speaker.json | 5 + messages/zh-CN/translator.json | 12 ++ next.config.ts | 4 +- package-lock.json | 149 ++++++++++++- package.json | 3 + public/changelog.txt | 1 + ...24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg | 1 + ...24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg | 1 + src/app/alphabet/MemoryCard.tsx | 6 +- src/app/alphabet/page.tsx | 16 +- src/app/layout.tsx | 5 +- src/app/memorize/Choose.tsx | 8 +- src/app/memorize/Edit.tsx | 12 +- src/app/memorize/Main.tsx | 22 +- src/app/memorize/Start.tsx | 14 +- src/app/page.tsx | 198 +++++++++--------- src/app/srt-player/UploadArea.tsx | 6 +- src/app/srt-player/VideoPlayer/VideoPanel.tsx | 16 +- src/app/text-speaker/SaveList.tsx | 4 +- src/app/text-speaker/page.tsx | 6 +- src/app/translator/page.tsx | 20 +- src/components/IconClick.tsx | 4 +- src/components/Navbar.tsx | 48 ++++- src/config/i18n.ts | 2 + src/config/images.ts | 2 + src/i18n/request.ts | 53 +++++ 44 files changed, 648 insertions(+), 163 deletions(-) create mode 100644 messages/en-US/alphabet.json create mode 100644 messages/en-US/home.json create mode 100644 messages/en-US/memorize/choose.json create mode 100644 messages/en-US/memorize/edit.json create mode 100644 messages/en-US/memorize/main.json create mode 100644 messages/en-US/memorize/start.json create mode 100644 messages/en-US/navbar.json create mode 100644 messages/en-US/srt-player.json create mode 100644 messages/en-US/text-speaker.json create mode 100644 messages/en-US/translator.json create mode 100644 messages/zh-CN/alphabet.json create mode 100644 messages/zh-CN/home.json create mode 100644 messages/zh-CN/memorize/choose.json create mode 100644 messages/zh-CN/memorize/edit.json create mode 100644 messages/zh-CN/memorize/main.json create mode 100644 messages/zh-CN/memorize/start.json create mode 100644 messages/zh-CN/navbar.json create mode 100644 messages/zh-CN/srt-player.json create mode 100644 messages/zh-CN/text-speaker.json create mode 100644 messages/zh-CN/translator.json create mode 100644 public/images/language_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg create mode 100644 public/images/language_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg create mode 100644 src/config/i18n.ts create mode 100644 src/i18n/request.ts diff --git a/messages/en-US/alphabet.json b/messages/en-US/alphabet.json new file mode 100644 index 0000000..eb9e46e --- /dev/null +++ b/messages/en-US/alphabet.json @@ -0,0 +1,13 @@ +{ + "chooseCharacters": "Please select the characters you want to learn", + "japanese": "Japanese Kana", + "english": "English Alphabet", + "uyghur": "Uyghur Alphabet", + "esperanto": "Esperanto Alphabet", + "loading": "Loading...", + "loadFailed": "Loading failed, please try again", + "hideLetter": "Hide Letter", + "showLetter": "Show Letter", + "hideIPA": "Hide IPA", + "showIPA": "Show IPA" +} diff --git a/messages/en-US/home.json b/messages/en-US/home.json new file mode 100644 index 0000000..7b6e68e --- /dev/null +++ b/messages/en-US/home.json @@ -0,0 +1,33 @@ +{ + "title": "Learn Languages", + "description": "Here is a very useful website to help you learn almost every language in the world, including constructed ones.", + "explore": "Explore", + "fortune": { + "quote": "Stay hungry, stay foolish.", + "author": "— Steve Jobs" + }, + "translator": { + "name": "Translator", + "description": "Translate to any language and annotate with International Phonetic Alphabet (IPA)" + }, + "textSpeaker": { + "name": "Text Speaker", + "description": "Recognize and read text aloud, supports loop playback and speed adjustment" + }, + "srtPlayer": { + "name": "SRT Video Player", + "description": "Play videos sentence by sentence based on SRT subtitle files to mimic native speaker pronunciation" + }, + "alphabet": { + "name": "Memorize Alphabet", + "description": "Start learning a new language from the alphabet" + }, + "memorize": { + "name": "Memorize Words", + "description": "Language A to Language B, Language B to Language A, supports dictation" + }, + "moreFeatures": { + "name": "More Features", + "description": "Under development, stay tuned" + } +} diff --git a/messages/en-US/memorize/choose.json b/messages/en-US/memorize/choose.json new file mode 100644 index 0000000..7ac3ad8 --- /dev/null +++ b/messages/en-US/memorize/choose.json @@ -0,0 +1,4 @@ +{ + "back": "Back", + "choose": "Choose" +} diff --git a/messages/en-US/memorize/edit.json b/messages/en-US/memorize/edit.json new file mode 100644 index 0000000..ea5d32f --- /dev/null +++ b/messages/en-US/memorize/edit.json @@ -0,0 +1,6 @@ +{ + "back": "Back", + "save": "Save Word Pairs", + "locale1": "Locale 1", + "locale2": "Locale 2" +} diff --git a/messages/en-US/memorize/main.json b/messages/en-US/memorize/main.json new file mode 100644 index 0000000..46b9b49 --- /dev/null +++ b/messages/en-US/memorize/main.json @@ -0,0 +1,10 @@ +{ + "title": "Memorize", + "locale1": "Your selected locale 1 is {locale}", + "locale2": "Your selected locale 2 is {locale}", + "total": "There are {total} word pairs in total", + "start": "Start", + "import": "Import", + "save": "Save", + "edit": "Edit" +} diff --git a/messages/en-US/memorize/start.json b/messages/en-US/memorize/start.json new file mode 100644 index 0000000..aea29cd --- /dev/null +++ b/messages/en-US/memorize/start.json @@ -0,0 +1,7 @@ +{ + "show": "Show", + "reverse": "Reverse", + "dictation": "Dictation", + "back": "Back", + "next": "Next" +} diff --git a/messages/en-US/navbar.json b/messages/en-US/navbar.json new file mode 100644 index 0000000..0600dda --- /dev/null +++ b/messages/en-US/navbar.json @@ -0,0 +1,5 @@ +{ + "title": "LL", + "about": "About", + "sourceCode": "GitHub" +} diff --git a/messages/en-US/srt-player.json b/messages/en-US/srt-player.json new file mode 100644 index 0000000..4a22aef --- /dev/null +++ b/messages/en-US/srt-player.json @@ -0,0 +1,10 @@ +{ + "uploadVideo": "Upload Video", + "uploadSubtitle": "Upload Subtitle", + "pause": "Pause", + "play": "Play", + "previous": "Previous", + "next": "Next", + "restart": "Restart", + "autoPause": "Auto Pause ({enabled})" +} diff --git a/messages/en-US/text-speaker.json b/messages/en-US/text-speaker.json new file mode 100644 index 0000000..fbb924d --- /dev/null +++ b/messages/en-US/text-speaker.json @@ -0,0 +1,5 @@ +{ + "generateIPA": "Generate IPA", + "viewSavedItems": "View Saved Items", + "confirmDeleteAll": "Are you sure you want to delete everything? (Y/N)" +} diff --git a/messages/en-US/translator.json b/messages/en-US/translator.json new file mode 100644 index 0000000..07d45ef --- /dev/null +++ b/messages/en-US/translator.json @@ -0,0 +1,12 @@ +{ + "detectLanguage": "detect language", + "generateIPA": "generate ipa", + "translateInto": "translate into", + "chinese": "Chinese", + "english": "English", + "italian": "Italian", + "other": "Other", + "translating": "translating...", + "translate": "translate", + "inputLanguage": "Input a language." +} diff --git a/messages/zh-CN/alphabet.json b/messages/zh-CN/alphabet.json new file mode 100644 index 0000000..670343d --- /dev/null +++ b/messages/zh-CN/alphabet.json @@ -0,0 +1,13 @@ +{ + "chooseCharacters": "请选择您想学习的字符", + "japanese": "日语假名", + "english": "英文字母", + "uyghur": "维吾尔字母", + "esperanto": "世界语字母", + "loading": "加载中...", + "loadFailed": "加载失败,请重试", + "hideLetter": "隐藏字母", + "showLetter": "显示字母", + "hideIPA": "隐藏IPA", + "showIPA": "显示IPA" +} diff --git a/messages/zh-CN/home.json b/messages/zh-CN/home.json new file mode 100644 index 0000000..00e6879 --- /dev/null +++ b/messages/zh-CN/home.json @@ -0,0 +1,33 @@ +{ + "title": "学语言", + "description": "这里是一个非常有用的网站,可以帮助你学习世界上几乎每一种语言,包括人造语言。", + "explore": "探索网站", + "fortune": { + "quote": "求知若饥,虚心若愚。", + "author": "—— 史蒂夫·乔布斯" + }, + "translator": { + "name": "翻译器", + "description": "翻译到任何语言,并标注国际音标(IPA)" + }, + "textSpeaker": { + "name": "朗读器", + "description": "识别并朗读文本,支持循环朗读、朗读速度调节" + }, + "srtPlayer": { + "name": "逐句视频播放器", + "description": "基于SRT字幕文件,逐句播放视频以模仿母语者的发音" + }, + "alphabet": { + "name": "背字母", + "description": "从字母表开始新语言的学习" + }, + "memorize": { + "name": "背单词", + "description": "语言A到语言B,语言B到语言A,支持听写" + }, + "moreFeatures": { + "name": "更多功能", + "description": "开发中,敬请期待" + } +} diff --git a/messages/zh-CN/memorize/choose.json b/messages/zh-CN/memorize/choose.json new file mode 100644 index 0000000..e79fa98 --- /dev/null +++ b/messages/zh-CN/memorize/choose.json @@ -0,0 +1,4 @@ +{ + "back": "返回", + "choose": "选择" +} diff --git a/messages/zh-CN/memorize/edit.json b/messages/zh-CN/memorize/edit.json new file mode 100644 index 0000000..d8dea51 --- /dev/null +++ b/messages/zh-CN/memorize/edit.json @@ -0,0 +1,6 @@ +{ + "back": "返回", + "save": "保存单词对", + "locale1": "区域1", + "locale2": "区域2" +} diff --git a/messages/zh-CN/memorize/main.json b/messages/zh-CN/memorize/main.json new file mode 100644 index 0000000..32d8c37 --- /dev/null +++ b/messages/zh-CN/memorize/main.json @@ -0,0 +1,10 @@ +{ + "title": "记忆", + "locale1": "您选择的区域一是{locale}", + "locale2": "您选择的区域二是{locale}", + "total": "总计有{total}个单词对", + "start": "开始", + "import": "导入", + "save": "保存", + "edit": "编辑" +} \ No newline at end of file diff --git a/messages/zh-CN/memorize/start.json b/messages/zh-CN/memorize/start.json new file mode 100644 index 0000000..e11d1a4 --- /dev/null +++ b/messages/zh-CN/memorize/start.json @@ -0,0 +1,7 @@ +{ + "show": "显示", + "reverse": "反向", + "dictation": "听写", + "back": "返回", + "next": "下个" +} diff --git a/messages/zh-CN/navbar.json b/messages/zh-CN/navbar.json new file mode 100644 index 0000000..f261619 --- /dev/null +++ b/messages/zh-CN/navbar.json @@ -0,0 +1,5 @@ +{ + "title": "学语言", + "about": "关于", + "sourceCode": "源码" +} diff --git a/messages/zh-CN/srt-player.json b/messages/zh-CN/srt-player.json new file mode 100644 index 0000000..82c2e63 --- /dev/null +++ b/messages/zh-CN/srt-player.json @@ -0,0 +1,10 @@ +{ + "uploadVideo": "上传视频", + "uploadSubtitle": "上传字幕", + "pause": "暂停", + "play": "播放", + "previous": "上句", + "next": "下句", + "restart": "句首", + "autoPause": "自动暂停({enabled})" +} diff --git a/messages/zh-CN/text-speaker.json b/messages/zh-CN/text-speaker.json new file mode 100644 index 0000000..04caa6a --- /dev/null +++ b/messages/zh-CN/text-speaker.json @@ -0,0 +1,5 @@ +{ + "generateIPA": "生成IPA", + "viewSavedItems": "查看保存项", + "confirmDeleteAll": "确定删光吗?(Y/N)" +} diff --git a/messages/zh-CN/translator.json b/messages/zh-CN/translator.json new file mode 100644 index 0000000..642bb71 --- /dev/null +++ b/messages/zh-CN/translator.json @@ -0,0 +1,12 @@ +{ + "detectLanguage": "检测语言", + "generateIPA": "生成国际音标", + "translateInto": "翻译为", + "chinese": "中文", + "english": "英文", + "italian": "意大利语", + "other": "其他", + "translating": "翻译中...", + "translate": "翻译", + "inputLanguage": "请输入语言。" +} diff --git a/next.config.ts b/next.config.ts index 12a3588..ca2fd69 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,8 +1,10 @@ import type { NextConfig } from "next"; +import createNextIntlPlugin from "next-intl/plugin"; const nextConfig: NextConfig = { /* config options here */ output: "standalone", allowedDevOrigins: ["192.168.3.65", "192.168.3.66"], }; -export default nextConfig; +const withNextIntl = createNextIntlPlugin(); +export default withNextIntl(nextConfig); diff --git a/package-lock.json b/package-lock.json index bd7bf23..1cc272e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,12 @@ "version": "0.1.0", "license": "GPL-3.0-only", "dependencies": { + "@formatjs/intl-localematcher": "^0.6.2", "edge-tts-universal": "^1.3.2", "motion": "^12.23.24", + "negotiator": "^1.0.0", "next": "15.5.3", + "next-intl": "^4.4.0", "react": "19.1.0", "react-dom": "19.1.0", "zod": "^3.25.76" @@ -2452,6 +2455,57 @@ "excpretty": "build/cli.js" } }, + "node_modules/@formatjs/ecma402-abstract": { + "version": "2.3.6", + "resolved": "https://mirrors.cloud.tencent.com/npm/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", + "integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/intl-localematcher": "0.6.2", + "decimal.js": "^10.4.3", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/fast-memoize": { + "version": "2.2.7", + "resolved": "https://mirrors.cloud.tencent.com/npm/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz", + "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "2.11.4", + "resolved": "https://mirrors.cloud.tencent.com/npm/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz", + "integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "@formatjs/icu-skeleton-parser": "1.8.16", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "1.8.16", + "resolved": "https://mirrors.cloud.tencent.com/npm/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz", + "integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==", + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.6.2", + "resolved": "https://mirrors.cloud.tencent.com/npm/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz", + "integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://mirrors.cloud.tencent.com/npm/@humanfs/core/-/core-0.19.1.tgz", @@ -3876,6 +3930,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@schummar/icu-type-parser": { + "version": "1.21.5", + "resolved": "https://mirrors.cloud.tencent.com/npm/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", + "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", + "license": "MIT" + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://mirrors.cloud.tencent.com/npm/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -5019,6 +5079,17 @@ "node": ">= 0.6" } }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://mirrors.cloud.tencent.com/npm/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://mirrors.cloud.tencent.com/npm/acorn/-/acorn-8.15.0.tgz", @@ -6528,6 +6599,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://mirrors.cloud.tencent.com/npm/deep-extend/-/deep-extend-0.6.0.tgz", @@ -8462,6 +8539,18 @@ "node": ">= 0.4" } }, + "node_modules/intl-messageformat": { + "version": "10.7.18", + "resolved": "https://mirrors.cloud.tencent.com/npm/intl-messageformat/-/intl-messageformat-10.7.18.tgz", + "integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==", + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/icu-messageformat-parser": "2.11.4", + "tslib": "^2.8.0" + } + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://mirrors.cloud.tencent.com/npm/invariant/-/invariant-2.2.4.tgz", @@ -11483,12 +11572,10 @@ "license": "MIT" }, "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://mirrors.cloud.tencent.com/npm/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "version": "1.0.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", - "optional": true, - "peer": true, "engines": { "node": ">= 0.6" } @@ -11553,6 +11640,42 @@ } } }, + "node_modules/next-intl": { + "version": "4.4.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/next-intl/-/next-intl-4.4.0.tgz", + "integrity": "sha512-QHqnP9V9Pe7Tn0PdVQ7u1Z8k9yCkW5SJKeRy2g5gxzhSt/C01y3B9qNxuj3Fsmup/yreIHe6osxU6sFa+9WIkQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/intl-localematcher": "^0.5.4", + "negotiator": "^1.0.0", + "use-intl": "^4.4.0" + }, + "peerDependencies": { + "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0", + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/next-intl/node_modules/@formatjs/intl-localematcher": { + "version": "0.5.10", + "resolved": "https://mirrors.cloud.tencent.com/npm/@formatjs/intl-localematcher/-/intl-localematcher-0.5.10.tgz", + "integrity": "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==", + "license": "MIT", + "dependencies": { + "tslib": "2" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://mirrors.cloud.tencent.com/npm/postcss/-/postcss-8.4.31.tgz", @@ -14427,7 +14550,7 @@ "version": "5.9.3", "resolved": "https://mirrors.cloud.tencent.com/npm/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -14634,6 +14757,20 @@ "punycode": "^2.1.0" } }, + "node_modules/use-intl": { + "version": "4.4.0", + "resolved": "https://mirrors.cloud.tencent.com/npm/use-intl/-/use-intl-4.4.0.tgz", + "integrity": "sha512-smFekJWtokDRBLC5/ZumlBREzdXOkw06+56Ifj2uRe9266Mk+yWQm2PcJO+EwlOE5sHIXHixOTzN6V8E0RGUbw==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "^2.2.0", + "@schummar/icu-type-parser": "1.21.5", + "intl-messageformat": "^10.5.14" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://mirrors.cloud.tencent.com/npm/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/package.json b/package.json index 19b5ee7..d149232 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,12 @@ "lint": "eslint" }, "dependencies": { + "@formatjs/intl-localematcher": "^0.6.2", "edge-tts-universal": "^1.3.2", "motion": "^12.23.24", + "negotiator": "^1.0.0", "next": "15.5.3", + "next-intl": "^4.4.0", "react": "19.1.0", "react-dom": "19.1.0", "zod": "^3.25.76" diff --git a/public/changelog.txt b/public/changelog.txt index 668037c..0fdf6f2 100644 --- a/public/changelog.txt +++ b/public/changelog.txt @@ -1,3 +1,4 @@ +2025.10.31 添加国际化支持 2025.10.30 添加背单词功能 2025.10.12 添加朗读器本地保存功能 2025.10.09 新增记忆字母表功能 diff --git a/public/images/language_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg b/public/images/language_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg new file mode 100644 index 0000000..dbbb18a --- /dev/null +++ b/public/images/language_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/language_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg b/public/images/language_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg new file mode 100644 index 0000000..c5c36cc --- /dev/null +++ b/public/images/language_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/app/alphabet/MemoryCard.tsx b/src/app/alphabet/MemoryCard.tsx index 66bdbb5..990f15a 100644 --- a/src/app/alphabet/MemoryCard.tsx +++ b/src/app/alphabet/MemoryCard.tsx @@ -9,6 +9,7 @@ import { useEffect, useState, } from "react"; +import { useTranslations } from "next-intl"; export default function MemoryCard({ alphabet, @@ -17,6 +18,7 @@ export default function MemoryCard({ alphabet: Letter[]; setChosenAlphabet: Dispatch>; }) { + const t = useTranslations("alphabet"); const [index, setIndex] = useState( Math.floor(Math.random() * alphabet.length), ); @@ -79,7 +81,7 @@ export default function MemoryCard({ setLetterDisplay(!letterDisplay); }} > - {letterDisplay ? "隐藏字母" : "显示字母"} + {letterDisplay ? t("hideLetter") : t("showLetter")} - {ipaDisplay ? "隐藏IPA" : "显示IPA"} + {ipaDisplay ? t("hideIPA") : t("showIPA")} ) : ( diff --git a/src/app/alphabet/page.tsx b/src/app/alphabet/page.tsx index 715553e..25dee3a 100644 --- a/src/app/alphabet/page.tsx +++ b/src/app/alphabet/page.tsx @@ -5,8 +5,10 @@ import { Letter, SupportedAlphabets } from "@/interfaces"; import { useEffect, useState } from "react"; import MemoryCard from "./MemoryCard"; import { Navbar } from "@/components/Navbar"; +import { useTranslations } from "next-intl"; export default function Alphabet() { + const t = useTranslations("alphabet"); const [chosenAlphabet, setChosenAlphabet] = useState(null); const [alphabetData, setAlphabetData] = useState< @@ -58,29 +60,29 @@ export default function Alphabet() { <>
- 请选择您想学习的字符 + {t("chooseCharacters")}
setChosenAlphabet("japanese")}> - 日语假名 + {t("japanese")} setChosenAlphabet("english")}> - 英文字母 + {t("english")} setChosenAlphabet("uyghur")}> - 维吾尔字母 + {t("uyghur")} setChosenAlphabet("esperanto")}> - 世界语字母 + {t("esperanto")}
); if (loadingState === "loading") { - return "加载中..."; + return t("loading"); } if (loadingState === "error") { - return "加载失败,请重试"; + return t("loadFailed"); } if (loadingState === "success" && alphabetData[chosenAlphabet]) { return ( diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2af812a..cf04445 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import type { Viewport } from "next"; +import { NextIntlClientProvider } from "next-intl"; export const viewport: Viewport = { width: "device-width", @@ -23,7 +24,7 @@ export const metadata: Metadata = { description: "A Website to Learn Languages", }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; @@ -33,7 +34,7 @@ export default function RootLayout({ - {children} + {children} ); diff --git a/src/app/memorize/Choose.tsx b/src/app/memorize/Choose.tsx index a0cacf7..aeba7a1 100644 --- a/src/app/memorize/Choose.tsx +++ b/src/app/memorize/Choose.tsx @@ -5,6 +5,7 @@ import { LOCALES } from "@/config/locales"; import { Dispatch, SetStateAction, useState } from "react"; import { WordData } from "@/interfaces"; import NavbarCenterWrapper from "@/components/NavbarCenterWrapper"; +import { useTranslations } from "next-intl"; interface Props { setEditPage: Dispatch>; @@ -19,6 +20,7 @@ export default function Choose({ setWordData, localeKey, }: Props) { + const t = useTranslations("memorize.choose"); const [chosenLocale, setChosenLocale] = useState< (typeof LOCALES)[number] | null >(null); @@ -53,8 +55,10 @@ export default function Choose({
- Choose - setEditPage("edit")}>Back + {t("choose")} + setEditPage("edit")}> + {t("back")} +
diff --git a/src/app/memorize/Edit.tsx b/src/app/memorize/Edit.tsx index 926f3d3..1642b18 100644 --- a/src/app/memorize/Edit.tsx +++ b/src/app/memorize/Edit.tsx @@ -6,6 +6,7 @@ import DarkButton from "@/components/buttons/DarkButton"; import { WordData } from "@/interfaces"; import Choose from "./Choose"; import NavbarCenterWrapper from "@/components/NavbarCenterWrapper"; +import { useTranslations } from "next-intl"; interface Props { setPage: Dispatch>; @@ -14,6 +15,7 @@ interface Props { } export default function Edit({ setPage, wordData, setWordData }: Props) { + const t = useTranslations("memorize.edit"); const textareaRef = useRef(null); const [localeKey, setLocaleKey] = useState<0 | 1>(0); const [editPage, setEditPage] = useState<"choose" | "edit">("edit"); @@ -65,15 +67,17 @@ export default function Edit({ setPage, wordData, setWordData }: Props) { >
- setPage("main")}>Back - Save Pairs + setPage("main")}> + {t("back")} + + {t("save")} { setLocaleKey(0); setEditPage("choose"); }} > - Locale 1 + {t("locale1")} { @@ -81,7 +85,7 @@ export default function Edit({ setPage, wordData, setWordData }: Props) { setEditPage("choose"); }} > - Locale 2 + {t("locale2")}
diff --git a/src/app/memorize/Main.tsx b/src/app/memorize/Main.tsx index 299cfa9..64c0c11 100644 --- a/src/app/memorize/Main.tsx +++ b/src/app/memorize/Main.tsx @@ -5,6 +5,7 @@ import { WordData, WordDataSchema } from "@/interfaces"; import { Dispatch, SetStateAction } from "react"; import useFileUpload from "@/hooks/useFileUpload"; import NavbarCenterWrapper from "@/components/NavbarCenterWrapper"; +import { useTranslations } from "next-intl"; interface Props { wordData: WordData; @@ -17,6 +18,7 @@ export default function Main({ setWordData, setPage: setPage, }: Props) { + const t = useTranslations("memorize.main"); const { upload, inputRef } = useFileUpload(async (file) => { try { const obj = JSON.parse(await file.text()); @@ -44,21 +46,25 @@ export default function Main({

- Memorize + {t("title")}

-

locale 1 {wordData.locales[0]}

-

locale 2 {wordData.locales[1]}

-

total {wordData.wordPairs.length} word pairs

+

{t("locale1", { locale: wordData.locales[0] })}

+

{t("locale2", { locale: wordData.locales[1] })}

+

{t("total", { total: wordData.wordPairs.length })}

- setPage("start")}>开始 - 导入 - 保存 - setPage("edit")}>编辑 + setPage("start")}> + {t("start")} + + {t("import")} + {t("save")} + setPage("edit")}> + {t("edit")} +
diff --git a/src/app/memorize/Start.tsx b/src/app/memorize/Start.tsx index 5901954..28a40d4 100644 --- a/src/app/memorize/Start.tsx +++ b/src/app/memorize/Start.tsx @@ -5,6 +5,7 @@ import { useAudioPlayer } from "@/hooks/useAudioPlayer"; import { getTTSAudioUrl } from "@/utils"; import { VOICES } from "@/config/locales"; import NavbarCenterWrapper from "@/components/NavbarCenterWrapper"; +import { useTranslations } from "next-intl"; interface WordBoardProps { children: React.ReactNode; @@ -22,6 +23,7 @@ interface Props { setPage: Dispatch>; } export default function Start({ wordData, setPage }: Props) { + const t = useTranslations("memorize.start"); const [display, setDisplay] = useState<"ask" | "show">("ask"); const [wordPair, setWordPair] = useState( wordData.wordPairs[Math.floor(Math.random() * wordData.wordPairs.length)], @@ -71,23 +73,25 @@ export default function Start({ wordData, setPage }: Props) {
{display === "ask" ? ( - Show + {t("show")} ) : ( - Next + {t("next")} )} setReverse(!reverse)} selected={reverse} > - Reverse + {t("reverse")} setDictation(!dictation)} selected={dictation} > - Dictation + {t("dictation")} + + setPage("main")}> + {t("back")} - setPage("main")}>Exit
diff --git a/src/app/page.tsx b/src/app/page.tsx index 8080512..dfcddc4 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,108 +1,104 @@ import { Navbar } from "@/components/Navbar"; +import { useTranslations } from "next-intl"; import Link from "next/link"; -function TopArea() { - return ( -
-
-

Learn Languages

-

- Here is a very useful website to help you learn almost every language - in the world, including constructed ones. -

-
-
- ); -} - -interface LinkAreaProps { - href: string; - name: string; - description: string; - color: string; -} -function LinkArea({ href, name, description, color }: LinkAreaProps) { - return ( - -
-

{name}

-

{description}

-
- - ); -} - -function LinkGrid() { - return ( -
- - - {/* */} - - - - -
- ); -} - -function Fortune() { - return ( -
-

Stay hungry, stay foolish.

- —— Steve Jobs -
- ); -} - -function Explore() { - return ( -
- 探索网站 -
-
- ); -} - export default function Home() { + const t = useTranslations("home"); + function TopArea() { + return ( +
+
+

+ {t("title")} +

+

{t("description")}

+
+
+ ); + } + interface LinkAreaProps { + href: string; + name: string; + description: string; + color: string; + } + function LinkArea({ href, name, description, color }: LinkAreaProps) { + return ( + +
+

{name}

+

{description}

+
+ + ); + } + function LinkGrid() { + return ( +
+ + + {/* */} + + + + +
+ ); + } + function Fortune() { + return ( +
+

{t("fortune.quote")}

+ {t("fortune.author")} +
+ ); + } + function Explore() { + return ( +
+ {t("explore")} +
+
+ ); + } return ( <> diff --git a/src/app/srt-player/UploadArea.tsx b/src/app/srt-player/UploadArea.tsx index edd3043..9bcd7fb 100644 --- a/src/app/srt-player/UploadArea.tsx +++ b/src/app/srt-player/UploadArea.tsx @@ -1,5 +1,6 @@ import LightButton from "@/components/buttons/LightButton"; import { useRef } from "react"; +import { useTranslations } from "next-intl"; export default function UploadArea({ setVideoUrl, @@ -8,6 +9,7 @@ export default function UploadArea({ setVideoUrl: (url: string | null) => void; setSrtUrl: (url: string | null) => void; }) { + const t = useTranslations("srt-player"); const inputRef = useRef(null); const uploadVideo = () => { @@ -38,8 +40,8 @@ export default function UploadArea({ }; return (
- 上传视频 - 上传字幕 + {t("uploadVideo")} + {t("uploadSubtitle")}
); diff --git a/src/app/srt-player/VideoPlayer/VideoPanel.tsx b/src/app/srt-player/VideoPlayer/VideoPanel.tsx index d1fbd08..701518e 100644 --- a/src/app/srt-player/VideoPlayer/VideoPanel.tsx +++ b/src/app/srt-player/VideoPlayer/VideoPanel.tsx @@ -2,6 +2,7 @@ import { useState, useRef, forwardRef, useEffect, useCallback } from "react"; import SubtitleDisplay from "./SubtitleDisplay"; import LightButton from "@/components/buttons/LightButton"; import { getIndex, parseSrt, getNearistIndex } from "../subtitle"; +import { useTranslations } from "next-intl"; type VideoPanelProps = { videoUrl: string | null; @@ -10,6 +11,7 @@ type VideoPanelProps = { const VideoPanel = forwardRef( ({ videoUrl, srtUrl }, videoRef) => { + const t = useTranslations("srt-player"); videoRef = videoRef as React.RefObject; const [isPlaying, setIsPlaying] = useState(false); const [srtLength, setSrtLength] = useState(0); @@ -185,14 +187,14 @@ const VideoPanel = forwardRef(
- {isPlaying ? "暂停" : "播放"} + {isPlaying ? t("pause") : t("play")} + + {t("previous")} + {t("next")} + {t("restart")} + + {t("autoPause", { enabled: autoPause ? "Yes" : "No" })} - 上句 - 下句 - 句首 - {`自动暂停(${autoPause ? "是" : "否"})`}
; @@ -47,6 +48,7 @@ interface SaveListProps { handleUse: (item: z.infer) => void; } export default function SaveList({ show = false, handleUse }: SaveListProps) { + const t = useTranslations("text-speaker"); const [data, setData] = useState(getTextSpeakerData()); const handleDel = (item: z.infer) => { const current_data = getTextSpeakerData(); @@ -61,7 +63,7 @@ export default function SaveList({ show = false, handleUse }: SaveListProps) { setData(getTextSpeakerData()); }; const handleDeleteAll = () => { - const yesorno = prompt("确定删光吗?(Y/N)")?.trim(); + const yesorno = prompt(t("confirmDeleteAll"))?.trim(); if (yesorno && (yesorno === "Y" || yesorno === "y")) { setTextSpeakerData([]); refresh(); diff --git a/src/app/text-speaker/page.tsx b/src/app/text-speaker/page.tsx index 4e18efc..1d3757d 100644 --- a/src/app/text-speaker/page.tsx +++ b/src/app/text-speaker/page.tsx @@ -15,8 +15,10 @@ import { TextSpeakerItemSchema } from "@/interfaces"; import z from "zod"; import { Navbar } from "@/components/Navbar"; import { VOICES } from "@/config/locales"; +import { useTranslations } from "next-intl"; export default function TextSpeaker() { + const t = useTranslations("text-speaker"); const textareaRef = useRef(null); const [showSpeedAdjust, setShowSpeedAdjust] = useState(false); const [showSaveList, setShowSaveList] = useState(false); @@ -320,7 +322,7 @@ export default function TextSpeaker() { selected={ipaEnabled} onClick={() => setIPAEnabled(!ipaEnabled)} > - 生成IPA + {t("generateIPA")} { @@ -328,7 +330,7 @@ export default function TextSpeaker() { }} selected={showSaveList} > - 查看保存项 + {t("viewSavedItems")} diff --git a/src/app/translator/page.tsx b/src/app/translator/page.tsx index d2f08e1..7c12f54 100644 --- a/src/app/translator/page.tsx +++ b/src/app/translator/page.tsx @@ -8,8 +8,10 @@ import IMAGES from "@/config/images"; import { getTTSAudioUrl } from "@/utils"; import { Navbar } from "@/components/Navbar"; import { VOICES } from "@/config/locales"; +import { useTranslations } from "next-intl"; export default function Translator() { + const t = useTranslations("translator"); const [ipaEnabled, setIPAEnabled] = useState(true); const [targetLang, setTargetLang] = useState("Chinese"); @@ -25,7 +27,7 @@ export default function Translator() { const tl = ["Chinese", "English", "Italian"]; const inputLanguage = () => { - const lang = prompt("Input a language.")?.trim(); + const lang = prompt(t("inputLanguage"))?.trim(); if (lang) { setTargetLang(lang); } @@ -210,12 +212,12 @@ export default function Translator() {
- detect language + {t("detectLanguage")} setIPAEnabled(!ipaEnabled)} > - generate ipa + {t("generateIPA")}
@@ -242,14 +244,14 @@ export default function Translator() {
- translate into + {t("translateInto")} { setTargetLang("Chinese"); }} selected={targetLang === "Chinese"} > - Chinese + {t("chinese")} { @@ -257,7 +259,7 @@ export default function Translator() { }} selected={targetLang === "English"} > - English + {t("english")} { @@ -265,13 +267,13 @@ export default function Translator() { }} selected={targetLang === "Italian"} > - Italian + {t("italian")} - {"Other" + (tl.includes(targetLang) ? "" : ": " + targetLang)} + {t("other") + (tl.includes(targetLang) ? "" : ": " + targetLang)}
@@ -282,7 +284,7 @@ export default function Translator() { onClick={translate} className={`duration-150 ease-in text-xl font-extrabold border rounded-4xl p-3 border-gray-200 h-16 ${translating ? "bg-gray-200" : "bg-white hover:bg-gray-200 hover:cursor-pointer"}`} > - {translating ? "translating..." : "translate"} + {translating ? t("translating") : t("translate")} diff --git a/src/components/IconClick.tsx b/src/components/IconClick.tsx index 077e77c..90eaa76 100644 --- a/src/components/IconClick.tsx +++ b/src/components/IconClick.tsx @@ -6,6 +6,7 @@ interface IconClickProps { onClick?: () => void; className?: string; size?: number; + disableOnHoverBgChange?: boolean; } export default function IconClick({ src, @@ -13,12 +14,13 @@ export default function IconClick({ onClick = () => {}, className = "", size = 32, + disableOnHoverBgChange = false, }: IconClickProps) { return ( <>
{alt}
diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 91bcc94..b315bdf 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -1,5 +1,12 @@ +"use client"; + import Link from "next/link"; import Image from "next/image"; +import { useTranslations } from "next-intl"; +import IconClick from "./IconClick"; +import IMAGES from "@/config/images"; +import { useState } from "react"; +import LightButton from "./buttons/LightButton"; function MyLink({ href, label }: { href: string; label: string }) { return ( @@ -9,6 +16,15 @@ function MyLink({ href, label }: { href: string; label: string }) { ); } export function Navbar() { + const t = useTranslations("navbar"); + const [showLanguageMenu, setShowLanguageMenu] = useState(false); + const handleLanguageClick = () => { + setShowLanguageMenu((prev) => !prev); + }; + const setLocale = async (locale: string) => { + document.cookie = `locale=${locale}`; + window.location.reload(); + }; return (
@@ -19,13 +35,39 @@ export function Navbar() { height="32" className="rounded-4xl" > - 学语言 + {t("title")}
- +
+ {showLanguageMenu && ( +
+
+ setLocale("en-US")} + > + English + + setLocale("zh-CN")} + > + 中文 + +
+
+ )} + +
+
diff --git a/src/config/i18n.ts b/src/config/i18n.ts new file mode 100644 index 0000000..a690025 --- /dev/null +++ b/src/config/i18n.ts @@ -0,0 +1,2 @@ +export const SUPPORTED_LOCALES = ["en-US", "zh-CN"]; +export const DEFAULT_LOCALE = "en-US"; \ No newline at end of file diff --git a/src/config/images.ts b/src/config/images.ts index 3869350..4c3b91c 100644 --- a/src/config/images.ts +++ b/src/config/images.ts @@ -15,6 +15,8 @@ const IMAGES = { save: "/images/save_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg", delete: "/images/delete_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg", speed: "/images/speed_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg", + language_black: "/images/language_24dp_1F1F1F_FILL0_wght400_GRAD0_opsz24.svg", + language_white: "/images/language_24dp_FFFFFF_FILL0_wght400_GRAD0_opsz24.svg", }; export default IMAGES; diff --git a/src/i18n/request.ts b/src/i18n/request.ts new file mode 100644 index 0000000..da48b45 --- /dev/null +++ b/src/i18n/request.ts @@ -0,0 +1,53 @@ +import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from "@/config/i18n"; +import { getRequestConfig } from "next-intl/server"; +import { cookies } from "next/headers"; +import { readFileSync, readdirSync, statSync } from "fs"; +import { join } from "path"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function loadMessagesFromDir(dirPath: string): Record { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const messages: Record = {}; + try { + const items = readdirSync(dirPath); + + for (const item of items) { + const fullPath = join(dirPath, item); + const stat = statSync(fullPath); + + if (stat.isDirectory()) { + const dirMessages = loadMessagesFromDir(fullPath); + Object.assign(messages, { [item]: dirMessages }); + } else if (item.endsWith(".json")) { + try { + const content = readFileSync(fullPath, "utf-8"); + const jsonContent = JSON.parse(content); + Object.assign(messages, { [item.replace(".json", "")]: jsonContent }); + } catch (error) { + console.warn(`Failed to load JSON file ${fullPath}:`, error); + } + } + } + } catch (error) { + console.warn(`Failed to read directory ${dirPath}:`, error); + } + + return messages; +} + +export default getRequestConfig(async () => { + const store = await cookies(); + const locale = (() => { + const locale = store.get("locale")?.value ?? DEFAULT_LOCALE; + if (!SUPPORTED_LOCALES.includes(locale)) return DEFAULT_LOCALE; + return locale; + })(); + + const messagesPath = join(process.cwd(), "messages", locale); + const messages = loadMessagesFromDir(messagesPath); + + return { + locale, + messages, + }; +});