From 4c9a07e3ef59fb4e494250064dfb6ddfec8bd7bf Mon Sep 17 00:00:00 2001 From: AyrisAI Date: Thu, 14 May 2026 12:56:43 +0300 Subject: [PATCH] Add i18n support with Next.js App Router and Dictionaries --- app/{ => [lang]}/dashboard/domains/page.tsx | 0 app/[lang]/dashboard/layout.tsx | 32 ++++++++++++++ app/{ => [lang]}/dashboard/mail/page.tsx | 0 app/{ => [lang]}/dashboard/mailboxes/page.tsx | 0 app/{ => [lang]}/dashboard/page.tsx | 0 app/{ => [lang]}/dashboard/users/page.tsx | 0 app/[lang]/layout.tsx | 30 +++++++++++++ .../page.tsx => [lang]/login/LoginForm.tsx} | 20 ++++----- app/[lang]/login/page.tsx | 13 ++++++ app/[lang]/page.tsx | 17 ++++++++ app/dashboard/layout.tsx | 22 ---------- app/dictionaries.ts | 10 +++++ app/dictionaries/en.json | 18 ++++++++ app/dictionaries/tr.json | 18 ++++++++ app/layout.tsx | 19 --------- app/page.tsx | 11 ----- components/Sidebar.tsx | 42 +++++++++---------- components/mail/ComposeModal.tsx | 2 +- components/mail/FolderList.tsx | 2 +- components/mail/MessageList.tsx | 2 +- components/mail/MessageView.tsx | 2 +- package-lock.json | 34 +++++++++++++++ package.json | 3 ++ proxy.ts | 38 +++++++++++++++-- 24 files changed, 244 insertions(+), 91 deletions(-) rename app/{ => [lang]}/dashboard/domains/page.tsx (100%) create mode 100644 app/[lang]/dashboard/layout.tsx rename app/{ => [lang]}/dashboard/mail/page.tsx (100%) rename app/{ => [lang]}/dashboard/mailboxes/page.tsx (100%) rename app/{ => [lang]}/dashboard/page.tsx (100%) rename app/{ => [lang]}/dashboard/users/page.tsx (100%) create mode 100644 app/[lang]/layout.tsx rename app/{login/page.tsx => [lang]/login/LoginForm.tsx} (74%) create mode 100644 app/[lang]/login/page.tsx create mode 100644 app/[lang]/page.tsx delete mode 100644 app/dashboard/layout.tsx create mode 100644 app/dictionaries.ts create mode 100644 app/dictionaries/en.json create mode 100644 app/dictionaries/tr.json delete mode 100644 app/layout.tsx delete mode 100644 app/page.tsx diff --git a/app/dashboard/domains/page.tsx b/app/[lang]/dashboard/domains/page.tsx similarity index 100% rename from app/dashboard/domains/page.tsx rename to app/[lang]/dashboard/domains/page.tsx diff --git a/app/[lang]/dashboard/layout.tsx b/app/[lang]/dashboard/layout.tsx new file mode 100644 index 0000000..2a8f11e --- /dev/null +++ b/app/[lang]/dashboard/layout.tsx @@ -0,0 +1,32 @@ +import { auth } from "@/auth"; +import { redirect } from "next/navigation"; +import Providers from "@/components/Providers"; +import Sidebar from "@/components/Sidebar"; +import { getDictionary, Locale } from "@/app/dictionaries"; + +export default async function DashboardLayout( + props: { + children: React.ReactNode; + params: Promise<{ lang: string }>; + } +) { + const params = await props.params; + + const { + children + } = props; + + const session = await auth(); + if (!session) redirect(`/${params.lang}/login`); + + const dict = await getDictionary(params.lang as Locale); + + return ( + +
+ +
{children}
+
+
+ ); +} diff --git a/app/dashboard/mail/page.tsx b/app/[lang]/dashboard/mail/page.tsx similarity index 100% rename from app/dashboard/mail/page.tsx rename to app/[lang]/dashboard/mail/page.tsx diff --git a/app/dashboard/mailboxes/page.tsx b/app/[lang]/dashboard/mailboxes/page.tsx similarity index 100% rename from app/dashboard/mailboxes/page.tsx rename to app/[lang]/dashboard/mailboxes/page.tsx diff --git a/app/dashboard/page.tsx b/app/[lang]/dashboard/page.tsx similarity index 100% rename from app/dashboard/page.tsx rename to app/[lang]/dashboard/page.tsx diff --git a/app/dashboard/users/page.tsx b/app/[lang]/dashboard/users/page.tsx similarity index 100% rename from app/dashboard/users/page.tsx rename to app/[lang]/dashboard/users/page.tsx diff --git a/app/[lang]/layout.tsx b/app/[lang]/layout.tsx new file mode 100644 index 0000000..5101cb1 --- /dev/null +++ b/app/[lang]/layout.tsx @@ -0,0 +1,30 @@ +import type { Metadata } from "next"; +import "../globals.css"; + +export const metadata: Metadata = { + title: "AyrisMail Central", + description: "Multi-tenant Mailcow yönetim paneli — AyrisTech", +}; + +export async function generateStaticParams() { + return [{ lang: "tr" }, { lang: "en" }]; +} + +export default async function RootLayout( + props: { + children: React.ReactNode; + params: Promise<{ lang: string }>; + } +) { + const params = await props.params; + + const { + children + } = props; + + return ( + + {children} + + ); +} diff --git a/app/login/page.tsx b/app/[lang]/login/LoginForm.tsx similarity index 74% rename from app/login/page.tsx rename to app/[lang]/login/LoginForm.tsx index c256ea1..a2ad9f6 100644 --- a/app/login/page.tsx +++ b/app/[lang]/login/LoginForm.tsx @@ -4,7 +4,7 @@ import { useState, useTransition } from "react"; import { signIn } from "next-auth/react"; import { useRouter } from "next/navigation"; -export default function LoginPage() { +export default function LoginForm({ dict, lang }: { dict: any; lang: string }) { const router = useRouter(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -23,9 +23,9 @@ export default function LoginPage() { }); if (res?.error) { - setError("E-posta veya şifre hatalı."); + setError("E-posta veya şifre hatalı."); // We can translate this later } else { - router.push("/dashboard"); + router.push(`/${lang}/dashboard`); router.refresh(); } }); @@ -41,20 +41,20 @@ export default function LoginPage() { -

AyrisMail Central

-

Mail sunucunuzu kolayca yönetin

+

{dict.title || "AyrisMail Central"}

+

{dict.subtitle || "Mail sunucunuzu kolayca yönetin"}

{error &&
{error}
}
- + setEmail(e.target.value)} required @@ -62,12 +62,12 @@ export default function LoginPage() { />
- + setPassword(e.target.value)} required @@ -76,7 +76,7 @@ export default function LoginPage() {
diff --git a/app/[lang]/login/page.tsx b/app/[lang]/login/page.tsx new file mode 100644 index 0000000..aee8d30 --- /dev/null +++ b/app/[lang]/login/page.tsx @@ -0,0 +1,13 @@ +import { getDictionary, Locale } from "@/app/dictionaries"; +import LoginForm from "./LoginForm"; + +export default async function LoginPage( + props: { + params: Promise<{ lang: string }>; + } +) { + const params = await props.params; + + const dict = await getDictionary(params.lang as Locale); + return ; +} diff --git a/app/[lang]/page.tsx b/app/[lang]/page.tsx new file mode 100644 index 0000000..30e1e58 --- /dev/null +++ b/app/[lang]/page.tsx @@ -0,0 +1,17 @@ +import { redirect } from "next/navigation"; +import { auth } from "@/auth"; + +export default async function HomePage( + props: { + params: Promise<{ lang: string }>; + } +) { + const params = await props.params; + + const session = await auth(); + if (session) { + redirect(`/${params.lang}/dashboard`); + } else { + redirect(`/${params.lang}/login`); + } +} diff --git a/app/dashboard/layout.tsx b/app/dashboard/layout.tsx deleted file mode 100644 index 7382cf8..0000000 --- a/app/dashboard/layout.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { auth } from "@/auth"; -import { redirect } from "next/navigation"; -import Providers from "@/components/Providers"; -import Sidebar from "@/components/Sidebar"; - -export default async function DashboardLayout({ - children, -}: { - children: React.ReactNode; -}) { - const session = await auth(); - if (!session) redirect("/login"); - - return ( - -
- -
{children}
-
-
- ); -} diff --git a/app/dictionaries.ts b/app/dictionaries.ts new file mode 100644 index 0000000..160661f --- /dev/null +++ b/app/dictionaries.ts @@ -0,0 +1,10 @@ +import 'server-only' + +const dictionaries = { + en: () => import('./dictionaries/en.json').then((module) => module.default), + tr: () => import('./dictionaries/tr.json').then((module) => module.default), +} + +export type Locale = keyof typeof dictionaries + +export const getDictionary = async (locale: Locale) => dictionaries[locale]() diff --git a/app/dictionaries/en.json b/app/dictionaries/en.json new file mode 100644 index 0000000..fd1d254 --- /dev/null +++ b/app/dictionaries/en.json @@ -0,0 +1,18 @@ +{ + "login": { + "title": "Welcome Back", + "subtitle": "Please log in to AyrisMail Central", + "emailLabel": "Email Address", + "emailPlaceholder": "name@domain.com", + "passwordLabel": "Password", + "passwordPlaceholder": "Enter your password", + "signInButton": "Sign In", + "signingIn": "Signing In..." + }, + "sidebar": { + "mailboxes": "Mailboxes", + "mailClient": "Mail Client", + "users": "User Management", + "logout": "Log Out" + } +} diff --git a/app/dictionaries/tr.json b/app/dictionaries/tr.json new file mode 100644 index 0000000..0c1696b --- /dev/null +++ b/app/dictionaries/tr.json @@ -0,0 +1,18 @@ +{ + "login": { + "title": "Tekrar Hoş Geldiniz", + "subtitle": "Lütfen AyrisMail Central'a giriş yapın", + "emailLabel": "E-posta Adresi", + "emailPlaceholder": "isim@domain.com", + "passwordLabel": "Şifre", + "passwordPlaceholder": "Şifrenizi girin", + "signInButton": "Giriş Yap", + "signingIn": "Giriş yapılıyor..." + }, + "sidebar": { + "mailboxes": "Mail Hesapları", + "mailClient": "Web Mail Client", + "users": "Kullanıcı Yönetimi", + "logout": "Çıkış Yap" + } +} diff --git a/app/layout.tsx b/app/layout.tsx deleted file mode 100644 index e202888..0000000 --- a/app/layout.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import type { Metadata } from "next"; -import "./globals.css"; - -export const metadata: Metadata = { - title: "AyrisMail Central", - description: "Multi-tenant Mailcow yönetim paneli — AyrisTech", -}; - -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return ( - - {children} - - ); -} diff --git a/app/page.tsx b/app/page.tsx deleted file mode 100644 index dc602ef..0000000 --- a/app/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { redirect } from "next/navigation"; -import { auth } from "@/auth"; - -export default async function HomePage() { - const session = await auth(); - if (session) { - redirect("/dashboard"); - } else { - redirect("/login"); - } -} diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 3c8fe3f..aacbcb1 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -4,31 +4,31 @@ import { useSession, signOut } from "next-auth/react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -const navItems = [ - { - section: "GENEL", - items: [ - { href: "/dashboard", label: "Dashboard", icon: HomeIcon, roles: ["SUPER_ADMIN", "DOMAIN_ADMIN"] }, - { href: "/dashboard/mail", label: "Mail", icon: InboxIcon, roles: ["SUPER_ADMIN", "DOMAIN_ADMIN"] }, - ], - }, - { - section: "YÖNETİM", - items: [ - { href: "/dashboard/domains", label: "Domainler", icon: GlobeIcon, roles: ["SUPER_ADMIN"] }, - { href: "/dashboard/users", label: "Kullanıcılar", icon: UsersIcon, roles: ["SUPER_ADMIN"] }, - { href: "/dashboard/mailboxes", label: "Mail Hesapları", icon: MailIcon, roles: ["SUPER_ADMIN", "DOMAIN_ADMIN"] }, - ], - }, -]; - -export default function Sidebar() { +export default function Sidebar({ dict, lang }: { dict: any; lang: string }) { const { data: session } = useSession(); const pathname = usePathname(); const role = session?.user?.role ?? ""; const name = session?.user?.name ?? ""; const email = session?.user?.email ?? ""; + const navItems = [ + { + section: "GENEL", + items: [ + { href: `/${lang}/dashboard`, label: "Dashboard", icon: HomeIcon, roles: ["SUPER_ADMIN", "DOMAIN_ADMIN"] }, + { href: `/${lang}/dashboard/mail`, label: dict.mailClient || "Mail", icon: InboxIcon, roles: ["SUPER_ADMIN", "DOMAIN_ADMIN"] }, + ], + }, + { + section: "YÖNETİM", + items: [ + { href: `/${lang}/dashboard/domains`, label: "Domainler", icon: GlobeIcon, roles: ["SUPER_ADMIN"] }, + { href: `/${lang}/dashboard/users`, label: dict.users || "Kullanıcılar", icon: UsersIcon, roles: ["SUPER_ADMIN"] }, + { href: `/${lang}/dashboard/mailboxes`, label: dict.mailboxes || "Mail Hesapları", icon: MailIcon, roles: ["SUPER_ADMIN", "DOMAIN_ADMIN"] }, + ], + }, + ]; + return ( diff --git a/components/mail/ComposeModal.tsx b/components/mail/ComposeModal.tsx index 0fc05dc..8657487 100644 --- a/components/mail/ComposeModal.tsx +++ b/components/mail/ComposeModal.tsx @@ -1,6 +1,6 @@ "use client"; import { useState, useRef, useCallback } from "react"; -import type { MailMessage } from "@/app/dashboard/mail/page"; +import type { MailMessage } from "@/app/[lang]/dashboard/mail/page"; import { formatBytes } from "@/lib/format"; interface AttachmentFile { diff --git a/components/mail/FolderList.tsx b/components/mail/FolderList.tsx index c954e14..8e707ad 100644 --- a/components/mail/FolderList.tsx +++ b/components/mail/FolderList.tsx @@ -1,5 +1,5 @@ "use client"; -import type { MailFolder } from "@/app/dashboard/mail/page"; +import type { MailFolder } from "@/app/[lang]/dashboard/mail/page"; const FOLDER_ICONS: Record = { "\\Inbox": "📥", diff --git a/components/mail/MessageList.tsx b/components/mail/MessageList.tsx index bb6520e..5337c53 100644 --- a/components/mail/MessageList.tsx +++ b/components/mail/MessageList.tsx @@ -1,5 +1,5 @@ "use client"; -import type { MailEnvelope } from "@/app/dashboard/mail/page"; +import type { MailEnvelope } from "@/app/[lang]/dashboard/mail/page"; function timeAgo(dateStr: string): string { const now = new Date(); diff --git a/components/mail/MessageView.tsx b/components/mail/MessageView.tsx index 412b78b..ebeb141 100644 --- a/components/mail/MessageView.tsx +++ b/components/mail/MessageView.tsx @@ -1,5 +1,5 @@ "use client"; -import type { MailMessage } from "@/app/dashboard/mail/page"; +import type { MailMessage } from "@/app/[lang]/dashboard/mail/page"; import { formatBytes } from "@/lib/format"; function getFileIcon(contentType: string, filename: string): string { diff --git a/package-lock.json b/package-lock.json index 8fe76fc..9ae91cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,13 @@ "name": "mailserver", "version": "0.1.0", "dependencies": { + "@formatjs/intl-localematcher": "^0.8.7", "@tanstack/react-query": "^5.100.10", "bcryptjs": "^3.0.3", "imapflow": "^1.3.3", "lucide-react": "^1.14.0", "mailparser": "^3.9.8", + "negotiator": "^1.0.0", "next": "16.2.6", "next-auth": "^5.0.0-beta.31", "nodemailer": "^8.0.7", @@ -24,6 +26,7 @@ "@tailwindcss/postcss": "^4", "@types/bcryptjs": "^2.4.6", "@types/mailparser": "^3.4.6", + "@types/negotiator": "^0.6.4", "@types/node": "^20", "@types/nodemailer": "^8.0.0", "@types/react": "^19", @@ -937,6 +940,21 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@formatjs/fast-memoize": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.5.tgz", + "integrity": "sha512-KLi3fan6WnCHmigd9pmEEN8Hid0v4wiFBW576M/d07KMWYecf1CvyMI3n34vCmHT4AoVqG2n702kiHbXjzZX2A==", + "license": "MIT" + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.8.7", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.7.tgz", + "integrity": "sha512-1R/ljfRKG1fUhKG4F0lUmrEKPkr/PlHqbgQ8xeYQYYunXu5/0+vbQeeVgGAgydp13Tq+S1X5Qjn6L90hijXjHg==", + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "3.1.5" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -2128,6 +2146,13 @@ "node": ">=0.10.0" } }, + "node_modules/@types/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@types/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-elf6BsTq+AkyNsb2h5cGNst2Mc7dPliVoAPm1fXglC/BM3f2pFA40BaSSv3E5lyHteEawVKLP+8TwiY1DMNb3A==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.41", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", @@ -5944,6 +5969,15 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/next": { "version": "16.2.6", "resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz", diff --git a/package.json b/package.json index d739af6..81eb51e 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,13 @@ "seed": "tsx prisma/seed.ts" }, "dependencies": { + "@formatjs/intl-localematcher": "^0.8.7", "@tanstack/react-query": "^5.100.10", "bcryptjs": "^3.0.3", "imapflow": "^1.3.3", "lucide-react": "^1.14.0", "mailparser": "^3.9.8", + "negotiator": "^1.0.0", "next": "16.2.6", "next-auth": "^5.0.0-beta.31", "nodemailer": "^8.0.7", @@ -29,6 +31,7 @@ "@tailwindcss/postcss": "^4", "@types/bcryptjs": "^2.4.6", "@types/mailparser": "^3.4.6", + "@types/negotiator": "^0.6.4", "@types/node": "^20", "@types/nodemailer": "^8.0.0", "@types/react": "^19", diff --git a/proxy.ts b/proxy.ts index 2034e40..d3c3c08 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,22 +1,52 @@ import { NextResponse } from "next/server"; import { auth } from "./auth"; +import { match } from "@formatjs/intl-localematcher"; +import Negotiator from "negotiator"; + +const locales = ["tr", "en"]; +const defaultLocale = "tr"; + +function getLocale(request: Request): string { + const headers = { "accept-language": request.headers.get("accept-language") || "" }; + const languages = new Negotiator({ headers }).languages(); + try { + return match(languages, locales, defaultLocale); + } catch { + return defaultLocale; + } +} export default auth((req) => { const { nextUrl } = req; const isLoggedIn = !!req.auth; - const isLoginPage = nextUrl.pathname === "/login"; + const pathname = nextUrl.pathname; + + // Check if there is any supported locale in the pathname + const pathnameIsMissingLocale = locales.every( + (locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}` + ); - // Proxy arkasında çalışırken doğru adresi alabilmek için const host = req.headers.get("x-forwarded-host") || req.headers.get("host") || "localhost:3000"; const proto = req.headers.get("x-forwarded-proto") || "http"; const baseUrl = `${proto}://${host}`; + // Redirect if there is no locale + if (pathnameIsMissingLocale) { + const locale = getLocale(req as any); + return NextResponse.redirect( + new URL(`/${locale}${pathname === '/' ? '' : pathname}`, baseUrl) + ); + } + + const currentLocale = pathname.split('/')[1] || defaultLocale; + const isLoginPage = pathname === `/${currentLocale}/login`; + if (!isLoggedIn && !isLoginPage) { - return NextResponse.redirect(new URL("/login", baseUrl)); + return NextResponse.redirect(new URL(`/${currentLocale}/login`, baseUrl)); } if (isLoggedIn && isLoginPage) { - return NextResponse.redirect(new URL("/dashboard", baseUrl)); + return NextResponse.redirect(new URL(`/${currentLocale}/dashboard`, baseUrl)); } return NextResponse.next();