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 +}