feat(i18n): update settings and dashboard layout for dynamic translations
This commit is contained in:
@@ -132,7 +132,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
|
|||||||
<div key={index} className="flex items-center justify-between gap-6 text-xs">
|
<div key={index} className="flex items-center justify-between gap-6 text-xs">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: entry.color }} />
|
<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>
|
</div>
|
||||||
<span className="font-semibold text-foreground">
|
<span className="font-semibold text-foreground">
|
||||||
{formatCurrency(Number(entry.value ?? 0))}
|
{formatCurrency(Number(entry.value ?? 0))}
|
||||||
@@ -247,7 +247,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={project.status === 'completed' ? 'secondary' : 'default'} className="capitalize text-[10px] px-1.5 py-0">
|
<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>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</PendingLink>
|
</PendingLink>
|
||||||
@@ -274,7 +274,7 @@ export function DashboardClient({ data }: DashboardClientProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium">{client.name}</p>
|
<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>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{new Date(client.created_at).toLocaleDateString(getDocumentIntlLocale(), { month: "short", day: "numeric" })}
|
{new Date(client.created_at).toLocaleDateString(getDocumentIntlLocale(), { month: "short", day: "numeric" })}
|
||||||
|
|||||||
@@ -19,20 +19,11 @@ export default async function DashboardLayout({
|
|||||||
const translator = createTranslator(resolvedLocale.locale, [
|
const translator = createTranslator(resolvedLocale.locale, [
|
||||||
"common",
|
"common",
|
||||||
"navigation",
|
"navigation",
|
||||||
"dashboard",
|
|
||||||
"clients",
|
|
||||||
"projects",
|
|
||||||
"tasks",
|
|
||||||
"calendar",
|
|
||||||
"finance",
|
|
||||||
"journal",
|
|
||||||
"chat",
|
|
||||||
"settings",
|
|
||||||
"status",
|
"status",
|
||||||
"validation",
|
"validation",
|
||||||
]);
|
]);
|
||||||
const t = translator.t;
|
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 =
|
const shortName =
|
||||||
displayName
|
displayName
|
||||||
@@ -55,15 +46,6 @@ export default async function DashboardLayout({
|
|||||||
i18n={getClientI18nPayload(resolvedLocale.locale, [
|
i18n={getClientI18nPayload(resolvedLocale.locale, [
|
||||||
"common",
|
"common",
|
||||||
"navigation",
|
"navigation",
|
||||||
"dashboard",
|
|
||||||
"clients",
|
|
||||||
"projects",
|
|
||||||
"tasks",
|
|
||||||
"calendar",
|
|
||||||
"finance",
|
|
||||||
"journal",
|
|
||||||
"chat",
|
|
||||||
"settings",
|
|
||||||
"status",
|
"status",
|
||||||
"validation",
|
"validation",
|
||||||
])}
|
])}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { DashboardClient, type DashboardData } from "./dashboard-client";
|
import { DashboardClient, type DashboardData } from "./dashboard-client";
|
||||||
import { parseDashboardRange, resolveDashboardRange } from "@/server/services/analytics-range";
|
import { parseDashboardRange, resolveDashboardRange } from "@/server/services/analytics-range";
|
||||||
import { requireFreelancerBackend } from "@/server/web/freelancer";
|
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" };
|
export const metadata = { title: "Dashboard" };
|
||||||
|
|
||||||
@@ -9,6 +13,10 @@ export default async function DashboardPage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
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 params = await searchParams;
|
||||||
const range = parseDashboardRange(params.range);
|
const range = parseDashboardRange(params.range);
|
||||||
const { actor, service } = await requireFreelancerBackend();
|
const { actor, service } = await requireFreelancerBackend();
|
||||||
@@ -31,5 +39,9 @@ export default async function DashboardPage({
|
|||||||
range,
|
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>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild variant="secondary" effect="shine" className="gap-2">
|
<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" />
|
<Languages className="h-4 w-4" aria-hidden="true" />
|
||||||
{t("settings.languageDetail.actions.translations")}
|
{t("settings.languageDetail.actions.translations")}
|
||||||
</Link>
|
</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,6 +72,12 @@ export function LanguagesList({
|
|||||||
{t("settings.languages.description")}
|
{t("settings.languages.description")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<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">
|
<Button asChild variant="default" effect="shine" className="gap-2">
|
||||||
<Link href="/settings/languages/new">
|
<Link href="/settings/languages/new">
|
||||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||||
@@ -79,6 +85,7 @@ export function LanguagesList({
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex gap-2 overflow-x-auto border-y border-border py-4"
|
className="flex gap-2 overflow-x-auto border-y border-border py-4"
|
||||||
|
|||||||
Reference in New Issue
Block a user