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,444 @@
"use client";
import { useState, useEffect, useTransition, useCallback } from "react";
import { useSession } from "next-auth/react";
import { formatBytes } from "@/lib/format";
interface Mailbox {
username: string;
name: string;
local_part: string;
domain: string;
quota: number;
quota_used: number;
active: string; // "1" | "0"
}
interface Domain {
domain_name: string;
}
export default function MailboxesPage() {
const { data: session } = useSession();
const [domains, setDomains] = useState<Domain[]>([]);
const [selectedDomain, setSelectedDomain] = useState("");
const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
const [loading, setLoading] = useState(false);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState<string | null>(null);
const [showInfoModal, setShowInfoModal] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const [search, setSearch] = useState("");
const [createForm, setCreateForm] = useState({ local_part: "", name: "", password: "", quota: 3072 });
const [newPassword, setNewPassword] = useState("");
const [formError, setFormError] = useState("");
useEffect(() => {
fetch("/api/domains")
.then((r) => r.json())
.then((data: Domain[]) => {
if (Array.isArray(data) && data.length > 0) {
setDomains(data);
setSelectedDomain(data[0].domain_name);
}
});
}, []);
const fetchMailboxes = useCallback(async (domain: string) => {
if (!domain) return;
setLoading(true);
const res = await fetch(`/api/mailboxes?domain=${encodeURIComponent(domain)}`);
const data = await res.json();
setMailboxes(Array.isArray(data) ? data : []);
setLoading(false);
}, []);
useEffect(() => {
if (selectedDomain) fetchMailboxes(selectedDomain);
}, [selectedDomain, fetchMailboxes]);
const handleCreate = (e: React.FormEvent) => {
e.preventDefault();
setFormError("");
startTransition(async () => {
const res = await fetch("/api/mailboxes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...createForm, domain: selectedDomain }),
});
const data = await res.json();
if (res.ok) {
setShowCreateModal(false);
setCreateForm({ local_part: "", name: "", password: "", quota: 3072 });
fetchMailboxes(selectedDomain);
} else {
const msg = Array.isArray(data)
? data.map((d: { msg?: unknown }) => JSON.stringify(d.msg)).join(", ")
: (data?.error ?? "Mailcow bağlantısını kontrol edin");
setFormError(String(msg));
}
});
};
const handleDelete = (username: string) => {
if (!confirm(`"${username}" hesabını silmek istediğinizden emin misiniz?`)) return;
startTransition(async () => {
await fetch(`/api/mailboxes/${encodeURIComponent(username)}`, { method: "DELETE" });
setMailboxes((prev) => prev.filter((m) => m.username !== username));
});
};
const handleToggle = (username: string, active: string) => {
const newActive = String(active) === "1" ? 0 : 1;
startTransition(async () => {
await fetch(`/api/mailboxes/${encodeURIComponent(username)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ active: newActive }),
});
setMailboxes((prev) =>
prev.map((m) => m.username === username ? { ...m, active: String(newActive) } : m)
);
});
};
const handlePasswordChange = (e: React.FormEvent) => {
e.preventDefault();
if (!showPasswordModal) return;
startTransition(async () => {
await fetch(`/api/mailboxes/${encodeURIComponent(showPasswordModal)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: newPassword }),
});
setShowPasswordModal(null);
setNewPassword("");
});
};
const filtered = mailboxes.filter(
(m) =>
m.username.toLowerCase().includes(search.toLowerCase()) ||
m.name.toLowerCase().includes(search.toLowerCase())
);
const isSuperAdmin = session?.user?.role === "SUPER_ADMIN";
return (
<>
<div className="page-header">
<div>
<h1 className="page-title">Mail Hesapları</h1>
<p className="page-subtitle">
{selectedDomain
? `${selectedDomain}${mailboxes.length} hesap`
: "Domain seçin"}
</p>
</div>
<div style={{ display: "flex", gap: 10, alignItems: "center" }}>
<select
className="input"
style={{ width: "auto", minWidth: 200 }}
value={selectedDomain}
onChange={(e) => setSelectedDomain(e.target.value)}
>
{domains.map((d) => (
<option key={d.domain_name} value={d.domain_name}>
{d.domain_name}
</option>
))}
</select>
<button
className="btn btn-ghost"
onClick={() => fetchMailboxes(selectedDomain)}
disabled={!selectedDomain}
>
<RefreshIcon />
</button>
<button
className="btn btn-primary"
onClick={() => setShowCreateModal(true)}
disabled={!selectedDomain}
>
<PlusIcon /> Hesap Ekle
</button>
</div>
</div>
<div className="page-body">
<div className="search-bar">
<div className="search-input-wrap">
<span className="search-icon"><SearchIcon /></span>
<input
type="text"
className="input search-input"
placeholder="E-posta veya isim ara..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
<div className="table-wrap">
{loading ? (
<div className="empty-state">
<span className="spinner" style={{ width: 24, height: 24 }} />
</div>
) : filtered.length === 0 ? (
<div className="empty-state">
<div className="empty-icon"><MailIcon size={24} /></div>
<div style={{ fontWeight: 600 }}>
{selectedDomain ? "Bu domainde mail hesabı yok" : "Domain seçin"}
</div>
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
{selectedDomain ? '"Hesap Ekle" butonuna tıklayın' : "Sol üstteki listeden domain seçin"}
</div>
</div>
) : (
<table>
<thead>
<tr>
<th>E-posta</th>
<th>Ad Soyad</th>
<th>Kota</th>
<th>Durum</th>
<th>İşlemler</th>
</tr>
</thead>
<tbody>
{filtered.map((m) => {
const usedPct = m.quota > 0 ? Math.min((m.quota_used / m.quota) * 100, 100) : 0;
return (
<tr key={m.username}>
<td>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{
width: 30, height: 30, borderRadius: 6,
background: "var(--accent-dim)", color: "var(--accent-hover)",
display: "flex", alignItems: "center", justifyContent: "center",
flexShrink: 0, fontSize: 13, fontWeight: 700,
}}>
{m.local_part[0]?.toUpperCase()}
</div>
<span style={{ fontWeight: 500 }}>{m.username}</span>
</div>
</td>
<td style={{ color: "var(--text-secondary)" }}>{m.name}</td>
<td style={{ minWidth: 160 }}>
<div style={{ fontSize: 11, color: "var(--text-secondary)", marginBottom: 4 }}>
{formatBytes(m.quota_used)} / {formatBytes(m.quota)}
</div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<div className="progress-bar">
<div className={`progress-fill ${usedPct > 80 ? "danger" : ""}`} style={{ width: `${usedPct}%` }} />
</div>
<span style={{ fontSize: 11, color: "var(--text-muted)", flexShrink: 0 }}>{Math.round(usedPct)}%</span>
</div>
</td>
<td>
<span className={`badge ${String(m.active) === "1" ? "badge-green" : "badge-red"}`}>
{String(m.active) === "1" ? "● Aktif" : "● Pasif"}
</span>
</td>
<td>
<div style={{ display: "flex", gap: 6 }}>
<button
className="btn btn-ghost btn-sm"
onClick={() => setShowInfoModal(m.username)}
title="Bağlantı Bilgileri"
>
<InfoIcon />
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setShowPasswordModal(m.username)}
title="Şifre Değiştir"
>
<KeyIcon />
</button>
<button
className={`btn btn-sm ${String(m.active) === "1" ? "btn-ghost" : "btn-success"}`}
onClick={() => handleToggle(m.username, m.active)}
title={String(m.active) === "1" ? "Pasife Al" : "Aktif Et"}
>
{String(m.active) === "1" ? <PauseIcon /> : <PlayIcon />}
</button>
<button
className="btn btn-danger btn-sm"
onClick={() => handleDelete(m.username)}
title="Sil"
>
<TrashIcon />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</div>
{/* Create Modal */}
{showCreateModal && (
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && setShowCreateModal(false)}>
<div className="modal">
<div className="modal-header">
<h2 className="modal-title">Yeni Mail Hesabı</h2>
<button className="modal-close" onClick={() => setShowCreateModal(false)}><XIcon /></button>
</div>
<form onSubmit={handleCreate}>
<div className="modal-body form-group">
{formError && <div className="error-msg">{formError}</div>}
<div>
<label className="label">Kullanıcı Adı</label>
<div style={{ display: "flex" }}>
<input
type="text" className="input"
style={{ borderRadius: "var(--radius) 0 0 var(--radius)" }}
placeholder="info"
value={createForm.local_part}
onChange={(e) => setCreateForm({ ...createForm, local_part: e.target.value })}
required
/>
<span style={{
background: "var(--bg-hover)", border: "1px solid var(--border)", borderLeft: "none",
padding: "8px 12px", borderRadius: "0 var(--radius) var(--radius) 0",
color: "var(--text-secondary)", fontSize: 13, whiteSpace: "nowrap",
}}>
@{selectedDomain}
</span>
</div>
</div>
<div>
<label className="label">Ad Soyad</label>
<input type="text" className="input" placeholder="Emina Karabudak"
value={createForm.name}
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
required />
</div>
<div>
<label className="label">Şifre</label>
<input type="password" className="input" placeholder="Güçlü bir şifre"
value={createForm.password}
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
required />
</div>
<div>
<label className="label">Kota (MB)</label>
<input type="number" className="input" value={createForm.quota} min={100} max={102400}
onChange={(e) => setCreateForm({ ...createForm, quota: parseInt(e.target.value) || 3072 })} />
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-ghost" onClick={() => setShowCreateModal(false)}>İptal</button>
<button type="submit" className="btn btn-primary" disabled={isPending}>
{isPending ? <span className="spinner" /> : <PlusIcon />} Oluştur
</button>
</div>
</form>
</div>
</div>
)}
{/* Password Modal */}
{showPasswordModal && (
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && setShowPasswordModal(null)}>
<div className="modal">
<div className="modal-header">
<h2 className="modal-title">Şifre Değiştir</h2>
<button className="modal-close" onClick={() => setShowPasswordModal(null)}><XIcon /></button>
</div>
<form onSubmit={handlePasswordChange}>
<div className="modal-body form-group">
<div style={{ fontSize: 13, color: "var(--text-secondary)" }}>
<strong style={{ color: "var(--text-primary)" }}>{showPasswordModal}</strong> için yeni şifre
</div>
<div>
<label className="label">Yeni Şifre</label>
<input type="password" className="input" placeholder="Yeni şifre"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required autoFocus />
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-ghost" onClick={() => setShowPasswordModal(null)}>İptal</button>
<button type="submit" className="btn btn-primary" disabled={isPending}>
{isPending ? <span className="spinner" /> : <KeyIcon />} Güncelle
</button>
</div>
</form>
</div>
</div>
)}
{/* Info Modal */}
{showInfoModal && (
<div className="modal-overlay" onClick={(e) => e.target === e.currentTarget && setShowInfoModal(null)}>
<div className="modal">
<div className="modal-header">
<h2 className="modal-title">İstemci Bağlantı Bilgileri</h2>
<button className="modal-close" onClick={() => setShowInfoModal(null)}><XIcon /></button>
</div>
<div className="modal-body form-group">
<div style={{ fontSize: 13, color: "var(--text-secondary)", marginBottom: 10 }}>
<strong>{showInfoModal}</strong> hesabını Apple Mail, Outlook veya telefonunuza kurmak için aşağıdaki bilgileri kullanın:
</div>
<div style={{ background: "var(--bg-hover)", padding: 12, borderRadius: "var(--radius)", border: "1px solid var(--border)", fontSize: 13 }}>
<div style={{ marginBottom: 12 }}>
<div style={{ color: "var(--text-secondary)", fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: 0.5 }}>IMAP (Gelen Sunucu)</div>
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 4, alignItems: "center" }}>
<span>Sunucu: <strong>mail.ayris.tech</strong></span>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<button className="btn btn-ghost btn-sm" style={{ padding: "4px 8px", fontSize: 11 }} onClick={() => navigator.clipboard.writeText("mail.ayris.tech")} title="Kopyala"><CopyIcon /> Kopyala</button>
</div>
</div>
<div style={{ marginTop: 2 }}>Port: <strong>993</strong> (SSL/TLS)</div>
</div>
<div style={{ marginBottom: 12, paddingTop: 12, borderTop: "1px solid var(--border)" }}>
<div style={{ color: "var(--text-secondary)", fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: 0.5 }}>SMTP (Giden Sunucu)</div>
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 4, alignItems: "center" }}>
<span>Sunucu: <strong>mail.ayris.tech</strong></span>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<button className="btn btn-ghost btn-sm" style={{ padding: "4px 8px", fontSize: 11 }} onClick={() => navigator.clipboard.writeText("mail.ayris.tech")} title="Kopyala"><CopyIcon /> Kopyala</button>
</div>
</div>
<div style={{ marginTop: 2 }}>Port: <strong>587</strong> (STARTTLS) <span style={{ color: "var(--text-secondary)" }}>veya 465 (SSL)</span></div>
</div>
<div style={{ paddingTop: 12, borderTop: "1px solid var(--border)" }}>
<div style={{ color: "var(--text-secondary)", fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: 0.5 }}>Kimlik Doğrulama</div>
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 4, alignItems: "center" }}>
<span>Kullanıcı Adı: <strong>{showInfoModal}</strong></span>
<button className="btn btn-ghost btn-sm" style={{ padding: "4px 8px", fontSize: 11 }} onClick={() => navigator.clipboard.writeText(showInfoModal)} title="Kopyala"><CopyIcon /> Kopyala</button>
</div>
<div style={{ color: "var(--text-secondary)", marginTop: 4 }}>Şifre: <span style={{ fontStyle: "italic" }}>Hesap oluştururken belirlediğiniz şifre</span></div>
</div>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-primary" onClick={() => setShowInfoModal(null)}>Tamam</button>
</div>
</div>
</div>
)}
</>
);
}
// Icons
function PlusIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14"/><path d="M12 5v14"/></svg>; }
function SearchIcon() { return <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>; }
function RefreshIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></svg>; }
function MailIcon({ size = 13 }: { size?: number }) { return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>; }
function TrashIcon() { return <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>; }
function KeyIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/></svg>; }
function PauseIcon() { return <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>; }
function PlayIcon() { return <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>; }
function XIcon() { return <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>; }
function InfoIcon() { return <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>; }
function CopyIcon() { return <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>; }