feat: wire i18n services and shared UI

This commit is contained in:
poyrazavsever
2026-07-19 03:06:56 +03:00
parent 805beccf95
commit 826410f53e
11 changed files with 1330 additions and 0 deletions
+275
View File
@@ -0,0 +1,275 @@
import "server-only";
import { and, eq, inArray } from "drizzle-orm";
import {
contentInputName,
contentTranslationRegistry,
type ContentTranslationInput,
} from "../../lib/i18n/content";
import {
contentTranslations,
instanceI18nSettings,
instanceLocales,
type TranslationEntityType,
} from "../db/schema";
import type { DomainActor } from "../domain/actor";
import type { DomainDatabase } from "../domain/database";
import { DomainError } from "../domain/errors";
import { I18nService, type LocaleRecord } from "./service";
export type ContentTranslationRow = {
entityType: TranslationEntityType;
entityId: string;
field: string;
locale: string;
value: string;
};
export type ContentLocalizationContext = {
defaultLocale: string;
locales: LocaleRecord[];
};
export class ContentTranslationService {
constructor(private readonly db: DomainDatabase) {}
getLocalizationContext(actor: DomainActor): ContentLocalizationContext {
const i18n = new I18nService(this.db);
const settings = i18n.getSettings(actor);
const locales = i18n
.listLocales(actor)
.filter((locale) => locale.status !== "archived")
.sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
return {
defaultLocale: settings.defaultLocale,
locales,
};
}
getPublicLocalizationContext(): ContentLocalizationContext {
const settings = this.db
.select()
.from(instanceI18nSettings)
.where(eq(instanceI18nSettings.key, "default"))
.get();
const locales = this.db
.select()
.from(instanceLocales)
.where(eq(instanceLocales.status, "active"))
.orderBy(instanceLocales.sortOrder, instanceLocales.code)
.all()
.map(toContentLocaleRecord);
return {
defaultLocale: settings?.defaultLocale ?? "tr",
locales,
};
}
listEntityTranslations(entityType: TranslationEntityType, entityId: string): ContentTranslationRow[] {
return this.db
.select()
.from(contentTranslations)
.where(and(eq(contentTranslations.entityType, entityType), eq(contentTranslations.entityId, entityId)))
.all()
.map(toContentTranslationRow);
}
listBatch(entityType: TranslationEntityType, entityIds: readonly string[]): Map<string, ContentTranslationRow[]> {
const uniqueIds = [...new Set(entityIds)].filter(Boolean);
const result = new Map<string, ContentTranslationRow[]>();
if (uniqueIds.length === 0) return result;
for (const row of this.db
.select()
.from(contentTranslations)
.where(and(eq(contentTranslations.entityType, entityType), inArray(contentTranslations.entityId, uniqueIds)))
.all()
.map(toContentTranslationRow)) {
const rows = result.get(row.entityId) ?? [];
rows.push(row);
result.set(row.entityId, rows);
}
return result;
}
upsertEntityTranslations(
entityType: TranslationEntityType,
entityId: string,
input: ContentTranslationInput | null | undefined,
) {
const fields = new Set(contentTranslationRegistry[entityType].map((field) => field.name));
if (!input) return;
for (const [locale, values] of Object.entries(input)) {
for (const [field, rawValue] of Object.entries(values)) {
if (!fields.has(field)) continue;
const value = normalizeOptionalText(rawValue);
if (!value) {
this.db
.delete(contentTranslations)
.where(
and(
eq(contentTranslations.entityType, entityType),
eq(contentTranslations.entityId, entityId),
eq(contentTranslations.field, field),
eq(contentTranslations.locale, locale),
),
)
.run();
continue;
}
this.db
.insert(contentTranslations)
.values({ entityType, entityId, field, locale, value })
.onConflictDoUpdate({
target: [
contentTranslations.entityType,
contentTranslations.entityId,
contentTranslations.field,
contentTranslations.locale,
],
set: {
value,
updatedAt: new Date(),
},
})
.run();
}
}
}
deleteEntityTranslations(entityType: TranslationEntityType, entityId: string) {
this.db
.delete(contentTranslations)
.where(and(eq(contentTranslations.entityType, entityType), eq(contentTranslations.entityId, entityId)))
.run();
}
resolveEntity<T extends Record<string, unknown>>(
entityType: TranslationEntityType,
entity: T,
options: {
locale: string;
fallbackLocale?: string | null;
defaultLocale: string;
translations?: ContentTranslationRow[];
},
): T {
const translations = options.translations ?? this.listEntityTranslations(entityType, String(entity.id ?? ""));
const resolved = { ...entity };
for (const field of contentTranslationRegistry[entityType]) {
const value = resolveFieldValue(translations, field.name, [
options.locale,
options.fallbackLocale,
options.defaultLocale,
]);
if (value != null) {
(resolved as Record<string, unknown>)[field.name] = value;
}
}
return resolved;
}
}
export function parseContentTranslationsFromFormData(
formData: FormData,
entityType: TranslationEntityType,
context: ContentLocalizationContext,
): ContentTranslationInput {
const fields = contentTranslationRegistry[entityType];
const translations: ContentTranslationInput = {};
for (const locale of context.locales) {
const values: Record<string, string | null> = {};
for (const field of fields) {
const raw = formData.get(contentInputName(locale.code, field.name));
values[field.name] = normalizeOptionalText(typeof raw === "string" ? raw : null);
}
translations[locale.code] = values;
}
assertDefaultLocaleRequiredFields(entityType, translations, context.defaultLocale);
return translations;
}
export function projectBaseFromTranslations<T extends Record<string, unknown>>(
entityType: TranslationEntityType,
payload: T,
translations: ContentTranslationInput,
defaultLocale: string,
): T {
const next = { ...payload };
const defaultValues = translations[defaultLocale] ?? {};
for (const field of contentTranslationRegistry[entityType]) {
const value = normalizeOptionalText(defaultValues[field.name]);
if (value != null) {
(next as Record<string, unknown>)[field.name] = value;
}
}
return next;
}
export function getContentFallbackLocale(
locale: string,
context: ContentLocalizationContext,
): string | null {
return context.locales.find((item) => item.code === locale)?.fallbackLocale ?? null;
}
function assertDefaultLocaleRequiredFields(
entityType: TranslationEntityType,
translations: ContentTranslationInput,
defaultLocale: string,
) {
const defaultValues = translations[defaultLocale] ?? {};
for (const field of contentTranslationRegistry[entityType]) {
if (!("required" in field) || !field.required) continue;
if (!normalizeOptionalText(defaultValues[field.name])) {
throw new DomainError("VALIDATION_ERROR", `${field.label} varsayılan dilde zorunludur.`);
}
}
}
function resolveFieldValue(rows: ContentTranslationRow[], field: string, localeChain: Array<string | null | undefined>) {
const uniqueLocales = [...new Set(localeChain.filter(Boolean) as string[])];
return rows.find((row) => row.field === field && uniqueLocales.includes(row.locale))?.value ?? null;
}
function normalizeOptionalText(value: string | null | undefined) {
if (typeof value !== "string") return null;
const normalized = value.trim();
return normalized.length > 0 ? normalized : null;
}
function toContentTranslationRow(row: typeof contentTranslations.$inferSelect): ContentTranslationRow {
return {
entityType: row.entityType,
entityId: row.entityId,
field: row.field,
locale: row.locale,
value: row.value,
};
}
function toContentLocaleRecord(row: typeof instanceLocales.$inferSelect): LocaleRecord {
return {
code: row.code,
name: row.name,
nativeName: row.nativeName,
status: row.status,
fallbackLocale: row.fallbackLocale,
textDirection: row.textDirection,
builtIn: row.builtIn,
sortOrder: row.sortOrder,
};
}