feat(i18n): update settings and dashboard layout for dynamic translations

This commit is contained in:
poyrazavsever
2026-07-20 11:08:32 +03:00
parent e5d609426d
commit 542cb66759
11 changed files with 496 additions and 30 deletions
+3 -3
View File
@@ -132,7 +132,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
<div key={index} className="flex items-center justify-between gap-6 text-xs">
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: entry.color }} />
<span className="text-muted-foreground">{entry.name === 'income' ? 'Gelir' : 'Gider'}</span>
<span className="text-muted-foreground">{entry.name === 'income' ? t('dashboard.charts.income') : t('dashboard.charts.expense')}</span>
</div>
<span className="font-semibold text-foreground">
{formatCurrency(Number(entry.value ?? 0))}
@@ -247,7 +247,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
</p>
</div>
<Badge variant={project.status === 'completed' ? 'secondary' : 'default'} className="capitalize text-[10px] px-1.5 py-0">
{project.status === 'completed' ? 'Tamamlandı' : project.status === 'active' ? 'Aktif' : 'Beklemede'}
{project.status === 'completed' ? t('dashboard.status.completed') : project.status === 'active' ? t('dashboard.status.active') : t('dashboard.status.pending')}
</Badge>
</div>
</PendingLink>
@@ -274,7 +274,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
</div>
<div className="flex-1">
<p className="text-sm font-medium">{client.name}</p>
<p className="text-xs text-muted-foreground">{client.company_name || "Bireysel"}</p>
<p className="text-xs text-muted-foreground">{client.company_name || t("dashboard.clients.individual")}</p>
</div>
<div className="text-xs text-muted-foreground">
{new Date(client.created_at).toLocaleDateString(getDocumentIntlLocale(), { month: "short", day: "numeric" })}
+1 -19
View File
@@ -19,20 +19,11 @@ export default async function DashboardLayout({
const translator = createTranslator(resolvedLocale.locale, [
"common",
"navigation",
"dashboard",
"clients",
"projects",
"tasks",
"calendar",
"finance",
"journal",
"chat",
"settings",
"status",
"validation",
]);
const t = translator.t;
const displayName = profile.displayName || user.name || user.email.split("@")[0] || "Neta Kullanıcısı";
const displayName = profile.displayName || user.name || user.email.split("@")[0] || t("navigation.account.defaultUser", { fallback: "Neta Kullanıcısı" });
const shortName =
displayName
@@ -55,15 +46,6 @@ export default async function DashboardLayout({
i18n={getClientI18nPayload(resolvedLocale.locale, [
"common",
"navigation",
"dashboard",
"clients",
"projects",
"tasks",
"calendar",
"finance",
"journal",
"chat",
"settings",
"status",
"validation",
])}
+13 -1
View File
@@ -1,6 +1,10 @@
import { DashboardClient, type DashboardData } from "./dashboard-client";
import { parseDashboardRange, resolveDashboardRange } from "@/server/services/analytics-range";
import { requireFreelancerBackend } from "@/server/web/freelancer";
import { requireFreelancer } from "@/server/auth/session";
import { resolveFreelancerLocale } from "@/server/i18n/resolver";
import { getClientI18nPayload } from "@/server/i18n/translator";
import { I18nProvider } from "@/components/i18n/i18n-provider";
export const metadata = { title: "Dashboard" };
@@ -9,6 +13,10 @@ export default async function DashboardPage({
}: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const context = await requireFreelancer();
const resolvedLocale = await resolveFreelancerLocale(context);
const payload = getClientI18nPayload(resolvedLocale.locale, ["dashboard"]);
const params = await searchParams;
const range = parseDashboardRange(params.range);
const { actor, service } = await requireFreelancerBackend();
@@ -31,5 +39,9 @@ export default async function DashboardPage({
range,
};
return <DashboardClient data={data} />;
return (
<I18nProvider locale={payload.locale} messages={payload.messages}>
<DashboardClient data={data} />
</I18nProvider>
);
}
@@ -123,7 +123,7 @@ export function LanguageDetail({
</div>
</div>
<Button asChild variant="secondary" effect="shine" className="gap-2">
<Link href="#translation-completion">
<Link href={`/settings/languages/${locale.code}/translations`}>
<Languages className="h-4 w-4" aria-hidden="true" />
{t("settings.languageDetail.actions.translations")}
</Link>
@@ -0,0 +1,34 @@
"use server";
import { revalidatePath } from "next/cache";
import { getSqliteConnection } from "@/server/db/client";
import { I18nService } from "@/server/i18n/service";
import { requireFreelancerBackend } from "@/server/web/freelancer";
export async function upsertTranslationsAction(
locale: string,
payload: { namespace: string; key: string; value: string }[]
) {
const { actor } = await requireFreelancerBackend();
const service = new I18nService(getSqliteConnection().db);
try {
for (const item of payload) {
if (item.value.trim() === "") {
try {
service.resetUiTranslation(actor, { locale, namespace: item.namespace, key: item.key });
} catch (e) {
// ignore if it doesn't exist
}
} else {
service.upsertUiTranslation(actor, { locale, namespace: item.namespace, key: item.key, value: item.value });
}
}
revalidatePath(`/settings/languages/${locale}`);
revalidatePath(`/settings/languages/${locale}/translations`);
return { success: true };
} catch (error) {
console.error("Translation save error:", error);
return { errorKey: "common.error.description" };
}
}
@@ -0,0 +1,43 @@
import { notFound } from "next/navigation";
import { getSqliteConnection } from "@/server/db/client";
import { I18nService, getReferenceTranslationKeys } from "@/server/i18n/service";
import { requireFreelancerBackend } from "@/server/web/freelancer";
import { TranslationEditor } from "./translation-editor";
import { I18N_NAMESPACES } from "@/lib/i18n";
export default async function TranslationsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const resolvedParams = await params;
const localeCode = resolvedParams.locale;
const { actor } = await requireFreelancerBackend();
const service = new I18nService(getSqliteConnection().db);
let locale;
try {
locale = service.listLocales(actor).find(l => l.code === localeCode);
if (!locale) notFound();
} catch {
notFound();
}
const keys = getReferenceTranslationKeys("all");
const currentTranslations = service.listUiTranslations(actor).filter(t => t.locale === localeCode);
const overrides = new Map(currentTranslations.map(t => [`${t.namespace}.${t.key}`, t.value]));
return (
<TranslationEditor
locale={{
code: locale.code,
name: locale.name,
nativeName: locale.nativeName,
}}
namespaces={I18N_NAMESPACES}
referenceKeys={keys}
overrides={Object.fromEntries(overrides)}
/>
);
}
@@ -0,0 +1,231 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition, useEffect } from "react";
import { ArrowLeft, Save, AlertTriangle } from "lucide-react";
import { Button, Card, CardContent, Input, Label, Badge } from "poyraz-ui/atoms";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, toast } from "poyraz-ui/molecules";
import { useTranslations } from "@/components/i18n/i18n-provider";
import { upsertTranslationsAction } from "./actions";
type ReferenceKey = {
key: string;
namespace: string;
translationKey: string;
tr: string;
en: string;
parityOk: boolean;
};
type LocaleDetail = {
code: string;
name: string;
nativeName: string;
};
export function TranslationEditor({
locale,
namespaces,
referenceKeys,
overrides,
}: {
locale: LocaleDetail;
namespaces: readonly string[];
referenceKeys: ReferenceKey[];
overrides: Record<string, string>;
}) {
const t = useTranslations();
const router = useRouter();
const [pending, startTransition] = useTransition();
const [activeNamespace, setActiveNamespace] = useState<string>("common");
const [filter, setFilter] = useState<"all" | "missing" | "dirty">("all");
const [edits, setEdits] = useState<Record<string, string>>({});
const isDirty = Object.keys(edits).length > 0;
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (isDirty) {
e.preventDefault();
e.returnValue = "";
}
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
}, [isDirty]);
const handleSave = () => {
if (!isDirty) return;
startTransition(async () => {
const payload = Object.entries(edits).map(([fullKey, value]) => {
const [namespace, ...rest] = fullKey.split(".");
return {
namespace,
key: rest.join("."),
value,
};
});
const result = await upsertTranslationsAction(locale.code, payload);
if (result.errorKey) {
toast.error(t(result.errorKey));
} else {
toast.success("Çeviriler başarıyla kaydedildi.");
setEdits({});
router.refresh();
}
});
};
const handleReset = (fullKey: string) => {
setEdits(prev => {
const next = { ...prev };
delete next[fullKey];
return next;
});
};
const filteredKeys = referenceKeys.filter(k => {
if (k.namespace !== activeNamespace && activeNamespace !== "all") return false;
const isEdited = edits[k.key] !== undefined;
const value = isEdited ? edits[k.key] : (overrides[k.key] || "");
const isMissing = value.trim() === "";
if (filter === "missing" && !isMissing) return false;
if (filter === "dirty" && !isEdited) return false;
return true;
});
return (
<div className="space-y-6 pb-20">
<Card>
<CardContent className="p-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<Button asChild size="icon-sm" variant="secondary" effect="shine">
<Link href={`/settings/languages/${locale.code}`}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div>
<h2 className="text-xl font-semibold text-foreground">
{locale.nativeName} Çevirileri
</h2>
</div>
</div>
<div className="flex items-center gap-3">
<Select value={filter} onValueChange={(v: any) => setFilter(v)}>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tümü</SelectItem>
<SelectItem value="missing">Eksikler</SelectItem>
<SelectItem value="dirty">Değişenler</SelectItem>
</SelectContent>
</Select>
<Select value={activeNamespace} onValueChange={setActiveNamespace}>
<SelectTrigger className="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tüm Modüller</SelectItem>
{namespaces.map(ns => (
<SelectItem key={ns} value={ns}>{ns}</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={handleSave} disabled={!isDirty} loading={pending} effect="shine">
<Save className="h-4 w-4 mr-2" />
Kaydet {isDirty && `(${Object.keys(edits).length})`}
</Button>
</div>
</CardContent>
</Card>
<div className="space-y-4">
{filteredKeys.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-muted-foreground">
Gösterilecek çeviri anahtarı bulunamadı.
</CardContent>
</Card>
) : (
filteredKeys.map((item) => {
const isEdited = edits[item.key] !== undefined;
const currentValue = isEdited ? edits[item.key] : (overrides[item.key] || "");
const trVars = item.tr.match(/\{[^}]+\}/g) || [];
const targetVars = currentValue.match(/\{[^}]+\}/g) || [];
const missingVars = trVars.filter(v => !targetVars.includes(v));
return (
<Card key={item.key} className={isEdited ? "border-primary" : ""}>
<CardContent className="p-5 flex flex-col gap-4">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center justify-between">
<Badge variant="outline" className="w-fit">{item.key}</Badge>
{isEdited && <Badge variant="default" className="w-fit">Değiştirildi</Badge>}
</div>
<div className="grid sm:grid-cols-2 gap-4 text-sm text-muted-foreground bg-muted/50 p-3 rounded-lg">
<div>
<span className="font-semibold block mb-1">TR Referans:</span>
{item.tr}
</div>
<div>
<span className="font-semibold block mb-1">EN Referans:</span>
{item.en}
</div>
</div>
<div className="space-y-2">
<Label>Hedef Metin ({locale.code})</Label>
<Input
value={currentValue}
onChange={(e) => setEdits(prev => ({ ...prev, [item.key]: e.target.value }))}
className={missingVars.length > 0 ? "border-destructive focus-visible:ring-destructive" : ""}
/>
{missingVars.length > 0 && (
<p className="text-xs text-destructive flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
Eksik değişkenler: {missingVars.join(", ")}
</p>
)}
{isEdited && (
<div className="flex justify-end">
<Button variant="ghost" size="sm" onClick={() => handleReset(item.key)} className="text-xs h-7">
Değişikliği İptal Et
</Button>
</div>
)}
</div>
</CardContent>
</Card>
);
})
)}
</div>
{isDirty && (
<div className="fixed bottom-0 left-0 right-0 p-4 bg-background/80 backdrop-blur-sm border-t border-border flex justify-end gap-3 z-50 sm:pl-64">
<Button variant="secondary" onClick={() => setEdits({})} disabled={pending}>
İptal
</Button>
<Button onClick={handleSave} loading={pending}>
Değişiklikleri Kaydet ({Object.keys(edits).length})
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,28 @@
"use server";
import { revalidatePath } from "next/cache";
import { getSqliteConnection } from "@/server/db/client";
import { I18nService } from "@/server/i18n/service";
import { requireFreelancerBackend } from "@/server/web/freelancer";
export async function exportTranslationsAction() {
const { actor } = await requireFreelancerBackend();
const service = new I18nService(getSqliteConnection().db);
const pkg = service.exportPackage(actor);
return { package: pkg };
}
export async function importTranslationsAction(content: string) {
const { actor } = await requireFreelancerBackend();
const service = new I18nService(getSqliteConnection().db);
try {
const data = JSON.parse(content);
service.importPackage(actor, data);
revalidatePath("/settings/languages");
return { success: true };
} catch (error: any) {
console.error("Import error:", error);
return { errorKey: "common.error.description", details: error.message };
}
}
@@ -0,0 +1,124 @@
"use client";
import { useState, useTransition } from "react";
import { Download, Upload, AlertTriangle } from "lucide-react";
import { Button, Card, CardContent, Label, Input } from "poyraz-ui/atoms";
import { Alert, AlertDescription, toast } from "poyraz-ui/molecules";
import { useTranslations } from "@/components/i18n/i18n-provider";
import { exportTranslationsAction, importTranslationsAction } from "./actions";
export function ImportExportForm() {
const t = useTranslations();
const [pending, startTransition] = useTransition();
const [file, setFile] = useState<File | null>(null);
const handleExport = () => {
startTransition(async () => {
const result = await exportTranslationsAction();
if (result.package) {
const blob = new Blob([JSON.stringify(result.package, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `neta-i18n-export-${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("Dışa aktarma başarılı.");
}
});
};
const handleImport = () => {
if (!file) return;
if (!window.confirm("Bu işlem mevcut çevirilerin üzerine yazabilir. Devam etmek istiyor musunuz?")) {
return;
}
startTransition(async () => {
try {
const text = await file.text();
const result = await importTranslationsAction(text);
if (result.errorKey) {
toast.error(t(result.errorKey) + (result.details ? ` (${result.details})` : ""));
} else {
toast.success("İçe aktarma başarılı.");
setFile(null);
}
} catch (err) {
toast.error("Dosya okunurken bir hata oluştu.");
}
});
};
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-foreground">İçe / Dışa Aktarma</h2>
<p className="text-sm text-muted-foreground mt-1">Dil paketlerini ve çevirileri taşıyın veya yedekleyin.</p>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<Card>
<CardContent className="p-6 space-y-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg text-primary">
<Download className="h-5 w-5" />
</div>
<h3 className="font-semibold">Dışa Aktar</h3>
</div>
<p className="text-sm text-muted-foreground">
Mevcut tüm dilleri, aktif çevirileri ve tercih edilen varsayılan dil bilgisini içeren bir JSON yedeği oluşturun.
</p>
<Button onClick={handleExport} loading={pending} effect="shine" className="w-full">
Dışa Aktar (.json)
</Button>
</CardContent>
</Card>
<Card>
<CardContent className="p-6 space-y-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg text-primary">
<Upload className="h-5 w-5" />
</div>
<h3 className="font-semibold">İçe Aktar</h3>
</div>
<p className="text-sm text-muted-foreground">
Daha önce Neta üzerinden dışa aktarılmış bir dil paketini içeri yükleyin.
</p>
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="text-xs">
Aynı dil koduna sahip çevirilerin üzerine yazılacaktır.
</AlertDescription>
</Alert>
<div className="space-y-2">
<Label>JSON Dosyası Seç</Label>
<Input
type="file"
accept=".json,application/json"
onChange={(e) => setFile(e.target.files?.[0] || null)}
disabled={pending}
/>
</div>
<Button
onClick={handleImport}
disabled={!file}
loading={pending}
effect="shine"
className="w-full"
>
İçe Aktar
</Button>
</CardContent>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,5 @@
import { ImportExportForm } from "./import-export-form";
export default function ImportExportPage() {
return <ImportExportForm />;
}
@@ -72,12 +72,19 @@ export function LanguagesList({
{t("settings.languages.description")}
</p>
</div>
<Button asChild variant="default" effect="shine" className="gap-2">
<Link href="/settings/languages/new">
<Plus className="h-4 w-4" aria-hidden="true" />
{t("settings.languages.actions.add")}
</Link>
</Button>
<div className="flex items-center gap-2">
<Button asChild variant="secondary" effect="shine" className="gap-2">
<Link href="/settings/languages/import-export">
İçe / Dışa Aktar
</Link>
</Button>
<Button asChild variant="default" effect="shine" className="gap-2">
<Link href="/settings/languages/new">
<Plus className="h-4 w-4" aria-hidden="true" />
{t("settings.languages.actions.add")}
</Link>
</Button>
</div>
</div>
<div