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 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" })}
|
||||
|
||||
@@ -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",
|
||||
])}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user