From 36c780bd09b5ac2a543893e8bb3d56d293552f28 Mon Sep 17 00:00:00 2001 From: YuukanOO Date: Thu, 23 Nov 2023 09:55:30 +0100 Subject: [PATCH] chore: wip working on localization --- cmd/serve/front/src/app.d.ts | 7 + cmd/serve/front/src/lib/localization/en.ts | 14 ++ cmd/serve/front/src/lib/localization/fr.ts | 13 ++ cmd/serve/front/src/lib/localization/index.ts | 181 ++++++++++++++++++ .../src/routes/(auth)/signin/+page.svelte | 13 +- 5 files changed, 222 insertions(+), 6 deletions(-) create mode 100644 cmd/serve/front/src/lib/localization/en.ts create mode 100644 cmd/serve/front/src/lib/localization/fr.ts create mode 100644 cmd/serve/front/src/lib/localization/index.ts diff --git a/cmd/serve/front/src/app.d.ts b/cmd/serve/front/src/app.d.ts index 4c0b239e..4b148dc5 100644 --- a/cmd/serve/front/src/app.d.ts +++ b/cmd/serve/front/src/app.d.ts @@ -11,6 +11,13 @@ declare namespace App { } type Maybe = T | undefined; + +type DateValue = string | number | Date; + +type KeysOfType = { + [K in keyof O]: O[K] extends T ? K : never; +}[keyof O]; + type Patch = Maybe | null; type HtmlInputType = diff --git a/cmd/serve/front/src/lib/localization/en.ts b/cmd/serve/front/src/lib/localization/en.ts new file mode 100644 index 00000000..4cbd6f7e --- /dev/null +++ b/cmd/serve/front/src/lib/localization/en.ts @@ -0,0 +1,14 @@ +import type { Locale, Translations } from '$lib/localization'; + +const translations = { + 'auth.signin.title': 'Sign in', + 'auth.signin.description': 'Please fill the form below to access your dashboard.', + 'shared.label.email': 'Email', + 'shared.label.password': 'Password' +} satisfies Translations; + +export default { + code: 'en', + displayName: 'English', + translations +} as const satisfies Locale; diff --git a/cmd/serve/front/src/lib/localization/fr.ts b/cmd/serve/front/src/lib/localization/fr.ts new file mode 100644 index 00000000..35cd169d --- /dev/null +++ b/cmd/serve/front/src/lib/localization/fr.ts @@ -0,0 +1,13 @@ +import type { AppTranslations, Locale } from '$lib/localization'; + +export default { + code: 'fr', + displayName: 'Français', + translations: { + 'auth.signin.title': 'Connexion', + 'auth.signin.description': + 'Remplissez le formulaire ci-dessous pour accéder au tableau de bord.', + 'shared.label.email': 'Email', + 'shared.label.password': 'Mot de passe' + } +} as const satisfies Locale; diff --git a/cmd/serve/front/src/lib/localization/index.ts b/cmd/serve/front/src/lib/localization/index.ts new file mode 100644 index 00000000..088b39ed --- /dev/null +++ b/cmd/serve/front/src/lib/localization/index.ts @@ -0,0 +1,181 @@ +import { browser } from '$app/environment'; +import en from '$lib/localization/en'; +import fr from '$lib/localization/fr'; + +/** + * Provides formatting methods for a specific locale. + */ +export interface FormatProvider { + format(format: 'date' | 'datetime', value: DateValue): string; + format(format: 'duration', start: DateValue, end: DateValue): string; +} + +export type TranslationsArgs = T extends (...args: infer P) => string ? P : never; +export type TranslationFunc = (this: FormatProvider, ...args: any[]) => string; +export type Translations = Record; + +/** + * Represents a single locale in the application. + */ +export type Locale = { + code: string; + displayName: string; + translations: T; +}; + +export type LocaleCode[]> = T[number]['code']; + +/** + * Localize resources accross the application. + */ +export interface LocalizationService[]> + extends FormatProvider { + /** Sets the current locale */ + locale(code: LocaleCode): void; + /** Gets the current locale */ + locale(): LocaleCode; + /** Gets all the locales supported by this localization service */ + locales(): TLocales; + /** Translate a given key */ + translate>(key: TKey): string; + /** Translate a given key with arguments */ + translate>( + key: TKey, + args: TranslationsArgs + ): string; +} + +export type LocalLocalizationOptions[]> = { + onLocaleChanged?(locale: LocaleCode, oldLocale: Maybe>): void; + default?: string; + fallback: LocaleCode; + locales: TLocales; +}; + +export class LocalLocalizationService[]> + implements LocalizationService +{ + private _currentLocaleCode?: LocaleCode; + private _currentTranslations!: T; + + private readonly _dateOptions: Intl.DateTimeFormatOptions = { + day: '2-digit', + month: '2-digit', + year: 'numeric' + }; + + private readonly _dateTimeOptions: Intl.DateTimeFormatOptions = { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }; + + public constructor(private readonly _options: LocalLocalizationOptions) { + this.locale(_options.default ?? _options.fallback); + } + + format(format: 'date' | 'datetime', value: DateValue): string; + format(format: 'duration', start: DateValue, end: DateValue): string; + format( + format: 'date' | 'datetime' | 'duration', + valueOrStart: DateValue, + end?: DateValue + ): string { + switch (format) { + case 'date': + return valueOrStart.toLocaleString(this._currentLocaleCode, this._dateOptions); + case 'datetime': + return valueOrStart.toLocaleString(this._currentLocaleCode, this._dateTimeOptions); + case 'duration': + const diffInSeconds = Math.max( + Math.floor((new Date(end!).getTime() - new Date(valueOrStart).getTime()) / 1000), + 0 + ); + + const numberOfMinutes = Math.floor(diffInSeconds / 60); + const numberOfSeconds = diffInSeconds - numberOfMinutes * 60; + + // FIXME: handle it better but since for now I only support french and english, this is not needed. + if (numberOfMinutes === 0) { + return `${numberOfSeconds}s`; + } + + return `${numberOfMinutes}m ${numberOfSeconds}s`; + default: + throw new Error(`Unsupported format: ${format}`); + } + } + + translate(key: TKey, args?: TranslationsArgs | undefined): string { + const v = this._currentTranslations[key]; + + if (typeof v === 'function') { + return v.apply(this, args ?? []); + } + + return v as string; + } + + locale(code: LocaleCode): void; + locale(): LocaleCode; + locale(code?: LocaleCode) { + if (!code) { + return this._currentLocaleCode; + } + + const targetLocale = + this._options.locales.find((l) => l.code === code) ?? + this._options.locales.find((l) => l.code === this._options.fallback)!; + + if (targetLocale.code === this._currentLocaleCode) { + return; + } + + const oldCode = this._currentLocaleCode; + + this._currentLocaleCode = targetLocale.code; + this._currentTranslations = targetLocale.translations; + this._options.onLocaleChanged?.(this._currentLocaleCode, oldCode); + } + + locales(): TLocales { + return this._options.locales; + } +} + +/** Type the application translations to provide strong typings. */ +export type AppTranslations = (typeof en)['translations']; + +const locales = [en, fr] as const satisfies Locale[]; + +export type AppLocales = typeof locales; +export type AppLocaleCodes = LocaleCode; +export type AppTranslationsString = KeysOfType; +export type AppTranslationsFunc = KeysOfType; + +const service: LocalizationService = new LocalLocalizationService({ + onLocaleChanged(value, old) { + if (!browser) { + return; + } + + localStorage.setItem('locale', value); + + // Old not defined, this is the localization initialization so no need to reload the page. + if (!old) { + return; + } + + // Reload the page to force the application to re-render with the new locale set. + // I don't want to make every translations reactive because it will bloat the application for nothing... + window.location.reload(); + }, + default: browser ? localStorage.getItem('locale') ?? undefined : undefined, + fallback: 'en', + locales +}); + +export default service; diff --git a/cmd/serve/front/src/routes/(auth)/signin/+page.svelte b/cmd/serve/front/src/routes/(auth)/signin/+page.svelte index 1d40c39c..cd8e29df 100644 --- a/cmd/serve/front/src/routes/(auth)/signin/+page.svelte +++ b/cmd/serve/front/src/routes/(auth)/signin/+page.svelte @@ -6,6 +6,7 @@ import Stack from '$components/stack.svelte'; import TextInput from '$components/text-input.svelte'; import auth from '$lib/auth'; + import l from '$lib/localization'; let email = ''; let password = ''; @@ -15,35 +16,35 @@ } - +