From 7398b73f3f0b59b9ffff4706276deae62febe9d3 Mon Sep 17 00:00:00 2001
From: beilunyang <786220806@qq.com>
Date: Tue, 21 Oct 2025 23:53:58 +0800
Subject: [PATCH] feat(i18n): enhance localization support with new
languages(zh-tw/ja)
---
app/[locale]/layout.tsx | 3 +-
.../layout/floating-language-switcher.tsx | 44 +----
app/components/layout/language-switcher.tsx | 51 ++----
app/hooks/use-locale-switcher.ts | 38 ++++
app/i18n/config.ts | 10 +-
app/i18n/messages/en/common.json | 7 -
app/i18n/messages/ja/auth.json | 43 +++++
app/i18n/messages/ja/common.json | 20 +++
app/i18n/messages/ja/emails.json | 168 ++++++++++++++++++
app/i18n/messages/ja/home.json | 27 +++
app/i18n/messages/ja/metadata.json | 6 +
app/i18n/messages/ja/profile.json | 131 ++++++++++++++
app/i18n/messages/zh-CN/common.json | 7 -
app/i18n/messages/zh-TW/auth.json | 43 +++++
app/i18n/messages/zh-TW/common.json | 19 ++
app/i18n/messages/zh-TW/emails.json | 168 ++++++++++++++++++
app/i18n/messages/zh-TW/home.json | 27 +++
app/i18n/messages/zh-TW/metadata.json | 6 +
app/i18n/messages/zh-TW/profile.json | 131 ++++++++++++++
middleware.ts | 63 ++++++-
20 files changed, 915 insertions(+), 97 deletions(-)
create mode 100644 app/hooks/use-locale-switcher.ts
create mode 100644 app/i18n/messages/ja/auth.json
create mode 100644 app/i18n/messages/ja/common.json
create mode 100644 app/i18n/messages/ja/emails.json
create mode 100644 app/i18n/messages/ja/home.json
create mode 100644 app/i18n/messages/ja/metadata.json
create mode 100644 app/i18n/messages/ja/profile.json
create mode 100644 app/i18n/messages/zh-TW/auth.json
create mode 100644 app/i18n/messages/zh-TW/common.json
create mode 100644 app/i18n/messages/zh-TW/emails.json
create mode 100644 app/i18n/messages/zh-TW/home.json
create mode 100644 app/i18n/messages/zh-TW/metadata.json
create mode 100644 app/i18n/messages/zh-TW/profile.json
diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx
index 593ed12..924b5e6 100644
--- a/app/[locale]/layout.tsx
+++ b/app/[locale]/layout.tsx
@@ -70,7 +70,7 @@ export async function generateMetadata({
},
openGraph: {
type: "website",
- locale: locale === "zh-CN" ? "zh_CN" : locale,
+ locale: locale === "zh-CN" ? "zh_CN" : locale === "zh-TW" ? "zh_TW" : locale,
url: `${baseUrl}/${locale}`,
title: t("title"),
description: t("description"),
@@ -145,4 +145,3 @@ export default async function LocaleLayout({
)
}
-
diff --git a/app/components/layout/floating-language-switcher.tsx b/app/components/layout/floating-language-switcher.tsx
index 7d7ab07..6532bf9 100644
--- a/app/components/layout/floating-language-switcher.tsx
+++ b/app/components/layout/floating-language-switcher.tsx
@@ -1,8 +1,7 @@
"use client"
-import { useLocale, useTranslations } from "next-intl"
-import { usePathname, useRouter } from "next/navigation"
-import { i18n } from "@/i18n/config"
+import { useLocaleSwitcher } from "@/hooks/use-locale-switcher"
+import { LOCALE_LABELS } from "@/i18n/config"
import { Button } from "@/components/ui/button"
import { Languages } from "lucide-react"
import {
@@ -13,38 +12,7 @@ import {
} from "@/components/ui/dropdown-menu"
export function FloatingLanguageSwitcher() {
- const t = useTranslations("common")
- const locale = useLocale()
- const router = useRouter()
- const pathname = usePathname()
-
- const switchLocale = (newLocale: string) => {
- if (newLocale === locale) return
-
- document.cookie = `NEXT_LOCALE=${newLocale}; path=/; max-age=31536000`
-
- const segments = pathname.split("/")
- if (i18n.locales.includes(segments[1] as any)) {
- segments[1] = newLocale
- } else {
- segments.splice(1, 0, newLocale)
- }
- const newPath = segments.join("/")
-
- router.push(newPath)
- router.refresh()
- }
-
- const getLanguageName = (loc: string) => {
- switch (loc) {
- case "en":
- return "English"
- case "zh-CN":
- return "简体中文"
- default:
- return loc
- }
- }
+ const { locale, locales, switchLocale } = useLocaleSwitcher()
return (
@@ -54,19 +22,19 @@ export function FloatingLanguageSwitcher() {
variant="outline"
size="icon"
className="bg-white dark:bg-background rounded-full shadow-lg group relative border-primary/20 hover:border-primary/40 transition-all"
+ aria-label="Switch language"
>
- {t("lang.switch")}
- {i18n.locales.map((loc) => (
+ {locales.map((loc) => (
switchLocale(loc)}
className={locale === loc ? "bg-accent" : ""}
>
- {getLanguageName(loc)}
+ {LOCALE_LABELS[loc]}
))}
diff --git a/app/components/layout/language-switcher.tsx b/app/components/layout/language-switcher.tsx
index 2a442e5..b329d4b 100644
--- a/app/components/layout/language-switcher.tsx
+++ b/app/components/layout/language-switcher.tsx
@@ -1,8 +1,7 @@
"use client"
-import { useLocale, useTranslations } from "next-intl"
-import { usePathname, useRouter } from "next/navigation"
-import { i18n } from "@/i18n/config"
+import { useLocaleSwitcher } from "@/hooks/use-locale-switcher"
+import { LOCALE_LABELS } from "@/i18n/config"
import {
DropdownMenu,
DropdownMenuContent,
@@ -13,49 +12,25 @@ import { Button } from "@/components/ui/button"
import { Languages } from "lucide-react"
export function LanguageSwitcher() {
- const t = useTranslations("common")
- const locale = useLocale()
- const router = useRouter()
- const pathname = usePathname()
-
- const switchLocale = (newLocale: string) => {
- if (newLocale === locale) return
-
- document.cookie = `NEXT_LOCALE=${newLocale}; path=/; max-age=31536000`
-
- const segments = pathname.split("/")
- if (i18n.locales.includes(segments[1] as any)) {
- segments[1] = newLocale
- } else {
- segments.splice(1, 0, newLocale)
- }
- const newPath = segments.join("/")
-
- router.push(newPath)
- router.refresh()
- }
+ const { locale, locales, switchLocale } = useLocaleSwitcher()
return (
-
- switchLocale("en")}
- className={locale === "en" ? "bg-accent" : ""}
- >
- {t("lang.en")}
-
- switchLocale("zh-CN")}
- className={locale === "zh-CN" ? "bg-accent" : ""}
- >
- {t("lang.zhCN")}
-
+ {locales.map((loc) => (
+ switchLocale(loc)}
+ className={locale === loc ? "bg-accent" : ""}
+ >
+ {LOCALE_LABELS[loc]}
+
+ ))}
)
diff --git a/app/hooks/use-locale-switcher.ts b/app/hooks/use-locale-switcher.ts
new file mode 100644
index 0000000..97e86a0
--- /dev/null
+++ b/app/hooks/use-locale-switcher.ts
@@ -0,0 +1,38 @@
+"use client"
+
+import { useCallback } from "react"
+import { useLocale } from "next-intl"
+import { usePathname, useRouter } from "next/navigation"
+import { i18n, type Locale } from "@/i18n/config"
+
+export function useLocaleSwitcher() {
+ const locale = useLocale()
+ const router = useRouter()
+ const pathname = usePathname()
+
+ const switchLocale = useCallback(
+ (newLocale: Locale) => {
+ if (newLocale === locale) return
+
+ document.cookie = `NEXT_LOCALE=${newLocale}; path=/; max-age=31536000`
+
+ const segments = pathname.split("/")
+ if (i18n.locales.includes(segments[1] as Locale)) {
+ segments[1] = newLocale
+ } else {
+ segments.splice(1, 0, newLocale)
+ }
+
+ router.push(segments.join("/"))
+ router.refresh()
+ },
+ [locale, pathname, router]
+ )
+
+ return {
+ locale,
+ switchLocale,
+ locales: i18n.locales,
+ }
+}
+
diff --git a/app/i18n/config.ts b/app/i18n/config.ts
index 9fcb0c0..26b1335 100644
--- a/app/i18n/config.ts
+++ b/app/i18n/config.ts
@@ -1,6 +1,13 @@
-export const locales = ['en', 'zh-CN'] as const
+export const locales = ['en', 'zh-CN', 'zh-TW', 'ja'] as const
export type Locale = typeof locales[number]
+export const LOCALE_LABELS: Record = {
+ en: "English",
+ "zh-CN": "简体中文",
+ "zh-TW": "繁體中文",
+ ja: "日本語",
+}
+
export const defaultLocale: Locale = 'en'
export const i18n = {
@@ -8,4 +15,3 @@ export const i18n = {
defaultLocale,
localePrefix: 'as-needed',
}
-
diff --git a/app/i18n/messages/en/common.json b/app/i18n/messages/en/common.json
index 39eeed7..1e4c1a6 100644
--- a/app/i18n/messages/en/common.json
+++ b/app/i18n/messages/en/common.json
@@ -2,11 +2,6 @@
"app": {
"name": "MoeMail"
},
- "lang": {
- "en": "English",
- "zhCN": "简体中文",
- "switch": "Switch Language"
- },
"actions": {
"ok": "OK",
"cancel": "Cancel",
@@ -22,5 +17,3 @@
},
"github": "Get Source Code"
}
-
-
diff --git a/app/i18n/messages/ja/auth.json b/app/i18n/messages/ja/auth.json
new file mode 100644
index 0000000..9864c28
--- /dev/null
+++ b/app/i18n/messages/ja/auth.json
@@ -0,0 +1,43 @@
+{
+ "signButton": {
+ "login": "ログイン/登録",
+ "logout": "ログアウト",
+ "userAvatar": "ユーザーアバター",
+ "linked": "連携済み"
+ },
+ "loginForm": {
+ "title": "MoeMailへようこそ",
+ "subtitle": "かわいい使い捨てメールサービス (。・∀・)ノ",
+ "tabs": {
+ "login": "ログイン",
+ "register": "登録"
+ },
+ "fields": {
+ "username": "ユーザー名",
+ "password": "パスワード",
+ "confirmPassword": "パスワードを確認"
+ },
+ "actions": {
+ "login": "ログイン",
+ "register": "登録",
+ "or": "または",
+ "githubLogin": "GitHub アカウントでログイン"
+ },
+ "errors": {
+ "usernameRequired": "ユーザー名を入力してください",
+ "passwordRequired": "パスワードを入力してください",
+ "confirmPasswordRequired": "パスワードを確認してください",
+ "usernameInvalid": "ユーザー名に @ を含めることはできません",
+ "passwordTooShort": "パスワードは8文字以上である必要があります",
+ "passwordMismatch": "パスワードが一致しません"
+ },
+ "toast": {
+ "loginFailed": "ログインに失敗しました",
+ "loginFailedDesc": "ユーザー名またはパスワードが正しくありません",
+ "registerFailed": "登録に失敗しました",
+ "registerFailedDesc": "しばらくしてからもう一度お試しください",
+ "autoLoginFailed": "自動ログインに失敗しました。手動でログインしてください"
+ }
+ }
+}
+
diff --git a/app/i18n/messages/ja/common.json b/app/i18n/messages/ja/common.json
new file mode 100644
index 0000000..7e768f8
--- /dev/null
+++ b/app/i18n/messages/ja/common.json
@@ -0,0 +1,20 @@
+{
+ "app": {
+ "name": "MoeMail"
+ },
+ "actions": {
+ "ok": "OK",
+ "cancel": "キャンセル",
+ "save": "保存",
+ "delete": "削除"
+ },
+ "nav": {
+ "home": "ホーム",
+ "login": "ログイン",
+ "profile": "プロフィール",
+ "logout": "ログアウト",
+ "backToMailbox": "メールボックスに戻る"
+ },
+ "github": "ソースコードを入手"
+}
+
diff --git a/app/i18n/messages/ja/emails.json b/app/i18n/messages/ja/emails.json
new file mode 100644
index 0000000..5258b29
--- /dev/null
+++ b/app/i18n/messages/ja/emails.json
@@ -0,0 +1,168 @@
+{
+ "noPermission": {
+ "title": "権限がありません",
+ "description": "このページにアクセスする権限がありません。サイト管理者に連絡してください",
+ "adminContact": "管理者の連絡先",
+ "backToHome": "ホームに戻る"
+ },
+ "layout": {
+ "myEmails": "マイメールボックス",
+ "selectEmail": "メールボックスを選択してメッセージを表示",
+ "messageContent": "メール内容",
+ "selectMessage": "メールを選択して詳細を表示",
+ "backToEmailList": "← メールボックス一覧に戻る",
+ "backToMessageList": "← メッセージ一覧に戻る"
+ },
+ "list": {
+ "emailCount": "{count}/{max} 個のメールボックス",
+ "emailCountUnlimited": "{count}/∞ 個のメールボックス",
+ "loading": "読み込み中...",
+ "loadingMore": "さらに読み込み中...",
+ "noEmails": "まだメールボックスがありません。作成しましょう!",
+ "expiresAt": "有効期限",
+ "permanent": "永久",
+ "deleteConfirm": "削除の確認",
+ "deleteDescription": "メールボックス {email} を削除しますか?この操作によってメールボックス内のすべてのメールが削除され、元に戻せません。",
+ "deleteSuccess": "メールボックスを削除しました",
+ "deleteFailed": "メールボックスの削除に失敗しました",
+ "error": "エラー",
+ "success": "成功"
+ },
+ "create": {
+ "title": "メールボックスを作成",
+ "name": "メールボックスのプレフィックス",
+ "namePlaceholder": "空白の場合はランダムに生成",
+ "domain": "ドメイン",
+ "domainPlaceholder": "ドメインを選択",
+ "expiryTime": "有効期間",
+ "oneHour": "1時間",
+ "oneDay": "1日",
+ "threeDays": "3日",
+ "permanent": "永久",
+ "create": "作成",
+ "creating": "作成中...",
+ "success": "メールボックスを作成しました",
+ "failed": "メールボックスの作成に失敗しました"
+ },
+ "messages": {
+ "received": "受信箱",
+ "sent": "送信済み",
+ "noMessages": "メールはまだありません",
+ "messageCount": "件のメール",
+ "from": "差出人",
+ "to": "宛先",
+ "subject": "件名",
+ "date": "日付",
+ "loading": "読み込み中...",
+ "loadingMore": "さらに読み込み中..."
+ },
+ "send": {
+ "title": "メールを送信",
+ "from": "差出人",
+ "to": "宛先",
+ "toPlaceholder": "宛先メールアドレス",
+ "subject": "件名",
+ "subjectPlaceholder": "メールの件名",
+ "content": "内容",
+ "contentPlaceholder": "メール内容(HTML 対応)",
+ "send": "送信",
+ "sending": "送信中...",
+ "success": "メールを送信しました",
+ "failed": "メールの送信に失敗しました",
+ "dailyLimitReached": "1日の送信上限に達しました",
+ "dailyLimit": "1日の上限: {count}/{max}",
+ "dailyLimitUnit": "通/日"
+ },
+ "messageView": {
+ "loading": "メール詳細を読み込み中...",
+ "loadError": "メール詳細の取得に失敗しました",
+ "networkError": "ネットワークエラーが発生しました。しばらくしてから再試行してください",
+ "retry": "クリックして再試行",
+ "from": "差出人",
+ "to": "宛先",
+ "time": "時間",
+ "htmlFormat": "HTML 表示",
+ "textFormat": "テキスト表示"
+ },
+ "share": {
+ "title": "メールボックスを共有",
+ "description": "共有リンクを作成して、このメールボックス内のメールを他の人に見せましょう",
+ "createLink": "リンクを作成",
+ "creating": "作成中...",
+ "loading": "読み込み中...",
+ "expiryTime": "リンクの有効期間",
+ "oneHour": "1時間",
+ "oneDay": "1日",
+ "threeDays": "3日",
+ "oneWeek": "1週間",
+ "permanent": "永久",
+ "activeLinks": "現在の共有リンク",
+ "noLinks": "共有リンクはまだありません",
+ "createdAt": "作成日時",
+ "expiresAt": "期限",
+ "expired": "期限切れ",
+ "copy": "リンクをコピー",
+ "copied": "コピーしました",
+ "copyFailed": "コピーに失敗しました",
+ "delete": "削除",
+ "deleteConfirm": "共有リンクを削除しますか?",
+ "deleteDescription": "この操作は取り消せません。共有リンクはすぐに無効になります。",
+ "cancel": "キャンセル",
+ "deleteSuccess": "削除しました",
+ "deleteFailed": "削除に失敗しました",
+ "createSuccess": "共有リンクを作成しました",
+ "createFailed": "共有リンクの作成に失敗しました",
+ "shareButton": "共有"
+ },
+ "shareMessage": {
+ "title": "メールを共有",
+ "description": "共有リンクを作成して、このメールを他の人に見せましょう",
+ "createLink": "リンクを作成",
+ "creating": "作成中...",
+ "loading": "読み込み中...",
+ "expiryTime": "リンクの有効期間",
+ "oneHour": "1時間",
+ "oneDay": "1日",
+ "threeDays": "3日",
+ "oneWeek": "1週間",
+ "permanent": "永久",
+ "activeLinks": "現在の共有リンク",
+ "noLinks": "共有リンクはまだありません",
+ "createdAt": "作成日時",
+ "expiresAt": "期限",
+ "expired": "期限切れ",
+ "copy": "リンクをコピー",
+ "copied": "コピーしました",
+ "copyFailed": "コピーに失敗しました",
+ "delete": "削除",
+ "deleteConfirm": "共有リンクを削除しますか?",
+ "deleteDescription": "この操作は取り消せません。共有リンクはすぐに無効になります。",
+ "cancel": "キャンセル",
+ "deleteSuccess": "削除しました",
+ "deleteFailed": "削除に失敗しました",
+ "createSuccess": "共有リンクを作成しました",
+ "createFailed": "共有リンクの作成に失敗しました",
+ "shareButton": "メールを共有"
+ },
+ "shared": {
+ "loading": "読み込み中...",
+ "emailNotFound": "メールボックスにアクセスできません",
+ "messageNotFound": "メールにアクセスできません",
+ "linkExpired": "共有リンクが存在しないか期限切れです",
+ "linkInvalid": "リンクが無効です",
+ "linkInvalidDescription": "この共有リンクは期限切れか存在しない可能性があります",
+ "sharedMailbox": "共有メールボックス",
+ "sharedMessage": "共有メール",
+ "expiresAt": "メールボックスの期限",
+ "permanent": "永久",
+ "createOwnEmail": "自分の使い捨てメールを作成する",
+ "brand": {
+ "title": "MoeMail",
+ "subtitle": "かわいい使い捨てメールサービス",
+ "cta": "今すぐ体験",
+ "officialSite": "公式サイト",
+ "copyright": "© 2024 MoeMail. かわいい使い捨てメールサービス"
+ }
+ }
+}
+
diff --git a/app/i18n/messages/ja/home.json b/app/i18n/messages/ja/home.json
new file mode 100644
index 0000000..8858c3e
--- /dev/null
+++ b/app/i18n/messages/ja/home.json
@@ -0,0 +1,27 @@
+{
+ "title": "MoeMail",
+ "subtitle": "かわいい使い捨てメールサービス",
+ "features": {
+ "privacy": {
+ "title": "プライバシー保護",
+ "description": "本物のメールアドレスを保護します"
+ },
+ "instant": {
+ "title": "メールボックス共有",
+ "description": "メールボックスを他の人と共有できます"
+ },
+ "expiry": {
+ "title": "自動期限切れ",
+ "description": "有効期限が切れると自動的に無効になります"
+ },
+ "openapi": {
+ "title": "オープンAPI",
+ "description": "完全な OpenAPI インターフェースを提供"
+ }
+ },
+ "actions": {
+ "enterMailbox": "メールボックスに入る",
+ "getStarted": "今すぐ始める"
+ }
+}
+
diff --git a/app/i18n/messages/ja/metadata.json b/app/i18n/messages/ja/metadata.json
new file mode 100644
index 0000000..0b1e45e
--- /dev/null
+++ b/app/i18n/messages/ja/metadata.json
@@ -0,0 +1,6 @@
+{
+ "title": "MoeMail - かわいい使い捨てメールサービス · オープンAPI",
+ "description": "安全で高速な使い捨てメールアドレスでプライバシーを守り、スパムを遠ざけます。メールボックス共有、即時受信、期限到来での自動失効に対応。完全な OpenAPI を提供し、開発者の統合や自動テストに最適です。",
+ "keywords": "使い捨てメール, 一時メール, 匿名メール, メール共有, プライバシー保護, スパム対策, 即時受信, 自動失効, 安全なメール, 登録確認, テスト用メール, 電話番号不要, 開発テスト, 自動テスト, メールAPI, OpenAPI, APIインターフェース, RESTful API, APIキー, 開発者ツール, MoeMail"
+}
+
diff --git a/app/i18n/messages/ja/profile.json b/app/i18n/messages/ja/profile.json
new file mode 100644
index 0000000..6bb08dd
--- /dev/null
+++ b/app/i18n/messages/ja/profile.json
@@ -0,0 +1,131 @@
+{
+ "title": "プロフィール",
+ "card": {
+ "title": "ユーザー情報",
+ "name": "ユーザー名",
+ "role": "ロール",
+ "roles": {
+ "EMPEROR": "皇帝",
+ "DUKE": "公爵",
+ "KNIGHT": "騎士",
+ "CIVILIAN": "市民"
+ }
+ },
+ "apiKey": {
+ "title": "API Key 管理",
+ "description": "OpenAPI にアクセスするための API キーを作成・管理します",
+ "create": "API Key を作成",
+ "name": "キー名",
+ "namePlaceholder": "キー名を入力",
+ "key": "API Key",
+ "createdAt": "作成日時",
+ "copy": "コピー",
+ "delete": "削除",
+ "noKeys": "API Key はまだありません",
+ "createSuccess": "API Key を作成しました",
+ "createFailed": "API Key の作成に失敗しました",
+ "deleteConfirm": "削除の確認",
+ "deleteDescription": "API Key {name} を削除しますか?この操作は元に戻せません。",
+ "deleteSuccess": "API Key を削除しました",
+ "deleteFailed": "API Key の削除に失敗しました",
+ "viewDocs": "ドキュメントを見る",
+ "docs": {
+ "getConfig": "システム設定を取得",
+ "generateEmail": "使い捨てメールを生成",
+ "getEmails": "メールボックス一覧を取得",
+ "getMessages": "メール一覧を取得",
+ "getMessage": "メールを1件取得",
+ "createEmailShare": "メールボックス共有リンクを作成",
+ "getEmailShares": "メールボックス共有リンク一覧を取得",
+ "deleteEmailShare": "メールボックス共有リンクを削除",
+ "createMessageShare": "メール共有リンクを作成",
+ "getMessageShares": "メール共有リンク一覧を取得",
+ "deleteMessageShare": "メール共有リンクを削除",
+ "notes": "注意:",
+ "note1": "YOUR_API_KEY を実際の API Key に置き換えてください",
+ "note2": "/api/config エンドポイントで利用可能なメールボックスドメインなどのシステム設定を取得できます",
+ "note3": "emailId はメールボックスの一意な識別子です",
+ "note4": "messageId はメールの一意な識別子です",
+ "note5": "expiryTime はメールボックスの有効期間(ミリ秒)です。利用可能な値: 3600000(1時間)、86400000(1日)、604800000(7日)、0(永久)",
+ "note6": "domain はメールボックスのドメインで、/api/config エンドポイントで利用可能な一覧を取得できます",
+ "note7": "cursor はページネーション用で、前回のレスポンスから nextCursor を取得してください",
+ "note8": "すべてのリクエストには X-API-Key ヘッダーが必要です",
+ "note9": "expiresIn は共有リンクの有効期間(ミリ秒)で、0 は永久を意味します",
+ "note10": "shareId は共有記録の一意な識別子です"
+ }
+ },
+ "emailService": {
+ "title": "Resend 送信サービス設定",
+ "configRoleLabel": "設定可能なロール権限",
+ "enable": "メール送信を有効にする",
+ "fixedRoleLimits": "固定権限ルール",
+ "emperorLimit": "皇帝は制限なく無制限に送信できます",
+ "civilianLimit": "市民は送信できません",
+ "enableDescription": "有効にすると Resend を利用してメールを送信します",
+ "apiKey": "Resend API Key",
+ "apiKeyPlaceholder": "Resend API Key を入力",
+ "dailyLimit": "1日の上限",
+ "roleLimits": "送信機能を許可するロール",
+ "save": "設定を保存",
+ "saving": "保存中...",
+ "saveSuccess": "設定を保存しました",
+ "saveFailed": "設定の保存に失敗しました",
+ "unlimited": "無制限",
+ "disabled": "送信権限は無効です",
+ "enabled": "送信権限が有効です"
+ },
+ "webhook": {
+ "title": "Webhook 設定",
+ "description": "新しいメールを受信した際に指定した URL に通知します",
+ "description2": "この URL に新しいメール情報を含む POST リクエストを送信します",
+ "description3": "データ形式を見る",
+ "enable": "Webhook を有効化",
+ "url": "Webhook URL",
+ "urlPlaceholder": "Webhook URL を入力",
+ "test": "テスト",
+ "testing": "テスト中...",
+ "save": "設定を保存",
+ "saving": "保存中...",
+ "saveSuccess": "設定を保存しました",
+ "saveFailed": "設定の保存に失敗しました",
+ "testSuccess": "Webhook テストに成功しました",
+ "testFailed": "Webhook テストに失敗しました",
+ "docs": {
+ "intro": "新しいメールを受信すると、設定した URL に POST リクエストを送信します。リクエストヘッダーには以下が含まれます:",
+ "exampleBody": "リクエストボディ例:",
+ "subject": "メール件名",
+ "content": "メールテキスト内容",
+ "html": "メール HTML 内容"
+ }
+ },
+ "website": {
+ "title": "サイト設定",
+ "description": "サイトの設定を管理(皇帝のみ利用可能)",
+ "defaultRole": "新規ユーザーのデフォルトロール",
+ "emailDomains": "メールボックスドメイン",
+ "emailDomainsPlaceholder": "複数のドメインはカンマで区切ります",
+ "adminContact": "管理者連絡先",
+ "adminContactPlaceholder": "メールまたはその他の連絡先",
+ "maxEmails": "ユーザーあたりの最大メールボックス数",
+ "save": "設定を保存",
+ "saving": "保存中...",
+ "saveSuccess": "設定を保存しました",
+ "saveFailed": "設定の保存に失敗しました"
+ },
+ "promote": {
+ "title": "ロール管理",
+ "description": "ユーザーロールを管理(皇帝のみ利用可能)",
+ "search": "ユーザーを検索",
+ "searchPlaceholder": "ユーザー名またはメールを入力",
+ "username": "ユーザー名",
+ "email": "メール",
+ "role": "ロール",
+ "actions": "操作",
+ "promote": "設定",
+ "noUsers": "ユーザーが見つかりません",
+ "loading": "読み込み中...",
+ "updateSuccess": "ユーザーロールを更新しました",
+ "updateFailed": "ユーザーロールの更新に失敗しました"
+ }
+}
+
diff --git a/app/i18n/messages/zh-CN/common.json b/app/i18n/messages/zh-CN/common.json
index de371c0..cc3f35a 100644
--- a/app/i18n/messages/zh-CN/common.json
+++ b/app/i18n/messages/zh-CN/common.json
@@ -2,11 +2,6 @@
"app": {
"name": "MoeMail"
},
- "lang": {
- "en": "English",
- "zhCN": "简体中文",
- "switch": "切换语言"
- },
"actions": {
"ok": "确定",
"cancel": "取消",
@@ -22,5 +17,3 @@
},
"github": "获取网站源代码"
}
-
-
diff --git a/app/i18n/messages/zh-TW/auth.json b/app/i18n/messages/zh-TW/auth.json
new file mode 100644
index 0000000..ad8608e
--- /dev/null
+++ b/app/i18n/messages/zh-TW/auth.json
@@ -0,0 +1,43 @@
+{
+ "signButton": {
+ "login": "登入/註冊",
+ "logout": "登出",
+ "userAvatar": "使用者頭像",
+ "linked": "已關聯"
+ },
+ "loginForm": {
+ "title": "歡迎使用 MoeMail",
+ "subtitle": "萌萌噠臨時郵箱服務 (。・∀・)ノ",
+ "tabs": {
+ "login": "登入",
+ "register": "註冊"
+ },
+ "fields": {
+ "username": "使用者名稱",
+ "password": "密碼",
+ "confirmPassword": "確認密碼"
+ },
+ "actions": {
+ "login": "登入",
+ "register": "註冊",
+ "or": "或者",
+ "githubLogin": "使用 GitHub 帳號登入"
+ },
+ "errors": {
+ "usernameRequired": "請輸入使用者名稱",
+ "passwordRequired": "請輸入密碼",
+ "confirmPasswordRequired": "請確認密碼",
+ "usernameInvalid": "使用者名稱不能包含 @ 符號",
+ "passwordTooShort": "密碼長度必須大於等於8位",
+ "passwordMismatch": "兩次輸入的密碼不一致"
+ },
+ "toast": {
+ "loginFailed": "登入失敗",
+ "loginFailedDesc": "使用者名稱或密碼錯誤",
+ "registerFailed": "註冊失敗",
+ "registerFailedDesc": "請稍後再試",
+ "autoLoginFailed": "自動登入失敗,請手動登入"
+ }
+ }
+}
+
diff --git a/app/i18n/messages/zh-TW/common.json b/app/i18n/messages/zh-TW/common.json
new file mode 100644
index 0000000..b8b44df
--- /dev/null
+++ b/app/i18n/messages/zh-TW/common.json
@@ -0,0 +1,19 @@
+{
+ "app": {
+ "name": "MoeMail"
+ },
+ "actions": {
+ "ok": "確定",
+ "cancel": "取消",
+ "save": "儲存",
+ "delete": "刪除"
+ },
+ "nav": {
+ "home": "首頁",
+ "login": "登入",
+ "profile": "個人中心",
+ "logout": "登出",
+ "backToMailbox": "返回郵箱"
+ },
+ "github": "取得網站原始碼"
+}
diff --git a/app/i18n/messages/zh-TW/emails.json b/app/i18n/messages/zh-TW/emails.json
new file mode 100644
index 0000000..805f6a2
--- /dev/null
+++ b/app/i18n/messages/zh-TW/emails.json
@@ -0,0 +1,168 @@
+{
+ "noPermission": {
+ "title": "權限不足",
+ "description": "你沒有權限訪問此頁面,請聯絡網站管理員",
+ "adminContact": "管理員聯絡方式",
+ "backToHome": "返回首頁"
+ },
+ "layout": {
+ "myEmails": "我的郵箱",
+ "selectEmail": "選擇郵箱查看訊息",
+ "messageContent": "郵件內容",
+ "selectMessage": "選擇郵件查看詳情",
+ "backToEmailList": "← 返回郵箱列表",
+ "backToMessageList": "← 返回訊息列表"
+ },
+ "list": {
+ "emailCount": "{count}/{max} 個郵箱",
+ "emailCountUnlimited": "{count}/∞ 個郵箱",
+ "loading": "載入中...",
+ "loadingMore": "載入更多...",
+ "noEmails": "還沒有郵箱,建立一個吧!",
+ "expiresAt": "過期時間",
+ "permanent": "永久有效",
+ "deleteConfirm": "確認刪除",
+ "deleteDescription": "確定要刪除郵箱 {email} 嗎?此操作將同時刪除該郵箱中的所有郵件,且不可恢復。",
+ "deleteSuccess": "郵箱已刪除",
+ "deleteFailed": "刪除郵箱失敗",
+ "error": "錯誤",
+ "success": "成功"
+ },
+ "create": {
+ "title": "建立郵箱",
+ "name": "郵箱前綴",
+ "namePlaceholder": "留空則隨機生成",
+ "domain": "網域",
+ "domainPlaceholder": "選擇網域",
+ "expiryTime": "有效期",
+ "oneHour": "1 小時",
+ "oneDay": "1 天",
+ "threeDays": "3 天",
+ "permanent": "永久",
+ "create": "建立",
+ "creating": "建立中...",
+ "success": "郵箱建立成功",
+ "failed": "建立郵箱失敗"
+ },
+ "messages": {
+ "received": "收件匣",
+ "sent": "已發送",
+ "noMessages": "暫無郵件",
+ "messageCount": "封郵件",
+ "from": "寄件者",
+ "to": "收件者",
+ "subject": "主旨",
+ "date": "日期",
+ "loading": "載入中...",
+ "loadingMore": "載入更多..."
+ },
+ "send": {
+ "title": "發送郵件",
+ "from": "寄件者",
+ "to": "收件者",
+ "toPlaceholder": "收件者郵箱地址",
+ "subject": "主旨",
+ "subjectPlaceholder": "郵件主旨",
+ "content": "內容",
+ "contentPlaceholder": "郵件內容(支援 HTML)",
+ "send": "發送",
+ "sending": "發送中...",
+ "success": "郵件發送成功",
+ "failed": "發送郵件失敗",
+ "dailyLimitReached": "已達每日發送上限",
+ "dailyLimit": "每日限額:{count}/{max}",
+ "dailyLimitUnit": "封/天"
+ },
+ "messageView": {
+ "loading": "載入郵件詳情...",
+ "loadError": "取得郵件詳情失敗",
+ "networkError": "網路錯誤,請稍後再試",
+ "retry": "點擊重試",
+ "from": "寄件者",
+ "to": "收件者",
+ "time": "時間",
+ "htmlFormat": "HTML 格式",
+ "textFormat": "純文字格式"
+ },
+ "share": {
+ "title": "分享郵箱",
+ "description": "建立分享連結,讓其他人可以查看此郵箱中的郵件",
+ "createLink": "建立連結",
+ "creating": "建立中...",
+ "loading": "載入中...",
+ "expiryTime": "連結有效期",
+ "oneHour": "1 小時",
+ "oneDay": "1 天",
+ "threeDays": "3 天",
+ "oneWeek": "1 週",
+ "permanent": "永久",
+ "activeLinks": "目前分享連結",
+ "noLinks": "暫無分享連結",
+ "createdAt": "建立時間",
+ "expiresAt": "過期時間",
+ "expired": "已過期",
+ "copy": "複製連結",
+ "copied": "已複製",
+ "copyFailed": "複製失敗",
+ "delete": "刪除",
+ "deleteConfirm": "確認刪除分享連結?",
+ "deleteDescription": "此操作無法撤銷,分享連結將立即失效。",
+ "cancel": "取消",
+ "deleteSuccess": "刪除成功",
+ "deleteFailed": "刪除失敗",
+ "createSuccess": "分享連結建立成功",
+ "createFailed": "建立分享連結失敗",
+ "shareButton": "分享"
+ },
+ "shareMessage": {
+ "title": "分享郵件",
+ "description": "建立分享連結,讓其他人可以查看這封郵件",
+ "createLink": "建立連結",
+ "creating": "建立中...",
+ "loading": "載入中...",
+ "expiryTime": "連結有效期",
+ "oneHour": "1 小時",
+ "oneDay": "1 天",
+ "threeDays": "3 天",
+ "oneWeek": "1 週",
+ "permanent": "永久",
+ "activeLinks": "目前分享連結",
+ "noLinks": "暫無分享連結",
+ "createdAt": "建立時間",
+ "expiresAt": "過期時間",
+ "expired": "已過期",
+ "copy": "複製連結",
+ "copied": "已複製",
+ "copyFailed": "複製失敗",
+ "delete": "刪除",
+ "deleteConfirm": "確認刪除分享連結?",
+ "deleteDescription": "此操作無法撤銷,分享連結將立即失效。",
+ "cancel": "取消",
+ "deleteSuccess": "刪除成功",
+ "deleteFailed": "刪除失敗",
+ "createSuccess": "分享連結建立成功",
+ "createFailed": "建立分享連結失敗",
+ "shareButton": "分享郵件"
+ },
+ "shared": {
+ "loading": "載入中...",
+ "emailNotFound": "無法訪問郵箱",
+ "messageNotFound": "無法訪問郵件",
+ "linkExpired": "分享連結不存在或已過期",
+ "linkInvalid": "連結無效",
+ "linkInvalidDescription": "此分享連結可能已過期或不存在",
+ "sharedMailbox": "分享郵箱",
+ "sharedMessage": "分享郵件",
+ "expiresAt": "郵箱過期時間",
+ "permanent": "永久有效",
+ "createOwnEmail": "建立自己的臨時郵箱",
+ "brand": {
+ "title": "MoeMail",
+ "subtitle": "萌萌噠臨時郵箱服務",
+ "cta": "立即體驗",
+ "officialSite": "官網",
+ "copyright": "© 2024 MoeMail. 萌萌噠臨時郵箱服務"
+ }
+ }
+}
+
diff --git a/app/i18n/messages/zh-TW/home.json b/app/i18n/messages/zh-TW/home.json
new file mode 100644
index 0000000..0e039f7
--- /dev/null
+++ b/app/i18n/messages/zh-TW/home.json
@@ -0,0 +1,27 @@
+{
+ "title": "MoeMail",
+ "subtitle": "萌萌噠臨時郵箱服務",
+ "features": {
+ "privacy": {
+ "title": "隱私保護",
+ "description": "保護您的真實郵箱地址"
+ },
+ "instant": {
+ "title": "郵箱分享",
+ "description": "將郵箱分享給其他人使用"
+ },
+ "expiry": {
+ "title": "自動過期",
+ "description": "到期自動失效"
+ },
+ "openapi": {
+ "title": "開放 API",
+ "description": "提供完整的 OpenAPI 介面"
+ }
+ },
+ "actions": {
+ "enterMailbox": "進入郵箱",
+ "getStarted": "開始使用"
+ }
+}
+
diff --git a/app/i18n/messages/zh-TW/metadata.json b/app/i18n/messages/zh-TW/metadata.json
new file mode 100644
index 0000000..4adfd6c
--- /dev/null
+++ b/app/i18n/messages/zh-TW/metadata.json
@@ -0,0 +1,6 @@
+{
+ "title": "MoeMail - 萌萌噠臨時郵箱服務 · 開放 API",
+ "description": "安全、快速、一次性的臨時郵箱地址,保護您的隱私,遠離垃圾郵件。支援郵箱分享、即時收件,到期自動失效。提供完整的 OpenAPI 介面,方便開發者整合與自動化測試。",
+ "keywords": "臨時郵箱, 一次性郵箱, 匿名郵箱, 郵箱分享, 隱私保護, 垃圾郵件過濾, 即時收件, 自動過期, 安全郵箱, 註冊驗證, 臨時帳號, 無需手機號, 開發測試, 自動化測試, 郵件API, OpenAPI, API介面, RESTful API, API Key, 開發者工具, MoeMail"
+}
+
diff --git a/app/i18n/messages/zh-TW/profile.json b/app/i18n/messages/zh-TW/profile.json
new file mode 100644
index 0000000..f7fe171
--- /dev/null
+++ b/app/i18n/messages/zh-TW/profile.json
@@ -0,0 +1,131 @@
+{
+ "title": "個人中心",
+ "card": {
+ "title": "使用者資訊",
+ "name": "使用者名稱",
+ "role": "角色",
+ "roles": {
+ "EMPEROR": "皇帝",
+ "DUKE": "公爵",
+ "KNIGHT": "騎士",
+ "CIVILIAN": "平民"
+ }
+ },
+ "apiKey": {
+ "title": "API Key 管理",
+ "description": "建立與管理用於存取 OpenAPI 的 API 金鑰",
+ "create": "建立 API Key",
+ "name": "金鑰名稱",
+ "namePlaceholder": "輸入金鑰名稱",
+ "key": "API Key",
+ "createdAt": "建立時間",
+ "copy": "複製",
+ "delete": "刪除",
+ "noKeys": "暫無 API Key",
+ "createSuccess": "API Key 建立成功",
+ "createFailed": "建立 API Key 失敗",
+ "deleteConfirm": "確認刪除",
+ "deleteDescription": "確定要刪除 API Key {name} 嗎?此操作不可恢復。",
+ "deleteSuccess": "API Key 已刪除",
+ "deleteFailed": "刪除 API Key 失敗",
+ "viewDocs": "查看使用文件",
+ "docs": {
+ "getConfig": "取得系統設定",
+ "generateEmail": "產生臨時郵箱",
+ "getEmails": "取得郵箱清單",
+ "getMessages": "取得郵件清單",
+ "getMessage": "取得單封郵件",
+ "createEmailShare": "建立郵箱分享連結",
+ "getEmailShares": "取得郵箱分享連結清單",
+ "deleteEmailShare": "刪除郵箱分享連結",
+ "createMessageShare": "建立郵件分享連結",
+ "getMessageShares": "取得郵件分享連結清單",
+ "deleteMessageShare": "刪除郵件分享連結",
+ "notes": "注意:",
+ "note1": "請將 YOUR_API_KEY 替換為你的實際 API Key",
+ "note2": "/api/config 介面可取得系統設定,包括可用的郵箱網域清單",
+ "note3": "emailId 是郵箱的唯一識別碼",
+ "note4": "messageId 是郵件的唯一識別碼",
+ "note5": "expiryTime 是郵箱的有效期(毫秒),可選值:3600000(1 小時)、86400000(1 天)、604800000(7 天)、0(永久)",
+ "note6": "domain 是郵箱網域,可透過 /api/config 介面取得可用網域清單",
+ "note7": "cursor 用於分頁,從上一次請求的回應中取得 nextCursor",
+ "note8": "所有請求都需要包含 X-API-Key 請求標頭",
+ "note9": "expiresIn 是分享連結的有效期(毫秒),0 表示永久有效",
+ "note10": "shareId 是分享紀錄的唯一識別碼"
+ }
+ },
+ "emailService": {
+ "title": "Resend 發件服務設定",
+ "configRoleLabel": "可設定的角色權限",
+ "enable": "啟用郵件服務",
+ "fixedRoleLimits": "固定權限規則",
+ "emperorLimit": "皇帝可以無限發件,不受任何限制",
+ "civilianLimit": "永遠不能發件",
+ "enableDescription": "開啟後將使用 Resend 發送郵件",
+ "apiKey": "Resend API Key",
+ "apiKeyPlaceholder": "輸入 Resend API Key",
+ "dailyLimit": "每日限額",
+ "roleLimits": "允許使用發件功能的角色",
+ "save": "儲存設定",
+ "saving": "儲存中...",
+ "saveSuccess": "設定儲存成功",
+ "saveFailed": "儲存設定失敗",
+ "unlimited": "無限制",
+ "disabled": "未啟用發件權限",
+ "enabled": "已啟用發件權限"
+ },
+ "webhook": {
+ "title": "Webhook 設定",
+ "description": "當收到新郵件時通知指定的 URL",
+ "description2": "我們會向此 URL 發送 POST 請求,包含新郵件的相關資訊",
+ "description3": "查看資料格式說明",
+ "enable": "啟用 Webhook",
+ "url": "Webhook URL",
+ "urlPlaceholder": "輸入 webhook URL",
+ "test": "測試",
+ "testing": "測試中...",
+ "save": "儲存設定",
+ "saving": "儲存中...",
+ "saveSuccess": "設定儲存成功",
+ "saveFailed": "儲存設定失敗",
+ "testSuccess": "Webhook 測試成功",
+ "testFailed": "Webhook 測試失敗",
+ "docs": {
+ "intro": "當收到新郵件時,我們會向設定的 URL 發送 POST 請求,請求標頭包含:",
+ "exampleBody": "請求體範例:",
+ "subject": "郵件主旨",
+ "content": "郵件文字內容",
+ "html": "郵件 HTML 內容"
+ }
+ },
+ "website": {
+ "title": "網站設定",
+ "description": "設定網站選項(僅皇帝可用)",
+ "defaultRole": "新使用者預設角色",
+ "emailDomains": "郵箱網域",
+ "emailDomainsPlaceholder": "多個網域以逗號分隔",
+ "adminContact": "管理員聯絡方式",
+ "adminContactPlaceholder": "郵箱或其他聯絡方式",
+ "maxEmails": "每個使用者最大郵箱數",
+ "save": "儲存設定",
+ "saving": "儲存中...",
+ "saveSuccess": "設定儲存成功",
+ "saveFailed": "儲存設定失敗"
+ },
+ "promote": {
+ "title": "角色管理",
+ "description": "管理使用者角色(僅皇帝可用)",
+ "search": "搜尋使用者",
+ "searchPlaceholder": "輸入使用者名稱或郵箱",
+ "username": "使用者名稱",
+ "email": "郵箱",
+ "role": "角色",
+ "actions": "操作",
+ "promote": "設為",
+ "noUsers": "未找到使用者",
+ "loading": "載入中...",
+ "updateSuccess": "使用者角色更新成功",
+ "updateFailed": "更新使用者角色失敗"
+ }
+}
+
diff --git a/middleware.ts b/middleware.ts
index eae9a07..c9871a9 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -1,6 +1,6 @@
import { auth } from "@/lib/auth"
import { NextResponse } from "next/server"
-import { i18n } from "@/i18n/config"
+import { i18n, type Locale } from "@/i18n/config"
import { PERMISSIONS } from "@/lib/permissions"
import { checkPermission } from "@/lib/auth"
import { Permission } from "@/lib/permissions"
@@ -63,7 +63,9 @@ export async function middleware(request: Request) {
const hasLocalePrefix = i18n.locales.includes(maybeLocale as any)
if (!hasLocalePrefix) {
const cookieLocale = request.headers.get('Cookie')?.match(/NEXT_LOCALE=([^;]+)/)?.[1]
- const targetLocale = (cookieLocale && i18n.locales.includes(cookieLocale as any)) ? cookieLocale : i18n.defaultLocale
+ const acceptLanguage = request.headers.get('Accept-Language')
+ const preferredLocale = resolvePreferredLocale(cookieLocale, acceptLanguage)
+ const targetLocale = preferredLocale ?? i18n.defaultLocale
const redirectURL = new URL(`/${targetLocale}${pathname}${url.search}`, request.url)
return NextResponse.redirect(redirectURL)
}
@@ -71,6 +73,61 @@ export async function middleware(request: Request) {
return NextResponse.next()
}
+function resolvePreferredLocale(cookieLocale: string | undefined, acceptLanguageHeader: string | null): Locale | null {
+ if (cookieLocale && i18n.locales.includes(cookieLocale as Locale)) {
+ return cookieLocale as Locale
+ }
+
+ if (!acceptLanguageHeader) return null
+
+ const candidates = parseAcceptLanguage(acceptLanguageHeader)
+ for (const lang of candidates) {
+ const match = matchLocale(lang)
+ if (match) {
+ return match
+ }
+ }
+
+ return null
+}
+
+function parseAcceptLanguage(header: string): string[] {
+ return header
+ .split(',')
+ .map((part) => {
+ const [lang, ...params] = part.trim().split(';')
+ const qualityParam = params.find((param) => param.trim().startsWith('q='))
+ const quality = qualityParam ? parseFloat(qualityParam.split('=')[1]) : 1
+ return { lang: lang.toLowerCase(), quality: isNaN(quality) ? 1 : quality }
+ })
+ .sort((a, b) => b.quality - a.quality)
+ .map((entry) => entry.lang)
+}
+
+function matchLocale(lang: string): Locale | null {
+ const exactMatch = i18n.locales.find((locale) => locale.toLowerCase() === lang)
+ if (exactMatch) return exactMatch
+
+ const base = lang.split('-')[0]
+
+ // Handle Chinese variants with explicit regions or scripts
+ if (base === 'zh') {
+ if (lang.includes('tw') || lang.includes('hk') || lang.includes('mo') || lang.includes('hant')) {
+ return 'zh-TW'
+ }
+ if (lang.includes('cn') || lang.includes('sg') || lang.includes('hans')) {
+ return 'zh-CN'
+ }
+ // default Chinese fallback
+ return 'zh-CN'
+ }
+
+ const baseMatch = i18n.locales.find((locale) => locale.toLowerCase().split('-')[0] === base)
+ if (baseMatch) return baseMatch
+
+ return null
+}
+
export const config = {
matcher: [
'/((?!_next|.*\\..*).*)', // all pages excluding static assets
@@ -80,4 +137,4 @@ export const config = {
'/api/config/:path*',
'/api/api-keys/:path*',
]
-}
\ No newline at end of file
+}