feat: polish

This commit is contained in:
Anatoly Antonov 2026-04-22 01:27:38 +09:00
parent 2e6177fe74
commit 4bd927bb4e
137 changed files with 6357 additions and 137560 deletions

87
src/lib/i18n/index.ts Normal file
View file

@ -0,0 +1,87 @@
import { derived, writable, type Readable } from 'svelte/store';
import { browser } from '$app/environment';
/**
* Minimal i18n layer no external deps.
*
* Lookup is key-based (`t('panel.title')`) against flat or nested JSON
* dictionaries. Interpolation uses `{name}` placeholders. Missing keys fall
* back to the key itself so screens remain functional during translation.
*
* Adding a locale
* ---------------
* 1. Drop a JSON file into `src/lib/i18n/locales/<code>.json`.
* 2. Register it in `loaders` below.
* 3. Expose it in the `Locale` type.
*/
export type Locale = 'ru' | 'en';
export const DEFAULT_LOCALE: Locale = 'ru';
export const SUPPORTED_LOCALES: Locale[] = ['ru', 'en'];
const STORAGE_KEY = 'locale';
type Messages = Record<string, unknown>;
const loaders: Record<Locale, () => Promise<{ default: Messages }>> = {
ru: () => import('./locales/ru.json'),
en: () => import('./locales/en.json'),
};
const messages = writable<Record<Locale, Messages>>({} as Record<Locale, Messages>);
const locale = writable<Locale>(DEFAULT_LOCALE);
async function loadLocale(code: Locale): Promise<void> {
const mod = await loaders[code]();
messages.update((m) => ({ ...m, [code]: mod.default }));
}
export async function setLocale(code: Locale): Promise<void> {
if (!SUPPORTED_LOCALES.includes(code)) return;
await loadLocale(code);
locale.set(code);
if (browser) localStorage.setItem(STORAGE_KEY, code);
}
export async function initI18n(): Promise<void> {
const stored = browser ? (localStorage.getItem(STORAGE_KEY) as Locale | null) : null;
const next = stored && SUPPORTED_LOCALES.includes(stored) ? stored : DEFAULT_LOCALE;
await setLocale(next);
}
function lookup(dict: Messages, key: string): string | undefined {
const parts = key.split('.');
let node: unknown = dict;
for (const p of parts) {
if (node && typeof node === 'object' && p in (node as Messages)) {
node = (node as Messages)[p];
} else {
return undefined;
}
}
return typeof node === 'string' ? node : undefined;
}
function interpolate(template: string, values: Record<string, string | number>): string {
return template.replace(/\{(\w+)\}/g, (_, name) =>
name in values ? String(values[name]) : `{${name}}`,
);
}
export interface Translator {
(key: string, values?: Record<string, string | number>): string;
}
export const t: Readable<Translator> = derived(
[locale, messages],
([$locale, $messages]) => {
const dict = $messages[$locale];
return (key: string, values?: Record<string, string | number>) => {
const str = dict ? lookup(dict, key) : undefined;
if (str === undefined) return key;
return values ? interpolate(str, values) : str;
};
},
);
export const currentLocale: Readable<Locale> = { subscribe: locale.subscribe };