feat: add language settings management

This commit is contained in:
poyrazavsever
2026-07-19 03:07:04 +03:00
parent 826410f53e
commit 5d3e720280
12 changed files with 1210 additions and 2 deletions
+180
View File
@@ -12,13 +12,21 @@ import { getServerConfig } from "@/server/config";
import { getBrandingService } from "@/server/branding/runtime"; import { getBrandingService } from "@/server/branding/runtime";
import { getSqliteConnection } from "@/server/db/client"; import { getSqliteConnection } from "@/server/db/client";
import { appProfiles } from "@/server/db/schema"; import { appProfiles } from "@/server/db/schema";
import { runtimeEvents } from "@/server/db/schema/runtime";
import { domainActorFromSession } from "@/server/auth/domain-actor"; import { domainActorFromSession } from "@/server/auth/domain-actor";
import { getFileService } from "@/server/files/runtime"; import { getFileService } from "@/server/files/runtime";
import { getPublicAiSettings, updateAiSettings } from "@/server/settings/ai"; import { getPublicAiSettings, updateAiSettings } from "@/server/settings/ai";
import { import {
getUserPreferences, getUserPreferences,
updateLanguagePreference,
updateColorModePreference, updateColorModePreference,
} from "@/server/settings/preferences"; } 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 { requireFreelancerBackend } from "@/server/web/freelancer";
import { cleanText } from "@/server/web/form-data"; import { cleanText } from "@/server/web/form-data";
@@ -28,6 +36,13 @@ export async function loadSettings() {
const ai = getPublicAiSettings(actor); const ai = getPublicAiSettings(actor);
const preferences = getUserPreferences(actor); const preferences = getUserPreferences(actor);
const branding = getBrandingService().getPublic(); 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 { return {
firstName, firstName,
@@ -46,6 +61,18 @@ export async function loadSettings() {
hasCustomLightLogo: Boolean(branding.lightLogoFileId), hasCustomLightLogo: Boolean(branding.lightLogoFileId),
hasCustomDarkLogo: Boolean(branding.darkLogoFileId), hasCustomDarkLogo: Boolean(branding.darkLogoFileId),
hasCustomFavicon: Boolean(branding.iconFileId), 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) { export async function saveGeneralSettings(formData: FormData) {
const uploadedFileIds: string[] = []; const uploadedFileIds: string[] = [];
let brandingCommitted = false; let brandingCommitted = false;
@@ -170,6 +331,9 @@ export async function saveGeneralSettings(formData: FormData) {
} }
const brandingService = getBrandingService(); 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 current = brandingService.getPublic();
const lightLogoFileId = await uploadBrandingFile(formData, "lightLogo", "branding_logo", actor); const lightLogoFileId = await uploadBrandingFile(formData, "lightLogo", "branding_logo", actor);
if (lightLogoFileId) uploadedFileIds.push(lightLogoFileId); if (lightLogoFileId) uploadedFileIds.push(lightLogoFileId);
@@ -188,6 +352,7 @@ export async function saveGeneralSettings(formData: FormData) {
...(iconFileId ? { iconFileId } : {}), ...(iconFileId ? { iconFileId } : {}),
}); });
brandingCommitted = true; brandingCommitted = true;
contentI18n.upsertEntityTranslations("branding", "default", brandingTranslations);
deleteSupersededBrandingFiles(actor, current, updated); deleteSupersededBrandingFiles(actor, current, updated);
@@ -306,3 +471,18 @@ function revalidateBrandingPaths(): void {
revalidatePath("/portal", "layout"); revalidatePath("/portal", "layout");
revalidatePath("/manifest.webmanifest"); 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();
}
+451
View File
@@ -5,8 +5,11 @@ import Image from "next/image";
import { import {
Blocks, Blocks,
Brain, Brain,
Download,
Globe2,
ImageIcon, ImageIcon,
Key, Key,
Languages,
Monitor, Monitor,
Moon, Moon,
Palette, Palette,
@@ -19,10 +22,19 @@ import {
} from "lucide-react"; } from "lucide-react";
import { import {
loadSettings, loadSettings,
commitI18nImportAction,
createLocaleAction,
exportI18nAction,
previewI18nImportAction,
removeBrandingAsset, removeBrandingAsset,
resetUiTranslationAction,
saveAiSettings, saveAiSettings,
saveColorMode, saveColorMode,
saveGeneralSettings, saveGeneralSettings,
saveLanguagePreference,
saveUiTranslationAction,
setDefaultLocaleAction,
updateLocaleStatusAction,
updatePassword, updatePassword,
updateProfile, updateProfile,
} from "./actions"; } from "./actions";
@@ -34,13 +46,44 @@ import {
Label, Label,
RadioGroup, RadioGroup,
RadioGroupItem, RadioGroupItem,
Textarea,
} from "poyraz-ui/atoms"; } from "poyraz-ui/atoms";
import { toast } from "poyraz-ui/molecules"; import { toast } from "poyraz-ui/molecules";
import { applyColorMode } from "@/components/theme/color-mode-sync"; 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 { isColorMode, type ColorMode } from "@/lib/color-mode";
import { contentTranslationRegistry } from "@/lib/i18n/content";
type AiProvider = "groq" | "ollama" | "openai" | "gemini"; type AiProvider = "groq" | "ollama" | "openai" | "gemini";
type BrandingAsset = "lightLogo" | "darkLogo" | "favicon"; 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 = [ const colorModeOptions = [
{ {
@@ -105,10 +148,32 @@ export default function SettingsPage() {
favicon: false, favicon: false,
}); });
const [isSavingBranding, setIsSavingBranding] = useState(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 assetObjectUrlRefs = useRef<Partial<Record<BrandingAsset, string>>>({});
const tabs = [ const tabs = [
{ name: "Genel", icon: Palette }, { name: "Genel", icon: Palette },
{ name: "Diller ve çeviriler", icon: Languages },
{ name: "Profile & Account", icon: User }, { name: "Profile & Account", icon: User },
{ name: "AI Preferences", icon: Brain }, { name: "AI Preferences", icon: Brain },
{ name: "Security", icon: Shield }, { name: "Security", icon: Shield },
@@ -140,6 +205,15 @@ export default function SettingsPage() {
darkLogo: settings.hasCustomDarkLogo, darkLogo: settings.hasCustomDarkLogo,
favicon: settings.hasCustomFavicon, 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(); 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 ( return (
<div className="mx-auto flex max-w-7xl flex-col gap-6"> <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"> <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> </div>
</section> </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"> <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"> <Button variant="default" effect="shine" type="submit" loading={isSavingBranding} className="gap-2">
<Upload className="h-4 w-4" aria-hidden="true" /> <Upload className="h-4 w-4" aria-hidden="true" />
@@ -518,6 +731,223 @@ export default function SettingsPage() {
</Card> </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" && ( {activeTab === "Profile & Account" && (
<Card className="animate-in fade-in duration-300"> <Card className="animate-in fade-in duration-300">
<CardContent className="p-6 sm:p-8"> <CardContent className="p-6 sm:p-8">
@@ -753,3 +1183,24 @@ function BrandingAssetField({
</div> </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;
}, {});
}
+125
View File
@@ -0,0 +1,125 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
const dataDir = path.join(process.cwd(), ".data", `i18n-phase1-smoke-${Date.now()}`);
const databasePath = path.join(dataDir, "neta.db");
const restoreDir = `${dataDir}-restore`;
const env = { ...process.env, DATA_DIR: dataDir, DATABASE_PATH: databasePath };
fs.mkdirSync(dataDir, { recursive: true });
execFileSync(process.execPath, ["scripts/migrate.mjs"], {
cwd: process.cwd(),
env,
stdio: "inherit",
});
assertMigratedDatabase(databasePath, "tr");
execFileSync("./node_modules/.bin/tsc", ["-p", "tsconfig.i18n-phase1-smoke.json"], {
cwd: process.cwd(),
stdio: "inherit",
});
execFileSync(
process.execPath,
[path.join(".next", "i18n-phase1-smoke-dist", "scripts", "i18n-phase1-smoke.js"), databasePath],
{ cwd: process.cwd(), stdio: "inherit" },
);
execFileSync(process.execPath, ["scripts/backup.mjs", "--retention-count", "1"], {
cwd: process.cwd(),
env,
stdio: "inherit",
});
const backupDir = latestBackupDir(path.join(dataDir, "backups"));
execFileSync(
process.execPath,
["scripts/restore.mjs", "--from", backupDir, "--target", restoreDir],
{ cwd: process.cwd(), env: process.env, stdio: "inherit" },
);
assertMigratedDatabase(path.join(restoreDir, "neta.db"), "fr");
assertRestoredI18nData(path.join(restoreDir, "neta.db"));
console.log("I18n phase 1 migration, service and backup/restore smoke passed.");
function assertMigratedDatabase(dbPath, expectedDefaultLocale) {
const sqlite = new Database(dbPath);
try {
for (const tableName of [
"instance_locales",
"instance_i18n_settings",
"instance_ui_translations",
"content_translations",
]) {
assert.equal(
sqlite.prepare("select count(*) as value from sqlite_master where type = 'table' and name = ?").get(tableName).value,
1,
`${tableName} must exist`,
);
}
const builtInLocales = sqlite
.prepare("select code, status, fallback_locale as fallbackLocale, built_in as builtIn from instance_locales where code in ('tr', 'en') order by sort_order")
.all();
assert.deepEqual(
builtInLocales,
[
{ code: "tr", status: "active", fallbackLocale: null, builtIn: 1 },
{ code: "en", status: "active", fallbackLocale: "tr", builtIn: 1 },
],
"Built-in locales must be seeded",
);
assert.deepEqual(
sqlite.prepare("select key, default_locale as defaultLocale, catalog_version as catalogVersion from instance_i18n_settings").all(),
[{ key: "default", defaultLocale: expectedDefaultLocale, catalogVersion: 1 }],
"Default i18n settings must be seeded",
);
const clientColumns = sqlite.prepare("pragma table_info(clients)").all().map((column) => column.name);
assert.equal(clientColumns.includes("portal_locale"), true, "clients.portal_locale must exist");
const invitationColumns = sqlite.prepare("pragma table_info(portal_invitations)").all().map((column) => column.name);
assert.equal(invitationColumns.includes("locale"), true, "portal_invitations.locale must exist");
sqlite.prepare(
"insert into user (id, name, email, email_verified, created_at, updated_at) values (?, ?, ?, ?, ?, ?)",
).run("locale-preference-user", "Locale Preference User", "locale-preference@example.com", 1, Date.now(), Date.now());
sqlite.prepare("insert into user_preferences (owner_user_id, language) values (?, ?)").run("locale-preference-user", "fr");
sqlite.prepare("delete from user_preferences where owner_user_id = ?").run("locale-preference-user");
sqlite.prepare("delete from user where id = ?").run("locale-preference-user");
} finally {
sqlite.close();
}
}
function assertRestoredI18nData(dbPath) {
const sqlite = new Database(dbPath);
try {
assert.equal(
sqlite.prepare("select default_locale as value from instance_i18n_settings where key = 'default'").get().value,
"fr",
"Restored backup must include updated i18n settings",
);
assert.equal(
sqlite.prepare("select value from content_translations where locale = 'fr' and entity_id = 'i18n-project'").get().value,
"Projet multilingue",
"Restored backup must include content translations",
);
} finally {
sqlite.close();
}
}
function latestBackupDir(backupsDir) {
const entries = fs
.readdirSync(backupsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => path.join(backupsDir, entry.name))
.sort((left, right) => path.basename(right).localeCompare(path.basename(left)));
assert.ok(entries[0], "Backup directory must contain a backup");
return entries[0];
}
+132
View File
@@ -0,0 +1,132 @@
import assert from "node:assert/strict";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import * as schema from "../server/db/schema";
import type { DomainActor } from "../server/domain/actor";
import { DomainError } from "../server/domain/errors";
import { I18nService } from "../server/i18n/service";
const databasePath = process.argv[2];
assert.ok(databasePath, "Database path is required");
const sqlite = new Database(databasePath);
sqlite.pragma("foreign_keys = ON");
const db = drizzle({ client: sqlite, schema });
const owner: DomainActor = {
authUserId: "i18n-owner",
role: "freelancer",
clientId: null,
disabled: false,
};
const client: DomainActor = {
authUserId: "i18n-client-user",
role: "client",
clientId: "i18n-client",
disabled: false,
};
const disabledOwner: DomainActor = {
...owner,
authUserId: "i18n-disabled-owner",
disabled: true,
};
try {
for (const actor of [owner, client, disabledOwner]) {
db.insert(schema.user).values({
id: actor.authUserId,
name: actor.authUserId,
email: `${actor.authUserId}@example.com`,
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
}).run();
}
const service = new I18nService(db);
const locales = service.listLocales(owner);
assert.deepEqual(
locales.map((locale) => ({
code: locale.code,
status: locale.status,
builtIn: locale.builtIn,
fallbackLocale: locale.fallbackLocale,
})),
[
{ code: "tr", status: "active", builtIn: true, fallbackLocale: null },
{ code: "en", status: "active", builtIn: true, fallbackLocale: "tr" },
],
);
assert.deepEqual(service.getSettings(owner), { defaultLocale: "tr", catalogVersion: 1 });
assertDomainError(() => service.listLocales(client), "FORBIDDEN");
assertDomainError(() => service.createLocale(disabledOwner, { code: "fr", name: "French" }), "FORBIDDEN");
assertDomainError(() => service.createLocale(owner, { code: "english", name: "English" }), "VALIDATION_ERROR");
assertDomainError(() => service.createLocale(owner, { code: "en", name: "English" }), "CONFLICT");
assertDomainError(
() => service.createLocale(owner, { code: "de", name: "German", fallbackLocale: "de" }),
"VALIDATION_ERROR",
);
assertDomainError(
() => service.createLocale(owner, { code: "pt", name: "Portuguese", fallbackLocale: "zz" }),
"VALIDATION_ERROR",
);
const french = service.createLocale(owner, {
code: "fr",
name: "French",
nativeName: "Français",
fallbackLocale: "en",
});
assert.equal(french.status, "draft");
assert.equal(french.fallbackLocale, "en");
service.createLocale(owner, { code: "es", name: "Spanish", fallbackLocale: "fr" });
assertDomainError(() => service.updateLocale(owner, "fr", { fallbackLocale: "es" }), "VALIDATION_ERROR");
assertDomainError(() => service.setDefaultLocale(owner, "fr"), "VALIDATION_ERROR");
const activeFrench = service.updateLocale(owner, "fr", { status: "active" });
assert.equal(activeFrench.status, "active");
assert.equal(service.setDefaultLocale(owner, "fr").defaultLocale, "fr");
assertDomainError(() => service.archiveLocale(owner, "fr"), "CONFLICT");
assertDomainError(() => service.archiveLocale(owner, "en"), "CONFLICT");
service.createLocale(owner, {
code: "it",
name: "Italian",
nativeName: "Italiano",
status: "active",
fallbackLocale: "en",
});
db.insert(schema.clients).values({
id: "i18n-client",
ownerUserId: owner.authUserId,
name: "I18n Client",
authUserId: client.authUserId,
portalLocale: "it",
}).run();
assertDomainError(() => service.archiveLocale(owner, "it"), "CONFLICT");
db.insert(schema.contentTranslations).values({
entityType: "project",
entityId: "i18n-project",
field: "name",
locale: "fr",
value: "Projet multilingue",
}).run();
const persisted = db.select().from(schema.instanceLocales).all();
assert.equal(persisted.some((locale) => locale.code === "fr" && locale.status === "active"), true);
assert.equal(
db.select().from(schema.contentTranslations).all()[0]?.value,
"Projet multilingue",
);
console.log("I18n phase 1 service smoke passed.");
} finally {
sqlite.close();
}
function assertDomainError(run: () => unknown, code: DomainError["code"]): void {
assert.throws(run, (error) => error instanceof DomainError && error.code === code);
}
+27
View File
@@ -0,0 +1,27 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const dataDir = path.join(process.cwd(), ".data", `i18n-phase2-smoke-${Date.now()}`);
const databasePath = path.join(dataDir, "neta.db");
const env = { ...process.env, DATA_DIR: dataDir, DATABASE_PATH: databasePath };
fs.mkdirSync(dataDir, { recursive: true });
execFileSync(process.execPath, ["scripts/migrate.mjs"], {
cwd: process.cwd(),
env,
stdio: "inherit",
});
execFileSync("./node_modules/.bin/tsc", ["-p", "tsconfig.i18n-phase2-smoke.json"], {
cwd: process.cwd(),
stdio: "inherit",
});
const serverOnlyStubDir = path.join(process.cwd(), ".next", "i18n-phase2-smoke-dist", "node_modules", "server-only");
fs.mkdirSync(serverOnlyStubDir, { recursive: true });
fs.writeFileSync(path.join(serverOnlyStubDir, "index.js"), "\n");
execFileSync(
process.execPath,
[path.join(".next", "i18n-phase2-smoke-dist", "scripts", "i18n-phase2-smoke.js")],
{ cwd: process.cwd(), env, stdio: "inherit" },
);
+93
View File
@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import {
compareCatalogKeys,
createTranslatorFromMessages,
formatDate,
formatMoney,
I18N_NAMESPACES,
} from "../lib/i18n";
import { enCatalog } from "../locales/en";
import { trCatalog } from "../locales/tr";
import { getSqliteConnection } from "../server/db/client";
import type { DomainActor } from "../server/domain/actor";
import { DomainError } from "../server/domain/errors";
import { I18nService } from "../server/i18n/service";
import { directionForLocale } from "../server/i18n/locale";
import { createTranslator } from "../server/i18n/translator";
const owner: DomainActor = {
authUserId: "i18n-phase2-owner",
role: "freelancer",
clientId: null,
disabled: false,
};
const client: DomainActor = {
authUserId: "i18n-phase2-client",
role: "client",
clientId: "i18n-phase2-client",
disabled: false,
};
const parity = compareCatalogKeys(trCatalog, enCatalog, I18N_NAMESPACES);
assert.deepEqual(parity, { missingInLeft: [], missingInRight: [] }, "TR/EN catalog keys must match");
const clientTranslator = createTranslatorFromMessages("en", {
"common.greeting": "Hello {name}",
"common.items": "{count, plural, one {# item} other {# items}}",
});
assert.equal(clientTranslator.t("common.greeting", { name: "Neta" }), "Hello Neta");
assert.equal(clientTranslator.t("common.items", { count: 1 }), "1 item");
assert.equal(clientTranslator.t("common.items", { count: 3 }), "3 items");
assert.equal(directionForLocale("ar-XB"), "rtl");
assert.match(formatDate("2026-07-19", "en"), /July/);
assert.match(formatMoney(12345, "USD", "en"), /\$123\.45/);
const service = new I18nService(getSqliteConnection().db);
service.listLocales(owner);
const tr = createTranslator("tr", ["common"]);
const en = createTranslator("en", ["common"]);
assert.equal(tr.t("common.actions.save"), "Kaydet");
assert.equal(en.t("common.actions.save"), "Save");
service.createLocale(owner, {
code: "fr",
name: "French",
nativeName: "Français",
fallbackLocale: "en",
});
service.upsertUiTranslation(owner, {
locale: "fr",
namespace: "common",
key: "actions.save",
value: "Enregistrer",
});
assertDomainError(
() => service.upsertUiTranslation(client, {
locale: "fr",
namespace: "common",
key: "actions.cancel",
value: "Annuler",
}),
"FORBIDDEN",
);
const fr = createTranslator("fr", ["common"]);
assert.equal(fr.t("common.actions.save"), "Enregistrer");
assert.equal(fr.t("common.actions.cancel"), "Cancel");
assert.equal(fr.t("common.missing.key"), "common.missing.key");
service.upsertUiTranslation(owner, {
locale: "fr",
namespace: "common",
key: "actions.cancel",
value: "Annuler",
});
const frAfterBump = createTranslator("fr", ["common"]);
assert.equal(frAfterBump.t("common.actions.cancel"), "Annuler");
console.log("I18n phase 2 runtime smoke passed.");
function assertDomainError(run: () => unknown, code: DomainError["code"]): void {
assert.throws(run, (error) => error instanceof DomainError && error.code === code);
}
+27
View File
@@ -0,0 +1,27 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const dataDir = path.join(process.cwd(), ".data", `i18n-phase3-smoke-${Date.now()}`);
const databasePath = path.join(dataDir, "neta.db");
const env = { ...process.env, DATA_DIR: dataDir, DATABASE_PATH: databasePath };
fs.mkdirSync(dataDir, { recursive: true });
execFileSync(process.execPath, ["scripts/migrate.mjs"], {
cwd: process.cwd(),
env,
stdio: "inherit",
});
execFileSync("./node_modules/.bin/tsc", ["-p", "tsconfig.i18n-phase3-smoke.json"], {
cwd: process.cwd(),
stdio: "inherit",
});
const serverOnlyStubDir = path.join(process.cwd(), ".next", "i18n-phase3-smoke-dist", "node_modules", "server-only");
fs.mkdirSync(serverOnlyStubDir, { recursive: true });
fs.writeFileSync(path.join(serverOnlyStubDir, "index.js"), "\n");
execFileSync(
process.execPath,
[path.join(".next", "i18n-phase3-smoke-dist", "scripts", "i18n-phase3-smoke.js")],
{ cwd: process.cwd(), env, stdio: "inherit" },
);
+72
View File
@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import { getSqliteConnection } from "../server/db/client";
import type { DomainActor } from "../server/domain/actor";
import { DomainError } from "../server/domain/errors";
import { I18nService } from "../server/i18n/service";
const owner: DomainActor = {
authUserId: "phase3-owner",
role: "freelancer",
clientId: null,
disabled: false,
};
const client: DomainActor = {
authUserId: "phase3-client-user",
role: "client",
clientId: "phase3-client",
disabled: false,
};
const service = new I18nService(getSqliteConnection().db);
service.listLocales(owner);
assertDomainError(() => service.listLocales(client), "FORBIDDEN");
const fr = service.createLocale(owner, {
code: "fr",
name: "French",
nativeName: "Français",
fallbackLocale: "en",
});
assert.equal(fr.status, "draft");
const beforeCompletion = service.getCompletion(owner).find((item) => item.locale === "fr");
assert.ok(beforeCompletion);
assert.ok(beforeCompletion.missingKeys.includes("navigation.items.projects"));
service.upsertUiTranslation(owner, {
locale: "fr",
namespace: "navigation",
key: "items.projects",
value: "Projets",
});
const exported = service.exportPackage(owner);
assert.equal(exported.format, "neta-i18n");
assert.equal(exported.translations.some((row) => row.locale === "fr" && row.value === "Projets"), true);
service.resetUiTranslation(owner, {
locale: "fr",
namespace: "navigation",
key: "items.projects",
});
assert.equal(
service.listUiTranslations(owner).some((row) => row.locale === "fr" && row.value === "Projets"),
false,
);
service.importPackage(owner, exported);
assert.equal(
service.listUiTranslations(owner).some((row) => row.locale === "fr" && row.value === "Projets"),
true,
);
service.updateLocale(owner, "fr", { status: "active" });
assert.equal(service.setDefaultLocale(owner, "fr").defaultLocale, "fr");
assertDomainError(() => service.archiveLocale(owner, "fr"), "CONFLICT");
console.log("I18n phase 3 settings smoke passed.");
function assertDomainError(run: () => unknown, code: DomainError["code"]): void {
assert.throws(run, (error) => error instanceof DomainError && error.code === code);
}
+46 -2
View File
@@ -11,22 +11,30 @@ import { DomainError } from "@/server/domain/errors";
const colorModeInputSchema = z.object({ const colorModeInputSchema = z.object({
colorMode: z.enum(["light", "dark", "system"]), colorMode: z.enum(["light", "dark", "system"]),
}); });
const languageInputSchema = z.object({
language: z.string().trim().regex(/^[a-z]{2}(?:-[A-Z]{2}[0-9]?)?$/),
});
export type PublicUserPreferences = { export type PublicUserPreferences = {
colorMode: ColorMode; colorMode: ColorMode;
language: string;
}; };
export function getUserPreferences(actor: DomainActor): PublicUserPreferences { export function getUserPreferences(actor: DomainActor): PublicUserPreferences {
assertEnabledActor(actor); assertEnabledActor(actor);
const row = getSqliteConnection().db const row = getSqliteConnection().db
.select({ colorMode: userPreferences.colorMode }) .select({
colorMode: userPreferences.colorMode,
language: userPreferences.language,
})
.from(userPreferences) .from(userPreferences)
.where(eq(userPreferences.ownerUserId, actor.authUserId)) .where(eq(userPreferences.ownerUserId, actor.authUserId))
.get(); .get();
return { return {
colorMode: (row?.colorMode as ColorMode | undefined) ?? "system", colorMode: (row?.colorMode as ColorMode | undefined) ?? "system",
language: row?.language ?? "tr",
}; };
} }
@@ -56,5 +64,41 @@ export function updateColorModePreference(
}) })
.run(); .run();
return { colorMode: parsed.data.colorMode }; return {
colorMode: parsed.data.colorMode,
language: getUserPreferences(actor).language,
};
}
export function updateLanguagePreference(
actor: DomainActor,
input: unknown,
): PublicUserPreferences {
assertEnabledActor(actor);
const parsed = languageInputSchema.safeParse(input);
if (!parsed.success) {
throw new DomainError("VALIDATION_ERROR", "Dil tercihi geçersiz.");
}
const { db } = getSqliteConnection();
db.insert(userPreferences)
.values({
ownerUserId: actor.authUserId,
language: parsed.data.language,
})
.onConflictDoUpdate({
target: userPreferences.ownerUserId,
set: {
language: parsed.data.language,
updatedAt: new Date().toISOString(),
},
})
.run();
const preferences = getUserPreferences(actor);
return {
...preferences,
language: parsed.data.language,
};
} }
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.phase2-smoke.json",
"compilerOptions": {
"outDir": ".next/i18n-phase1-smoke-dist"
},
"include": [
"scripts/i18n-phase1-smoke.ts",
"server/auth/types.ts",
"server/db/schema/**/*.ts",
"server/domain/**/*.ts",
"server/i18n/locale.ts",
"server/i18n/service.ts",
"server/repositories/i18n.ts"
],
"exclude": ["node_modules"]
}
+21
View File
@@ -0,0 +1,21 @@
{
"extends": "./tsconfig.phase2-smoke.json",
"compilerOptions": {
"outDir": ".next/i18n-phase2-smoke-dist"
},
"include": [
"scripts/i18n-phase2-smoke.ts",
"components/i18n/**/*.tsx",
"lib/i18n/**/*.ts",
"locales/**/*.ts",
"server/auth/types.ts",
"server/db/**/*.ts",
"server/domain/**/*.ts",
"server/i18n/catalog.ts",
"server/i18n/locale.ts",
"server/i18n/service.ts",
"server/i18n/translator.ts",
"server/repositories/i18n.ts"
],
"exclude": ["node_modules"]
}
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "./tsconfig.i18n-phase2-smoke.json",
"compilerOptions": {
"outDir": ".next/i18n-phase3-smoke-dist"
},
"include": [
"scripts/i18n-phase3-smoke.ts",
"lib/i18n/**/*.ts",
"locales/**/*.ts",
"server/auth/types.ts",
"server/db/**/*.ts",
"server/domain/**/*.ts",
"server/i18n/catalog.ts",
"server/i18n/locale.ts",
"server/i18n/service.ts",
"server/i18n/translator.ts",
"server/repositories/i18n.ts"
],
"exclude": ["node_modules"]
}