feat: add language settings management
This commit is contained in:
@@ -12,13 +12,21 @@ import { getServerConfig } from "@/server/config";
|
||||
import { getBrandingService } from "@/server/branding/runtime";
|
||||
import { getSqliteConnection } from "@/server/db/client";
|
||||
import { appProfiles } from "@/server/db/schema";
|
||||
import { runtimeEvents } from "@/server/db/schema/runtime";
|
||||
import { domainActorFromSession } from "@/server/auth/domain-actor";
|
||||
import { getFileService } from "@/server/files/runtime";
|
||||
import { getPublicAiSettings, updateAiSettings } from "@/server/settings/ai";
|
||||
import {
|
||||
getUserPreferences,
|
||||
updateLanguagePreference,
|
||||
updateColorModePreference,
|
||||
} from "@/server/settings/preferences";
|
||||
import { buildLocaleCookie } from "@/server/i18n/locale";
|
||||
import {
|
||||
ContentTranslationService,
|
||||
parseContentTranslationsFromFormData,
|
||||
} from "@/server/i18n/content";
|
||||
import { getReferenceTranslationKeys, I18nService } from "@/server/i18n/service";
|
||||
import { requireFreelancerBackend } from "@/server/web/freelancer";
|
||||
import { cleanText } from "@/server/web/form-data";
|
||||
|
||||
@@ -28,6 +36,13 @@ export async function loadSettings() {
|
||||
const ai = getPublicAiSettings(actor);
|
||||
const preferences = getUserPreferences(actor);
|
||||
const branding = getBrandingService().getPublic();
|
||||
const i18nService = new I18nService(getSqliteConnection().db);
|
||||
const locales = i18nService.listLocales(actor);
|
||||
const i18nSettings = i18nService.getSettings(actor);
|
||||
const translations = i18nService.listUiTranslations(actor);
|
||||
const completion = i18nService.getCompletion(actor);
|
||||
const contentI18n = new ContentTranslationService(getSqliteConnection().db);
|
||||
const brandingTranslations = contentI18n.listEntityTranslations("branding", "default");
|
||||
|
||||
return {
|
||||
firstName,
|
||||
@@ -46,6 +61,18 @@ export async function loadSettings() {
|
||||
hasCustomLightLogo: Boolean(branding.lightLogoFileId),
|
||||
hasCustomDarkLogo: Boolean(branding.darkLogoFileId),
|
||||
hasCustomFavicon: Boolean(branding.iconFileId),
|
||||
language: preferences.language,
|
||||
i18n: {
|
||||
locales,
|
||||
defaultLocale: i18nSettings.defaultLocale,
|
||||
catalogVersion: i18nSettings.catalogVersion,
|
||||
translations,
|
||||
completion,
|
||||
referenceKeys: getReferenceTranslationKeys("all"),
|
||||
},
|
||||
contentTranslations: {
|
||||
branding: brandingTranslations,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -143,6 +170,140 @@ export async function saveColorMode(colorMode: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveLanguagePreference(language: string) {
|
||||
try {
|
||||
const { actor } = await requireFreelancerBackend();
|
||||
const preferences = updateLanguagePreference(actor, { language });
|
||||
|
||||
(await cookies()).set(buildLocaleCookie(preferences.language));
|
||||
recordI18nEvent(actor.authUserId, `user_language_updated:${preferences.language}`);
|
||||
revalidatePath("/", "layout");
|
||||
revalidatePath("/settings");
|
||||
return { success: true, language: preferences.language };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Dil tercihi kaydedilemedi." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createLocaleAction(input: {
|
||||
code: string;
|
||||
name: string;
|
||||
nativeName: string;
|
||||
fallbackLocale: string;
|
||||
textDirection: "ltr" | "rtl";
|
||||
}) {
|
||||
try {
|
||||
const { actor } = await requireFreelancerBackend();
|
||||
const service = new I18nService(getSqliteConnection().db);
|
||||
const locale = service.createLocale(actor, input);
|
||||
recordI18nEvent(actor.authUserId, `locale_created:${locale.code}`);
|
||||
revalidateI18nPaths();
|
||||
return { success: true, locale };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Dil eklenemedi." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateLocaleStatusAction(code: string, status: "draft" | "active" | "archived" | "test") {
|
||||
try {
|
||||
const { actor } = await requireFreelancerBackend();
|
||||
const service = new I18nService(getSqliteConnection().db);
|
||||
const locale = service.updateLocale(actor, code, { status });
|
||||
recordI18nEvent(actor.authUserId, `locale_status_updated:${locale.code}:${locale.status}`);
|
||||
revalidateI18nPaths();
|
||||
return { success: true, locale };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Dil durumu güncellenemedi." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function setDefaultLocaleAction(code: string) {
|
||||
try {
|
||||
const { actor } = await requireFreelancerBackend();
|
||||
const service = new I18nService(getSqliteConnection().db);
|
||||
const settings = service.setDefaultLocale(actor, code);
|
||||
recordI18nEvent(actor.authUserId, `default_locale_updated:${settings.defaultLocale}`);
|
||||
revalidateI18nPaths();
|
||||
return { success: true, settings };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Varsayılan dil güncellenemedi." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveUiTranslationAction(input: {
|
||||
locale: string;
|
||||
namespace: string;
|
||||
key: string;
|
||||
value: string;
|
||||
}) {
|
||||
try {
|
||||
const { actor } = await requireFreelancerBackend();
|
||||
const service = new I18nService(getSqliteConnection().db);
|
||||
service.upsertUiTranslation(actor, input);
|
||||
recordI18nEvent(actor.authUserId, `ui_translation_saved:${input.locale}:${input.namespace}.${input.key}`);
|
||||
revalidateI18nPaths();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Çeviri kaydedilemedi." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetUiTranslationAction(input: {
|
||||
locale: string;
|
||||
namespace: string;
|
||||
key: string;
|
||||
}) {
|
||||
try {
|
||||
const { actor } = await requireFreelancerBackend();
|
||||
const service = new I18nService(getSqliteConnection().db);
|
||||
service.resetUiTranslation(actor, input);
|
||||
recordI18nEvent(actor.authUserId, `ui_translation_reset:${input.locale}:${input.namespace}.${input.key}`);
|
||||
revalidateI18nPaths();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Çeviri sıfırlanamadı." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportI18nAction() {
|
||||
try {
|
||||
const { actor } = await requireFreelancerBackend();
|
||||
const service = new I18nService(getSqliteConnection().db);
|
||||
recordI18nEvent(actor.authUserId, "i18n_exported");
|
||||
return { success: true, package: service.exportPackage(actor) };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Çeviri paketi dışa aktarılamadı." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function previewI18nImportAction(rawJson: string) {
|
||||
try {
|
||||
await requireFreelancerBackend();
|
||||
const parsed = JSON.parse(rawJson);
|
||||
const localeCount = Array.isArray(parsed.locales) ? parsed.locales.length : 0;
|
||||
const translationCount = Array.isArray(parsed.translations) ? parsed.translations.length : 0;
|
||||
if (parsed.format !== "neta-i18n" || parsed.version !== 1) {
|
||||
return { error: "Import paketi desteklenmiyor." };
|
||||
}
|
||||
return { success: true, preview: { localeCount, translationCount, defaultLocale: parsed.defaultLocale ?? "tr" } };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Import paketi okunamadı." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function commitI18nImportAction(rawJson: string) {
|
||||
try {
|
||||
const { actor } = await requireFreelancerBackend();
|
||||
const service = new I18nService(getSqliteConnection().db);
|
||||
const result = service.importPackage(actor, JSON.parse(rawJson));
|
||||
recordI18nEvent(actor.authUserId, `i18n_imported:${result.translations.length}`);
|
||||
revalidateI18nPaths();
|
||||
return { success: true, package: result };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Çeviri paketi içe aktarılamadı." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveGeneralSettings(formData: FormData) {
|
||||
const uploadedFileIds: string[] = [];
|
||||
let brandingCommitted = false;
|
||||
@@ -170,6 +331,9 @@ export async function saveGeneralSettings(formData: FormData) {
|
||||
}
|
||||
|
||||
const brandingService = getBrandingService();
|
||||
const contentI18n = new ContentTranslationService(getSqliteConnection().db);
|
||||
const localization = contentI18n.getLocalizationContext(actor);
|
||||
const brandingTranslations = parseContentTranslationsFromFormData(formData, "branding", localization);
|
||||
const current = brandingService.getPublic();
|
||||
const lightLogoFileId = await uploadBrandingFile(formData, "lightLogo", "branding_logo", actor);
|
||||
if (lightLogoFileId) uploadedFileIds.push(lightLogoFileId);
|
||||
@@ -188,6 +352,7 @@ export async function saveGeneralSettings(formData: FormData) {
|
||||
...(iconFileId ? { iconFileId } : {}),
|
||||
});
|
||||
brandingCommitted = true;
|
||||
contentI18n.upsertEntityTranslations("branding", "default", brandingTranslations);
|
||||
|
||||
deleteSupersededBrandingFiles(actor, current, updated);
|
||||
|
||||
@@ -306,3 +471,18 @@ function revalidateBrandingPaths(): void {
|
||||
revalidatePath("/portal", "layout");
|
||||
revalidatePath("/manifest.webmanifest");
|
||||
}
|
||||
|
||||
function revalidateI18nPaths(): void {
|
||||
revalidatePath("/", "layout");
|
||||
revalidatePath("/settings");
|
||||
revalidatePath("/portal", "layout");
|
||||
revalidatePath("/api/v1/meta");
|
||||
}
|
||||
|
||||
function recordI18nEvent(authUserId: string, message: string): void {
|
||||
getSqliteConnection().db.insert(runtimeEvents).values({
|
||||
type: "i18n.settings",
|
||||
message: `${authUserId}:${message}`,
|
||||
createdAt: new Date(),
|
||||
}).run();
|
||||
}
|
||||
|
||||
@@ -5,8 +5,11 @@ import Image from "next/image";
|
||||
import {
|
||||
Blocks,
|
||||
Brain,
|
||||
Download,
|
||||
Globe2,
|
||||
ImageIcon,
|
||||
Key,
|
||||
Languages,
|
||||
Monitor,
|
||||
Moon,
|
||||
Palette,
|
||||
@@ -19,10 +22,19 @@ import {
|
||||
} from "lucide-react";
|
||||
import {
|
||||
loadSettings,
|
||||
commitI18nImportAction,
|
||||
createLocaleAction,
|
||||
exportI18nAction,
|
||||
previewI18nImportAction,
|
||||
removeBrandingAsset,
|
||||
resetUiTranslationAction,
|
||||
saveAiSettings,
|
||||
saveColorMode,
|
||||
saveGeneralSettings,
|
||||
saveLanguagePreference,
|
||||
saveUiTranslationAction,
|
||||
setDefaultLocaleAction,
|
||||
updateLocaleStatusAction,
|
||||
updatePassword,
|
||||
updateProfile,
|
||||
} from "./actions";
|
||||
@@ -34,13 +46,44 @@ import {
|
||||
Label,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Textarea,
|
||||
} from "poyraz-ui/atoms";
|
||||
import { toast } from "poyraz-ui/molecules";
|
||||
import { applyColorMode } from "@/components/theme/color-mode-sync";
|
||||
import { LocalizedFields, type LocalizedFieldValues } from "@/components/i18n/localized-fields";
|
||||
import { isColorMode, type ColorMode } from "@/lib/color-mode";
|
||||
import { contentTranslationRegistry } from "@/lib/i18n/content";
|
||||
|
||||
type AiProvider = "groq" | "ollama" | "openai" | "gemini";
|
||||
type BrandingAsset = "lightLogo" | "darkLogo" | "favicon";
|
||||
type LocaleStatus = "draft" | "active" | "archived" | "test";
|
||||
type LocaleRecord = {
|
||||
code: string;
|
||||
name: string;
|
||||
nativeName: string;
|
||||
status: LocaleStatus;
|
||||
fallbackLocale: string | null;
|
||||
textDirection: "ltr" | "rtl";
|
||||
builtIn: boolean;
|
||||
sortOrder: number;
|
||||
};
|
||||
type TranslationRow = { locale: string; namespace: string; key: string; value: string };
|
||||
type ContentTranslationRow = { entityType: string; entityId: string; field: string; locale: string; value: string };
|
||||
type ReferenceKey = {
|
||||
key: string;
|
||||
namespace: string;
|
||||
translationKey: string;
|
||||
tr: string;
|
||||
en: string;
|
||||
parityOk: boolean;
|
||||
};
|
||||
type TranslationCompletion = {
|
||||
locale: string;
|
||||
translated: number;
|
||||
total: number;
|
||||
percent: number;
|
||||
missingKeys: string[];
|
||||
};
|
||||
|
||||
const colorModeOptions = [
|
||||
{
|
||||
@@ -105,10 +148,32 @@ export default function SettingsPage() {
|
||||
favicon: false,
|
||||
});
|
||||
const [isSavingBranding, setIsSavingBranding] = useState(false);
|
||||
const [language, setLanguage] = useState("tr");
|
||||
const [locales, setLocales] = useState<LocaleRecord[]>([]);
|
||||
const [defaultLocale, setDefaultLocale] = useState("tr");
|
||||
const [catalogVersion, setCatalogVersion] = useState(1);
|
||||
const [translations, setTranslations] = useState<TranslationRow[]>([]);
|
||||
const [brandingContentTranslations, setBrandingContentTranslations] = useState<LocalizedFieldValues>({});
|
||||
const [referenceKeys, setReferenceKeys] = useState<ReferenceKey[]>([]);
|
||||
const [completion, setCompletion] = useState<TranslationCompletion[]>([]);
|
||||
const [newLocale, setNewLocale] = useState({
|
||||
code: "fr",
|
||||
name: "French",
|
||||
nativeName: "Français",
|
||||
fallbackLocale: "en",
|
||||
textDirection: "ltr" as "ltr" | "rtl",
|
||||
});
|
||||
const [selectedLocale, setSelectedLocale] = useState("fr");
|
||||
const [selectedNamespace, setSelectedNamespace] = useState("navigation");
|
||||
const [translationSearch, setTranslationSearch] = useState("");
|
||||
const [editingValues, setEditingValues] = useState<Record<string, string>>({});
|
||||
const [importJson, setImportJson] = useState("");
|
||||
const [importPreview, setImportPreview] = useState("");
|
||||
const assetObjectUrlRefs = useRef<Partial<Record<BrandingAsset, string>>>({});
|
||||
|
||||
const tabs = [
|
||||
{ name: "Genel", icon: Palette },
|
||||
{ name: "Diller ve çeviriler", icon: Languages },
|
||||
{ name: "Profile & Account", icon: User },
|
||||
{ name: "AI Preferences", icon: Brain },
|
||||
{ name: "Security", icon: Shield },
|
||||
@@ -140,6 +205,15 @@ export default function SettingsPage() {
|
||||
darkLogo: settings.hasCustomDarkLogo,
|
||||
favicon: settings.hasCustomFavicon,
|
||||
});
|
||||
setLanguage(settings.language);
|
||||
setLocales(settings.i18n.locales);
|
||||
setDefaultLocale(settings.i18n.defaultLocale);
|
||||
setCatalogVersion(settings.i18n.catalogVersion);
|
||||
setTranslations(settings.i18n.translations);
|
||||
setReferenceKeys(settings.i18n.referenceKeys);
|
||||
setCompletion(settings.i18n.completion);
|
||||
setBrandingContentTranslations(toLocalizedValues(settings.contentTranslations.branding));
|
||||
setSelectedLocale(settings.i18n.locales.find((locale) => !locale.builtIn)?.code ?? "en");
|
||||
};
|
||||
|
||||
void fetchData();
|
||||
@@ -254,6 +328,129 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const refreshI18nSettings = async () => {
|
||||
const settings = await loadSettings();
|
||||
setLanguage(settings.language);
|
||||
setLocales(settings.i18n.locales);
|
||||
setDefaultLocale(settings.i18n.defaultLocale);
|
||||
setCatalogVersion(settings.i18n.catalogVersion);
|
||||
setTranslations(settings.i18n.translations);
|
||||
setReferenceKeys(settings.i18n.referenceKeys);
|
||||
setCompletion(settings.i18n.completion);
|
||||
};
|
||||
|
||||
const handleLanguagePreferenceChange = async (value: string) => {
|
||||
setLanguage(value);
|
||||
const response = await saveLanguagePreference(value);
|
||||
if (response.error) {
|
||||
toast.error(response.error);
|
||||
return;
|
||||
}
|
||||
toast.success("Arayüz dili kaydedildi.");
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const handleCreateLocale = async () => {
|
||||
const response = await createLocaleAction(newLocale);
|
||||
if (response.error) {
|
||||
toast.error(response.error);
|
||||
return;
|
||||
}
|
||||
toast.success("Dil eklendi.");
|
||||
setSelectedLocale(response.locale?.code ?? newLocale.code);
|
||||
await refreshI18nSettings();
|
||||
};
|
||||
|
||||
const handleUpdateLocaleStatus = async (code: string, status: LocaleStatus) => {
|
||||
const response = await updateLocaleStatusAction(code, status);
|
||||
if (response.error) {
|
||||
toast.error(response.error);
|
||||
return;
|
||||
}
|
||||
toast.success("Dil durumu güncellendi.");
|
||||
await refreshI18nSettings();
|
||||
};
|
||||
|
||||
const handleSetDefaultLocale = async (code: string) => {
|
||||
const response = await setDefaultLocaleAction(code);
|
||||
if (response.error) {
|
||||
toast.error(response.error);
|
||||
return;
|
||||
}
|
||||
toast.success("Varsayılan dil güncellendi.");
|
||||
await refreshI18nSettings();
|
||||
};
|
||||
|
||||
const handleSaveTranslation = async (reference: ReferenceKey) => {
|
||||
const value = editingValues[reference.key] ?? getTranslationValue(translations, selectedLocale, reference);
|
||||
const response = await saveUiTranslationAction({
|
||||
locale: selectedLocale,
|
||||
namespace: reference.namespace,
|
||||
key: reference.translationKey,
|
||||
value,
|
||||
});
|
||||
if (response.error) {
|
||||
toast.error(response.error);
|
||||
return;
|
||||
}
|
||||
toast.success("Çeviri kaydedildi.");
|
||||
await refreshI18nSettings();
|
||||
};
|
||||
|
||||
const handleResetTranslation = async (reference: ReferenceKey) => {
|
||||
const response = await resetUiTranslationAction({
|
||||
locale: selectedLocale,
|
||||
namespace: reference.namespace,
|
||||
key: reference.translationKey,
|
||||
});
|
||||
if (response.error) {
|
||||
toast.error(response.error);
|
||||
return;
|
||||
}
|
||||
toast.success("Override sıfırlandı.");
|
||||
setEditingValues((current) => ({ ...current, [reference.key]: "" }));
|
||||
await refreshI18nSettings();
|
||||
};
|
||||
|
||||
const handleExportI18n = async () => {
|
||||
const response = await exportI18nAction();
|
||||
if (response.error || !response.package) {
|
||||
toast.error(response.error ?? "Export oluşturulamadı.");
|
||||
return;
|
||||
}
|
||||
setImportJson(JSON.stringify(response.package, null, 2));
|
||||
toast.success("Çeviri paketi aşağıdaki alana yazıldı.");
|
||||
};
|
||||
|
||||
const handlePreviewImport = async () => {
|
||||
const response = await previewI18nImportAction(importJson);
|
||||
if (response.error || !response.preview) {
|
||||
setImportPreview("");
|
||||
toast.error(response.error ?? "Import paketi okunamadı.");
|
||||
return;
|
||||
}
|
||||
setImportPreview(`${response.preview.localeCount} dil, ${response.preview.translationCount} çeviri, default: ${response.preview.defaultLocale}`);
|
||||
};
|
||||
|
||||
const handleCommitImport = async () => {
|
||||
const response = await commitI18nImportAction(importJson);
|
||||
if (response.error) {
|
||||
toast.error(response.error);
|
||||
return;
|
||||
}
|
||||
toast.success("Çeviri paketi içe aktarıldı.");
|
||||
setImportPreview("");
|
||||
await refreshI18nSettings();
|
||||
};
|
||||
|
||||
const filteredReferenceKeys = referenceKeys.filter((reference) => {
|
||||
const namespaceMatches = selectedNamespace === "all" || reference.namespace === selectedNamespace;
|
||||
const query = translationSearch.trim().toLowerCase();
|
||||
const textMatches = !query || [reference.key, reference.tr, reference.en]
|
||||
.some((value) => value.toLowerCase().includes(query));
|
||||
return namespaceMatches && textMatches;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-6">
|
||||
<div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
@@ -442,6 +639,22 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 border-t border-border pt-7">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold text-foreground">Portal metinleri</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Müşteri portalında kullanılacak karşılama ve footer metinlerini aktif dillere göre girin.
|
||||
</p>
|
||||
</div>
|
||||
<LocalizedFields
|
||||
idPrefix="branding-content"
|
||||
defaultLocale={defaultLocale}
|
||||
locales={locales.filter((locale) => locale.status !== "archived")}
|
||||
fields={contentTranslationRegistry.branding}
|
||||
values={brandingContentTranslations}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="flex items-center gap-3 border-t border-border pt-6">
|
||||
<Button variant="default" effect="shine" type="submit" loading={isSavingBranding} className="gap-2">
|
||||
<Upload className="h-4 w-4" aria-hidden="true" />
|
||||
@@ -518,6 +731,223 @@ export default function SettingsPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === "Diller ve çeviriler" && (
|
||||
<Card className="animate-in fade-in duration-300">
|
||||
<CardContent className="space-y-8 p-6 sm:p-8">
|
||||
<div className="flex flex-col gap-4 border-b border-border pb-6 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-xl font-bold text-foreground">Diller ve çeviriler</h2>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Instance dillerini, kişisel arayüz dilini ve katalog override metinlerini yönetin.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
Catalog v{catalogVersion}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe2 className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-semibold text-foreground">Aktif diller</h3>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{locales.map((locale) => {
|
||||
const localeCompletion = completion.find((item) => item.locale === locale.code);
|
||||
return (
|
||||
<div key={locale.code} className="rounded-md border border-border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-foreground">{locale.nativeName}</span>
|
||||
<span className="rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground">{locale.code}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{locale.name} · {locale.status} · fallback: {locale.fallbackLocale ?? "-"}
|
||||
</p>
|
||||
</div>
|
||||
{locale.builtIn ? (
|
||||
<span className="rounded-md bg-primary/10 px-2 py-0.5 text-xs text-primary">Built-in</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-4 h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{ width: `${localeCompletion?.percent ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{localeCompletion?.percent ?? 0}% tamamlandı</span>
|
||||
<span>{localeCompletion?.translated ?? 0}/{localeCompletion?.total ?? 0}</span>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button effect="shine" type="button" size="sm" variant="secondary" onClick={() => setSelectedLocale(locale.code)}>
|
||||
Düzenle
|
||||
</Button>
|
||||
{locale.status !== "active" ? (
|
||||
<Button effect="shine" type="button" size="sm" variant="secondary" onClick={() => handleUpdateLocaleStatus(locale.code, "active")}>
|
||||
Aktifleştir
|
||||
</Button>
|
||||
) : null}
|
||||
{locale.status === "active" ? (
|
||||
<Button effect="shine" type="button" size="sm" variant="secondary" onClick={() => handleSetDefaultLocale(locale.code)} disabled={defaultLocale === locale.code}>
|
||||
{defaultLocale === locale.code ? "Varsayılan" : "Default yap"}
|
||||
</Button>
|
||||
) : null}
|
||||
{!locale.builtIn && locale.status !== "archived" ? (
|
||||
<Button effect="shine" type="button" size="sm" variant="secondary" onClick={() => handleUpdateLocaleStatus(locale.code, "archived")}>
|
||||
Arşivle
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-md border border-border bg-muted/20 p-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">Yeni dil ekle</h3>
|
||||
<div className="grid gap-3">
|
||||
<Input value={newLocale.code} onChange={(event) => setNewLocale((current) => ({ ...current, code: event.target.value }))} placeholder="fr" />
|
||||
<Input value={newLocale.name} onChange={(event) => setNewLocale((current) => ({ ...current, name: event.target.value }))} placeholder="French" />
|
||||
<Input value={newLocale.nativeName} onChange={(event) => setNewLocale((current) => ({ ...current, nativeName: event.target.value }))} placeholder="Français" />
|
||||
<select
|
||||
value={newLocale.fallbackLocale}
|
||||
onChange={(event) => setNewLocale((current) => ({ ...current, fallbackLocale: event.target.value }))}
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm text-foreground"
|
||||
>
|
||||
{locales.filter((locale) => locale.status !== "archived").map((locale) => (
|
||||
<option key={locale.code} value={locale.code}>{locale.nativeName}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={newLocale.textDirection}
|
||||
onChange={(event) => setNewLocale((current) => ({ ...current, textDirection: event.target.value as "ltr" | "rtl" }))}
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm text-foreground"
|
||||
>
|
||||
<option value="ltr">LTR</option>
|
||||
<option value="rtl">RTL</option>
|
||||
</select>
|
||||
<Button effect="shine" type="button" variant="default" className="gap-2" onClick={handleCreateLocale}>
|
||||
<Languages className="h-4 w-4" />
|
||||
Dili ekle
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-5 border-t border-border pt-7 lg:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Kişisel arayüz dili</Label>
|
||||
<select
|
||||
value={language}
|
||||
onChange={(event) => handleLanguagePreferenceChange(event.target.value)}
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground"
|
||||
>
|
||||
{locales.filter((locale) => locale.status === "active").map((locale) => (
|
||||
<option key={locale.code} value={locale.code}>{locale.nativeName}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Çeviri hedef dili</Label>
|
||||
<select
|
||||
value={selectedLocale}
|
||||
onChange={(event) => setSelectedLocale(event.target.value)}
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground"
|
||||
>
|
||||
{locales.filter((locale) => locale.status !== "archived").map((locale) => (
|
||||
<option key={locale.code} value={locale.code}>{locale.nativeName}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 border-t border-border pt-7">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end">
|
||||
<div className="space-y-2 lg:w-56">
|
||||
<Label>Namespace</Label>
|
||||
<select
|
||||
value={selectedNamespace}
|
||||
onChange={(event) => setSelectedNamespace(event.target.value)}
|
||||
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground"
|
||||
>
|
||||
<option value="all">Tümü</option>
|
||||
{[...new Set(referenceKeys.map((reference) => reference.namespace))].map((namespace) => (
|
||||
<option key={namespace} value={namespace}>{namespace}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label>Eksik anahtar veya metin ara</Label>
|
||||
<Input value={translationSearch} onChange={(event) => setTranslationSearch(event.target.value)} placeholder="navigation.projects" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{filteredReferenceKeys.slice(0, 60).map((reference) => {
|
||||
const currentValue = getTranslationValue(translations, selectedLocale, reference);
|
||||
const draftValue = editingValues[reference.key] ?? currentValue;
|
||||
const isMissing = !currentValue;
|
||||
return (
|
||||
<div key={reference.key} className="grid gap-3 rounded-md border border-border p-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<code className="rounded-md bg-muted px-2 py-1 text-xs">{reference.key}</code>
|
||||
{isMissing ? <span className="rounded-md bg-destructive/10 px-2 py-1 text-xs text-destructive">Eksik</span> : null}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">TR: {reference.tr}</p>
|
||||
<p className="text-sm text-muted-foreground">EN: {reference.en}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={draftValue}
|
||||
onChange={(event) => setEditingValues((current) => ({ ...current, [reference.key]: event.target.value }))}
|
||||
rows={3}
|
||||
placeholder={reference.en || reference.tr}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button effect="shine" type="button" size="sm" variant="default" onClick={() => handleSaveTranslation(reference)}>
|
||||
Kaydet
|
||||
</Button>
|
||||
<Button effect="shine" type="button" size="sm" variant="secondary" onClick={() => handleResetTranslation(reference)}>
|
||||
Override sıfırla
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 border-t border-border pt-7">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h3 className="text-sm font-semibold text-foreground">JSON import / export</h3>
|
||||
<Button effect="shine" type="button" variant="secondary" className="gap-2" onClick={handleExportI18n}>
|
||||
<Download className="h-4 w-4" />
|
||||
Export oluştur
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={importJson}
|
||||
onChange={(event) => setImportJson(event.target.value)}
|
||||
rows={8}
|
||||
className="font-mono text-xs"
|
||||
placeholder='{"format":"neta-i18n","version":1,...}'
|
||||
/>
|
||||
{importPreview ? <p className="text-sm text-muted-foreground">{importPreview}</p> : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button effect="shine" type="button" variant="secondary" onClick={handlePreviewImport}>Preview</Button>
|
||||
<Button effect="shine" type="button" variant="default" onClick={handleCommitImport}>Import et</Button>
|
||||
</div>
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === "Profile & Account" && (
|
||||
<Card className="animate-in fade-in duration-300">
|
||||
<CardContent className="p-6 sm:p-8">
|
||||
@@ -753,3 +1183,24 @@ function BrandingAssetField({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getTranslationValue(
|
||||
translations: TranslationRow[],
|
||||
locale: string,
|
||||
reference: ReferenceKey,
|
||||
): string {
|
||||
return translations.find(
|
||||
(translation) =>
|
||||
translation.locale === locale &&
|
||||
translation.namespace === reference.namespace &&
|
||||
translation.key === reference.translationKey,
|
||||
)?.value ?? "";
|
||||
}
|
||||
|
||||
function toLocalizedValues(rows: ContentTranslationRow[]) {
|
||||
return rows.reduce<Record<string, Record<string, string>>>((result, row) => {
|
||||
result[row.locale] = result[row.locale] ?? {};
|
||||
result[row.locale][row.field] = row.value;
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user