feat(i18n): enhance localization support with new languages(zh-tw/ja)

This commit is contained in:
beilunyang
2025-10-21 23:53:58 +08:00
parent 0c7a4d84a5
commit 7398b73f3f
20 changed files with 915 additions and 97 deletions

View File

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

View File

@@ -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 (
<div className="fixed bottom-6 right-6 z-50">
@@ -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"
>
<Languages className="h-5 w-5 text-primary group-hover:scale-110 transition-transform" />
<span className="sr-only">{t("lang.switch")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top" className="mb-2">
{i18n.locales.map((loc) => (
{locales.map((loc) => (
<DropdownMenuItem
key={loc}
onClick={() => switchLocale(loc)}
className={locale === loc ? "bg-accent" : ""}
>
{getLanguageName(loc)}
{LOCALE_LABELS[loc]}
</DropdownMenuItem>
))}
</DropdownMenuContent>

View File

@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Button variant="ghost" size="icon" aria-label="Switch language">
<Languages className="h-5 w-5" />
<span className="sr-only">{t("lang.switch")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => switchLocale("en")}
className={locale === "en" ? "bg-accent" : ""}
>
{t("lang.en")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => switchLocale("zh-CN")}
className={locale === "zh-CN" ? "bg-accent" : ""}
>
{t("lang.zhCN")}
</DropdownMenuItem>
{locales.map((loc) => (
<DropdownMenuItem
key={loc}
onClick={() => switchLocale(loc)}
className={locale === loc ? "bg-accent" : ""}
>
{LOCALE_LABELS[loc]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)

View File

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

View File

@@ -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<Locale, string> = {
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',
}

View File

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

View File

@@ -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": "自動ログインに失敗しました。手動でログインしてください"
}
}
}

View File

@@ -0,0 +1,20 @@
{
"app": {
"name": "MoeMail"
},
"actions": {
"ok": "OK",
"cancel": "キャンセル",
"save": "保存",
"delete": "削除"
},
"nav": {
"home": "ホーム",
"login": "ログイン",
"profile": "プロフィール",
"logout": "ログアウト",
"backToMailbox": "メールボックスに戻る"
},
"github": "ソースコードを入手"
}

View File

@@ -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. かわいい使い捨てメールサービス"
}
}
}

View File

@@ -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": "今すぐ始める"
}
}

View File

@@ -0,0 +1,6 @@
{
"title": "MoeMail - かわいい使い捨てメールサービス · オープンAPI",
"description": "安全で高速な使い捨てメールアドレスでプライバシーを守り、スパムを遠ざけます。メールボックス共有、即時受信、期限到来での自動失効に対応。完全な OpenAPI を提供し、開発者の統合や自動テストに最適です。",
"keywords": "使い捨てメール, 一時メール, 匿名メール, メール共有, プライバシー保護, スパム対策, 即時受信, 自動失効, 安全なメール, 登録確認, テスト用メール, 電話番号不要, 開発テスト, 自動テスト, メールAPI, OpenAPI, APIインターフェース, RESTful API, APIキー, 開発者ツール, MoeMail"
}

View File

@@ -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 はメールボックスの有効期間(ミリ秒)です。利用可能な値: 36000001時間、864000001日、6048000007日、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": "ユーザーロールの更新に失敗しました"
}
}

View File

@@ -2,11 +2,6 @@
"app": {
"name": "MoeMail"
},
"lang": {
"en": "English",
"zhCN": "简体中文",
"switch": "切换语言"
},
"actions": {
"ok": "确定",
"cancel": "取消",
@@ -22,5 +17,3 @@
},
"github": "获取网站源代码"
}

View File

@@ -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": "自動登入失敗,請手動登入"
}
}
}

View File

@@ -0,0 +1,19 @@
{
"app": {
"name": "MoeMail"
},
"actions": {
"ok": "確定",
"cancel": "取消",
"save": "儲存",
"delete": "刪除"
},
"nav": {
"home": "首頁",
"login": "登入",
"profile": "個人中心",
"logout": "登出",
"backToMailbox": "返回郵箱"
},
"github": "取得網站原始碼"
}

View File

@@ -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. 萌萌噠臨時郵箱服務"
}
}
}

View File

@@ -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": "開始使用"
}
}

View File

@@ -0,0 +1,6 @@
{
"title": "MoeMail - 萌萌噠臨時郵箱服務 · 開放 API",
"description": "安全、快速、一次性的臨時郵箱地址,保護您的隱私,遠離垃圾郵件。支援郵箱分享、即時收件,到期自動失效。提供完整的 OpenAPI 介面,方便開發者整合與自動化測試。",
"keywords": "臨時郵箱, 一次性郵箱, 匿名郵箱, 郵箱分享, 隱私保護, 垃圾郵件過濾, 即時收件, 自動過期, 安全郵箱, 註冊驗證, 臨時帳號, 無需手機號, 開發測試, 自動化測試, 郵件API, OpenAPI, API介面, RESTful API, API Key, 開發者工具, MoeMail"
}

View File

@@ -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 是郵箱的有效期毫秒可選值36000001 小時、864000001 天、6048000007 天、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": "更新使用者角色失敗"
}
}

View File

@@ -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*',
]
}
}