feat: add internationalization support with next-intl

This commit is contained in:
beilunyang
2025-10-13 00:57:32 +08:00
parent 0fcc4b9e85
commit d175017b51
46 changed files with 1436 additions and 432 deletions
+148
View File
@@ -0,0 +1,148 @@
import { NextIntlClientProvider } from "next-intl"
import { notFound } from "next/navigation"
import { getTranslations } from "next-intl/server"
import { i18n, type Locale } from "@/i18n/config"
import type { Metadata, Viewport } from "next"
import { FloatMenu } from "@/components/float-menu"
import { ThemeProvider } from "@/components/theme/theme-provider"
import { Toaster } from "@/components/ui/toaster"
import { cn } from "@/lib/utils"
import { zpix } from "../fonts"
import "../globals.css"
import { Providers } from "../providers"
export const runtime = "edge"
export const viewport: Viewport = {
themeColor: '#826DD9',
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
}
async function getMessages(locale: Locale) {
try {
const common = (await import(`@/i18n/messages/${locale}/common.json`)).default
const home = (await import(`@/i18n/messages/${locale}/home.json`)).default
const auth = (await import(`@/i18n/messages/${locale}/auth.json`)).default
const metadata = (await import(`@/i18n/messages/${locale}/metadata.json`)).default
const emails = (await import(`@/i18n/messages/${locale}/emails.json`)).default
const profile = (await import(`@/i18n/messages/${locale}/profile.json`)).default
return { common, home, auth, metadata, emails, profile }
} catch (error) {
console.error(`Failed to load messages for locale ${locale}:`, error)
return { common: {}, home: {}, auth: {}, metadata: {}, emails: {}, profile: {} }
}
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>
}): Promise<Metadata> {
const { locale: localeFromParams } = await params
const locale = localeFromParams as Locale
const t = await getTranslations({ locale, namespace: "metadata" })
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://moemail.app"
// Generate hreflang links for all supported locales
const languages: Record<string, string> = {}
i18n.locales.forEach((loc) => {
languages[loc] = `${baseUrl}/${loc}`
})
return {
title: t("title"),
description: t("description"),
keywords: t("keywords"),
authors: [{ name: "SoftMoe Studio" }],
creator: "SoftMoe Studio",
publisher: "SoftMoe Studio",
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
},
},
openGraph: {
type: "website",
locale: locale === "zh-CN" ? "zh_CN" : locale,
url: `${baseUrl}/${locale}`,
title: t("title"),
description: t("description"),
siteName: "MoeMail",
},
twitter: {
card: "summary_large_image",
title: t("title"),
description: t("description"),
},
alternates: {
canonical: `${baseUrl}/${locale}`,
languages,
},
manifest: '/manifest.json',
icons: [
{ rel: 'apple-touch-icon', url: '/icons/icon-192x192.png' },
],
}
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ locale: string }>
}) {
const { locale: localeFromParams } = await params
const locale = localeFromParams as Locale
if (!i18n.locales.includes(locale)) {
notFound()
}
const messages = await getMessages(locale)
return (
<html lang={locale} suppressHydrationWarning>
<head>
<meta name="application-name" content="MoeMail" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="MoeMail" />
<meta name="format-detection" content="telephone=no" />
<meta name="mobile-web-app-capable" content="yes" />
</head>
<body
className={cn(
zpix.variable,
"font-zpix min-h-screen antialiased",
"bg-background text-foreground",
"transition-colors duration-300"
)}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange={false}
storageKey="temp-mail-theme"
>
<Providers>
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
<FloatMenu />
</NextIntlClientProvider>
</Providers>
<Toaster />
</ThemeProvider>
</body>
</html>
)
}
+27
View File
@@ -0,0 +1,27 @@
import { LoginForm } from "@/components/auth/login-form"
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
import type { Locale } from "@/i18n/config"
export const runtime = "edge"
export default async function LoginPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale: localeFromParams } = await params
const locale = localeFromParams as Locale
const session = await auth()
if (session?.user) {
redirect(`/${locale}`)
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
<LoginForm />
</div>
)
}
+39
View File
@@ -0,0 +1,39 @@
import { Header } from "@/components/layout/header"
import { ThreeColumnLayout } from "@/components/emails/three-column-layout"
import { NoPermissionDialog } from "@/components/no-permission-dialog"
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
import { checkPermission } from "@/lib/auth"
import { PERMISSIONS } from "@/lib/permissions"
import type { Locale } from "@/i18n/config"
export const runtime = "edge"
export default async function MoePage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale: localeFromParams } = await params
const locale = localeFromParams as Locale
const session = await auth()
if (!session?.user) {
redirect(`/${locale}`)
}
const hasPermission = await checkPermission(PERMISSIONS.MANAGE_EMAIL)
return (
<div className="bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 h-screen">
<div className="container mx-auto h-full px-4 lg:px-8 max-w-[1600px]">
<Header />
<main className="h-full">
<ThreeColumnLayout />
{!hasPermission && <NoPermissionDialog />}
</main>
</div>
</div>
)
}
+69
View File
@@ -0,0 +1,69 @@
import { Header } from "@/components/layout/header"
import { auth } from "@/lib/auth"
import { Shield, Mail, Clock } from "lucide-react"
import { ActionButton } from "@/components/home/action-button"
import { FeatureCard } from "@/components/home/feature-card"
import { getTranslations } from "next-intl/server"
import type { Locale } from "@/i18n/config"
export const runtime = "edge"
export default async function Home({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale: localeFromParams } = await params
const locale = localeFromParams as Locale
const session = await auth()
const t = await getTranslations({ locale, namespace: "home" })
return (
<div className="bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 h-screen">
<div className="container mx-auto h-full px-4 lg:px-8 max-w-[1600px]">
<Header />
<main className="h-full">
<div className="h-[calc(100vh-4rem)] flex flex-col items-center justify-center text-center px-4 relative">
<div className="absolute inset-0 -z-10 bg-grid-primary/5" />
<div className="w-full max-w-3xl mx-auto space-y-12 py-8">
<div className="space-y-4">
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold tracking-wider">
<span className="bg-clip-text text-transparent bg-gradient-to-r from-primary to-purple-600">
{t("title")}
</span>
</h1>
<p className="text-lg sm:text-xl text-gray-600 dark:text-gray-300 tracking-wide">
{t("subtitle")}
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 px-4 sm:px-0">
<FeatureCard
icon={<Shield className="w-5 h-5" />}
title={t("features.privacy.title")}
description={t("features.privacy.description")}
/>
<FeatureCard
icon={<Mail className="w-5 h-5" />}
title={t("features.instant.title")}
description={t("features.instant.description")}
/>
<FeatureCard
icon={<Clock className="w-5 h-5" />}
title={t("features.expiry.title")}
description={t("features.expiry.description")}
/>
</div>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 px-4 sm:px-0">
<ActionButton isLoggedIn={!!session} />
</div>
</div>
</div>
</main>
</div>
</div>
)
}
+33
View File
@@ -0,0 +1,33 @@
import { Header } from "@/components/layout/header"
import { ProfileCard } from "@/components/profile/profile-card"
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
import type { Locale } from "@/i18n/config"
export const runtime = "edge"
export default async function ProfilePage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale: localeFromParams } = await params
const locale = localeFromParams as Locale
const session = await auth()
if (!session?.user) {
redirect(`/${locale}`)
}
return (
<div className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
<div className="container mx-auto px-4 lg:px-8 max-w-[1600px]">
<Header />
<main className="pt-20 pb-5">
<ProfileCard user={session.user} />
</main>
</div>
</div>
)
}