feat: wire i18n services and shared UI
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user