diff --git a/app/(dashboard)/settings/actions.ts b/app/(dashboard)/settings/actions.ts index 7d21ede..c585e18 100644 --- a/app/(dashboard)/settings/actions.ts +++ b/app/(dashboard)/settings/actions.ts @@ -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(); +} diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index 1f156fc..bebd4ce 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -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([]); + const [defaultLocale, setDefaultLocale] = useState("tr"); + const [catalogVersion, setCatalogVersion] = useState(1); + const [translations, setTranslations] = useState([]); + const [brandingContentTranslations, setBrandingContentTranslations] = useState({}); + const [referenceKeys, setReferenceKeys] = useState([]); + const [completion, setCompletion] = useState([]); + 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>({}); + const [importJson, setImportJson] = useState(""); + const [importPreview, setImportPreview] = useState(""); const assetObjectUrlRefs = useRef>>({}); 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 (
@@ -442,6 +639,22 @@ export default function SettingsPage() {
+
+
+

Portal metinleri

+

+ Müşteri portalında kullanılacak karşılama ve footer metinlerini aktif dillere göre girin. +

+
+ locale.status !== "archived")} + fields={contentTranslationRegistry.branding} + values={brandingContentTranslations} + /> +
+
+ {locale.status !== "active" ? ( + + ) : null} + {locale.status === "active" ? ( + + ) : null} + {!locale.builtIn && locale.status !== "archived" ? ( + + ) : null} +
+
+ ); + })} + + + +
+

Yeni dil ekle

+
+ setNewLocale((current) => ({ ...current, code: event.target.value }))} placeholder="fr" /> + setNewLocale((current) => ({ ...current, name: event.target.value }))} placeholder="French" /> + setNewLocale((current) => ({ ...current, nativeName: event.target.value }))} placeholder="Français" /> + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
+ + setTranslationSearch(event.target.value)} placeholder="navigation.projects" /> +
+
+ +
+ {filteredReferenceKeys.slice(0, 60).map((reference) => { + const currentValue = getTranslationValue(translations, selectedLocale, reference); + const draftValue = editingValues[reference.key] ?? currentValue; + const isMissing = !currentValue; + return ( +
+
+
+ {reference.key} + {isMissing ? Eksik : null} +
+

TR: {reference.tr}

+

EN: {reference.en}

+
+
+