Add i18n support with Next.js App Router and Dictionaries

This commit is contained in:
AyrisAI
2026-05-14 12:56:43 +03:00
parent 89d74ce3fe
commit 4c9a07e3ef
24 changed files with 244 additions and 91 deletions

View File

@@ -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 (
<Providers>
<div className="app-layout">
<Sidebar dict={dict.sidebar} lang={params.lang} />
<div className="main-content">{children}</div>
</div>
</Providers>
);
}

30
app/[lang]/layout.tsx Normal file
View File

@@ -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 (
<html lang={params.lang}>
<body>{children}</body>
</html>
);
}

View File

@@ -4,7 +4,7 @@ import { useState, useTransition } from "react";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
export default function LoginPage() { export default function LoginForm({ dict, lang }: { dict: any; lang: string }) {
const router = useRouter(); const router = useRouter();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@@ -23,9 +23,9 @@ export default function LoginPage() {
}); });
if (res?.error) { if (res?.error) {
setError("E-posta veya şifre hatalı."); setError("E-posta veya şifre hatalı."); // We can translate this later
} else { } else {
router.push("/dashboard"); router.push(`/${lang}/dashboard`);
router.refresh(); router.refresh();
} }
}); });
@@ -41,20 +41,20 @@ export default function LoginPage() {
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" /> <path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg> </svg>
</div> </div>
<h1 className="login-title">AyrisMail Central</h1> <h1 className="login-title">{dict.title || "AyrisMail Central"}</h1>
<p className="login-sub">Mail sunucunuzu kolayca yönetin</p> <p className="login-sub">{dict.subtitle || "Mail sunucunuzu kolayca yönetin"}</p>
</div> </div>
<div className="card"> <div className="card">
<form onSubmit={handleSubmit} className="form-group"> <form onSubmit={handleSubmit} className="form-group">
{error && <div className="error-msg">{error}</div>} {error && <div className="error-msg">{error}</div>}
<div> <div>
<label htmlFor="email" className="label">E-posta</label> <label htmlFor="email" className="label">{dict.emailLabel || "E-posta"}</label>
<input <input
id="email" id="email"
type="email" type="email"
className="input" className="input"
placeholder="admin@ayristech.com" placeholder={dict.emailPlaceholder || "admin@ayristech.com"}
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
@@ -62,12 +62,12 @@ export default function LoginPage() {
/> />
</div> </div>
<div> <div>
<label htmlFor="password" className="label">Şifre</label> <label htmlFor="password" className="label">{dict.passwordLabel || "Şifre"}</label>
<input <input
id="password" id="password"
type="password" type="password"
className="input" className="input"
placeholder="••••••••" placeholder={dict.passwordPlaceholder || "••••••••"}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
@@ -76,7 +76,7 @@ export default function LoginPage() {
</div> </div>
<button type="submit" className="btn btn-primary" disabled={isPending} style={{ width: "100%", justifyContent: "center", padding: "10px" }}> <button type="submit" className="btn btn-primary" disabled={isPending} style={{ width: "100%", justifyContent: "center", padding: "10px" }}>
{isPending ? <span className="spinner" /> : null} {isPending ? <span className="spinner" /> : null}
{isPending ? "Giriş yapılıyor..." : "Giriş Yap"} {isPending ? (dict.signingIn || "Giriş yapılıyor...") : (dict.signInButton || "Giriş Yap")}
</button> </button>
</form> </form>
</div> </div>

13
app/[lang]/login/page.tsx Normal file
View File

@@ -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 <LoginForm dict={dict.login} lang={params.lang} />;
}

17
app/[lang]/page.tsx Normal file
View File

@@ -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`);
}
}

View File

@@ -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 (
<Providers>
<div className="app-layout">
<Sidebar />
<div className="main-content">{children}</div>
</div>
</Providers>
);
}

10
app/dictionaries.ts Normal file
View File

@@ -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]()

18
app/dictionaries/en.json Normal file
View File

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

18
app/dictionaries/tr.json Normal file
View File

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

View File

@@ -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 (
<html lang="tr">
<body>{children}</body>
</html>
);
}

View File

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

View File

@@ -4,31 +4,31 @@ import { useSession, signOut } from "next-auth/react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
const navItems = [ export default function Sidebar({ dict, lang }: { dict: any; lang: string }) {
{
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() {
const { data: session } = useSession(); const { data: session } = useSession();
const pathname = usePathname(); const pathname = usePathname();
const role = session?.user?.role ?? ""; const role = session?.user?.role ?? "";
const name = session?.user?.name ?? ""; const name = session?.user?.name ?? "";
const email = session?.user?.email ?? ""; 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 ( return (
<aside className="sidebar"> <aside className="sidebar">
<div className="sidebar-logo"> <div className="sidebar-logo">
@@ -80,11 +80,11 @@ export default function Sidebar() {
style={{ width: "100%", justifyContent: "center", fontSize: "12px" }} style={{ width: "100%", justifyContent: "center", fontSize: "12px" }}
onClick={async () => { onClick={async () => {
await signOut({ redirect: false }); await signOut({ redirect: false });
window.location.href = "/login"; window.location.href = `/${lang}/login`;
}} }}
> >
<LogOutIcon /> <LogOutIcon />
Çıkış Yap {dict.logout || ıkış Yap"}
</button> </button>
</div> </div>
</aside> </aside>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useRef, useCallback } from "react"; 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"; import { formatBytes } from "@/lib/format";
interface AttachmentFile { interface AttachmentFile {

View File

@@ -1,5 +1,5 @@
"use client"; "use client";
import type { MailFolder } from "@/app/dashboard/mail/page"; import type { MailFolder } from "@/app/[lang]/dashboard/mail/page";
const FOLDER_ICONS: Record<string, string> = { const FOLDER_ICONS: Record<string, string> = {
"\\Inbox": "📥", "\\Inbox": "📥",

View File

@@ -1,5 +1,5 @@
"use client"; "use client";
import type { MailEnvelope } from "@/app/dashboard/mail/page"; import type { MailEnvelope } from "@/app/[lang]/dashboard/mail/page";
function timeAgo(dateStr: string): string { function timeAgo(dateStr: string): string {
const now = new Date(); const now = new Date();

View File

@@ -1,5 +1,5 @@
"use client"; "use client";
import type { MailMessage } from "@/app/dashboard/mail/page"; import type { MailMessage } from "@/app/[lang]/dashboard/mail/page";
import { formatBytes } from "@/lib/format"; import { formatBytes } from "@/lib/format";
function getFileIcon(contentType: string, filename: string): string { function getFileIcon(contentType: string, filename: string): string {

34
package-lock.json generated
View File

@@ -8,11 +8,13 @@
"name": "mailserver", "name": "mailserver",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@formatjs/intl-localematcher": "^0.8.7",
"@tanstack/react-query": "^5.100.10", "@tanstack/react-query": "^5.100.10",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"imapflow": "^1.3.3", "imapflow": "^1.3.3",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"mailparser": "^3.9.8", "mailparser": "^3.9.8",
"negotiator": "^1.0.0",
"next": "16.2.6", "next": "16.2.6",
"next-auth": "^5.0.0-beta.31", "next-auth": "^5.0.0-beta.31",
"nodemailer": "^8.0.7", "nodemailer": "^8.0.7",
@@ -24,6 +26,7 @@
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/mailparser": "^3.4.6", "@types/mailparser": "^3.4.6",
"@types/negotiator": "^0.6.4",
"@types/node": "^20", "@types/node": "^20",
"@types/nodemailer": "^8.0.0", "@types/nodemailer": "^8.0.0",
"@types/react": "^19", "@types/react": "^19",
@@ -937,6 +940,21 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "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": { "node_modules/@humanfs/core": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -2128,6 +2146,13 @@
"node": ">=0.10.0" "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": { "node_modules/@types/node": {
"version": "20.19.41", "version": "20.19.41",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
@@ -5944,6 +5969,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/next": {
"version": "16.2.6", "version": "16.2.6",
"resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz", "resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz",

View File

@@ -13,11 +13,13 @@
"seed": "tsx prisma/seed.ts" "seed": "tsx prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@formatjs/intl-localematcher": "^0.8.7",
"@tanstack/react-query": "^5.100.10", "@tanstack/react-query": "^5.100.10",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"imapflow": "^1.3.3", "imapflow": "^1.3.3",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"mailparser": "^3.9.8", "mailparser": "^3.9.8",
"negotiator": "^1.0.0",
"next": "16.2.6", "next": "16.2.6",
"next-auth": "^5.0.0-beta.31", "next-auth": "^5.0.0-beta.31",
"nodemailer": "^8.0.7", "nodemailer": "^8.0.7",
@@ -29,6 +31,7 @@
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/mailparser": "^3.4.6", "@types/mailparser": "^3.4.6",
"@types/negotiator": "^0.6.4",
"@types/node": "^20", "@types/node": "^20",
"@types/nodemailer": "^8.0.0", "@types/nodemailer": "^8.0.0",
"@types/react": "^19", "@types/react": "^19",

View File

@@ -1,22 +1,52 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "./auth"; 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) => { export default auth((req) => {
const { nextUrl } = req; const { nextUrl } = req;
const isLoggedIn = !!req.auth; 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 host = req.headers.get("x-forwarded-host") || req.headers.get("host") || "localhost:3000";
const proto = req.headers.get("x-forwarded-proto") || "http"; const proto = req.headers.get("x-forwarded-proto") || "http";
const baseUrl = `${proto}://${host}`; 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) { if (!isLoggedIn && !isLoginPage) {
return NextResponse.redirect(new URL("/login", baseUrl)); return NextResponse.redirect(new URL(`/${currentLocale}/login`, baseUrl));
} }
if (isLoggedIn && isLoginPage) { if (isLoggedIn && isLoginPage) {
return NextResponse.redirect(new URL("/dashboard", baseUrl)); return NextResponse.redirect(new URL(`/${currentLocale}/dashboard`, baseUrl));
} }
return NextResponse.next(); return NextResponse.next();