From 346024674917eede1b97014976f1b15447849e20 Mon Sep 17 00:00:00 2001 From: h Date: Mon, 19 Oct 2026 01:54:34 +0000 Subject: [PATCH] feat(frontend): russian and english interface with a language choice in settings, soon stubs removed --- .../src/lib/components/settings/About.svelte | 8 +- .../lib/components/settings/Appearance.svelte | 77 +++------------- .../lib/components/settings/Language.svelte | 16 ++++ .../lib/components/settings/Settings.svelte | 38 ++++---- .../components/settings/SettingsItem.svelte | 29 ++---- .../src/lib/components/ui/RadioList.svelte | 90 +++++++++++++++++++ frontend/src/lib/format/datetime.ts | 50 ++++++----- frontend/src/lib/i18n/locale.svelte.ts | 80 +++++++++++++++++ frontend/src/lib/i18n/messages.ts | 9 ++ frontend/src/lib/i18n/messages/common.ts | 9 ++ frontend/src/lib/i18n/messages/settings.ts | 25 ++++++ frontend/src/lib/i18n/types.ts | 9 ++ frontend/src/routes/+layout.svelte | 5 ++ 13 files changed, 313 insertions(+), 132 deletions(-) create mode 100644 frontend/src/lib/components/settings/Language.svelte create mode 100644 frontend/src/lib/components/ui/RadioList.svelte create mode 100644 frontend/src/lib/i18n/locale.svelte.ts create mode 100644 frontend/src/lib/i18n/messages.ts create mode 100644 frontend/src/lib/i18n/messages/common.ts create mode 100644 frontend/src/lib/i18n/messages/settings.ts create mode 100644 frontend/src/lib/i18n/types.ts diff --git a/frontend/src/lib/components/settings/About.svelte b/frontend/src/lib/components/settings/About.svelte index 85b9207..6823e9f 100644 --- a/frontend/src/lib/components/settings/About.svelte +++ b/frontend/src/lib/components/settings/About.svelte @@ -1,18 +1,20 @@
Beavergram
-
Версия {appVersion}
-
Лицензия GPL-3.0
+
{t("settings.about.version", { version: appVersion })}
+
{t("settings.about.license")}
- UI портирован из Telegram A + {t("settings.about.port")}
diff --git a/frontend/src/lib/components/settings/Appearance.svelte b/frontend/src/lib/components/settings/Appearance.svelte index e1b20d9..37c82f3 100644 --- a/frontend/src/lib/components/settings/Appearance.svelte +++ b/frontend/src/lib/components/settings/Appearance.svelte @@ -1,70 +1,17 @@ -
- {#each options as option (option.value)} - - {/each} -
- - + theme.set(value)} + {options} + selected={theme.preference} +/> diff --git a/frontend/src/lib/components/settings/Language.svelte b/frontend/src/lib/components/settings/Language.svelte new file mode 100644 index 0000000..5434729 --- /dev/null +++ b/frontend/src/lib/components/settings/Language.svelte @@ -0,0 +1,16 @@ + + + locale.set(value)} + {options} + selected={locale.preference} +/> diff --git a/frontend/src/lib/components/settings/Settings.svelte b/frontend/src/lib/components/settings/Settings.svelte index 66574b4..7dcd0ad 100644 --- a/frontend/src/lib/components/settings/Settings.svelte +++ b/frontend/src/lib/components/settings/Settings.svelte @@ -1,23 +1,19 @@
-

Настройки

+

{t("settings.title")}

-
Внешний вид
+
{t("settings.appearance")}
+
+
{t("settings.language")}
+ +
+
ui.openPanel("policy")} /> ui.openPanel("jobs")} /> ui.openPanel("storage")} /> ui.openPanel("stories-all")} /> ui.openPanel("shares")} /> ui.openPanel("bots")} /> ui.openPanel("watches")} /> ui.openPanel("alerts")} /> - {#each features as feature (feature.label)} - - {/each}
diff --git a/frontend/src/lib/components/settings/SettingsItem.svelte b/frontend/src/lib/components/settings/SettingsItem.svelte index 9abf06f..b431053 100644 --- a/frontend/src/lib/components/settings/SettingsItem.svelte +++ b/frontend/src/lib/components/settings/SettingsItem.svelte @@ -6,29 +6,19 @@ icon: string; label: string; onclick?: () => void; - soon?: boolean; value?: string; } - let { icon, label, value, soon = false, onclick }: Props = $props(); + let { icon, label, value, onclick }: Props = $props(); - @@ -53,13 +43,9 @@ background-color: transparent; transition: background-color 0.15s ease; - &:hover:not(:disabled) { + &:hover { background-color: var(--color-chat-hover); } - - &:disabled { - cursor: default; - } } :global(.settings-item .leading) { @@ -78,11 +64,6 @@ color: var(--color-text-secondary); } - .soon-tag { - font-size: 0.75rem; - color: var(--color-text-secondary); - } - :global(.settings-item .chevron) { flex-shrink: 0; color: var(--color-icon-secondary); diff --git a/frontend/src/lib/components/ui/RadioList.svelte b/frontend/src/lib/components/ui/RadioList.svelte new file mode 100644 index 0000000..3fd697c --- /dev/null +++ b/frontend/src/lib/components/ui/RadioList.svelte @@ -0,0 +1,90 @@ + + +
+ {#each options as option (option.value)} + + {/each} +
+ + diff --git a/frontend/src/lib/format/datetime.ts b/frontend/src/lib/format/datetime.ts index 3d59b5f..a5c1551 100644 --- a/frontend/src/lib/format/datetime.ts +++ b/frontend/src/lib/format/datetime.ts @@ -1,29 +1,39 @@ -const time = new Intl.DateTimeFormat(undefined, { - hour: "2-digit", - minute: "2-digit", -}); -const day = new Intl.DateTimeFormat(undefined, { - day: "numeric", - month: "short", -}); -const full = new Intl.DateTimeFormat(undefined, { - day: "numeric", - hour: "2-digit", - minute: "2-digit", - month: "short", - year: "numeric", -}); +import { locale } from "#lib/i18n/locale.svelte.js"; + +const OPTIONS = { + day: { day: "numeric", month: "short" }, + full: { + day: "numeric", + hour: "2-digit", + minute: "2-digit", + month: "short", + year: "numeric", + }, + time: { hour: "2-digit", minute: "2-digit" }, +} satisfies Record; + +const formatters = new Map(); + +function format(kind: keyof typeof OPTIONS, date: Date): string { + const key = `${locale.tag}:${kind}`; + let formatter = formatters.get(key); + if (!formatter) { + formatter = new Intl.DateTimeFormat(locale.tag, OPTIONS[kind]); + formatters.set(key, formatter); + } + return formatter.format(date); +} export function formatTime(iso: string): string { - return time.format(new Date(iso)); + return format("time", new Date(iso)); } export function formatDay(iso: string): string { - return day.format(new Date(iso)); + return format("day", new Date(iso)); } export function formatFull(iso: string): string { - return full.format(new Date(iso)); + return format("full", new Date(iso)); } export function formatListDate(iso: string | null): string { @@ -33,7 +43,7 @@ export function formatListDate(iso: string | null): string { const date = new Date(iso); const now = new Date(); if (date.toDateString() === now.toDateString()) { - return time.format(date); + return format("time", date); } - return day.format(date); + return format("day", date); } diff --git a/frontend/src/lib/i18n/locale.svelte.ts b/frontend/src/lib/i18n/locale.svelte.ts new file mode 100644 index 0000000..79fddbc --- /dev/null +++ b/frontend/src/lib/i18n/locale.svelte.ts @@ -0,0 +1,80 @@ +import { type MessageKey, messages } from "#lib/i18n/messages.js"; +import type { Locale, Text } from "#lib/i18n/types.js"; +import { browser } from "$app/env"; + +type Preference = Locale | "system"; +type Params = Record; + +const STORAGE_KEY = "bg.locale"; +const PARAM = /\{(\w+)\}/g; +const TAGS: Record = { en: "en-GB", ru: "ru-RU" }; +const plurals: Record = { + en: new Intl.PluralRules(TAGS.en), + ru: new Intl.PluralRules(TAGS.ru), +}; + +function readPreference(): Preference { + if (!browser) { + return "system"; + } + const stored = localStorage.getItem(STORAGE_KEY); + return stored === "en" || stored === "ru" ? stored : "system"; +} + +function systemLocale(): Locale { + if (!browser) { + return "ru"; + } + for (const language of navigator.languages) { + const base = language.toLowerCase().slice(0, 2); + if (base === "ru" || base === "en") { + return base; + } + } + return "en"; +} + +function createLocale() { + let preference = $state(readPreference()); + const current = $derived( + preference === "system" ? systemLocale() : preference + ); + + return { + get current() { + return current; + }, + get preference() { + return preference; + }, + set(value: Preference) { + preference = value; + if (browser) { + localStorage.setItem(STORAGE_KEY, value); + } + }, + get tag() { + return TAGS[current]; + }, + }; +} + +export const locale = createLocale(); + +function pick(text: Text, count: number | undefined): string { + if (typeof text === "string") { + return text; + } + return text[plurals[locale.current].select(count ?? 0)] ?? text.other; +} + +export function t(key: MessageKey, params?: Params): string { + const count = typeof params?.count === "number" ? params.count : undefined; + const text = pick(messages[key][locale.current], count); + if (!params) { + return text; + } + return text.replace(PARAM, (match, name: string) => + name in params ? String(params[name]) : match + ); +} diff --git a/frontend/src/lib/i18n/messages.ts b/frontend/src/lib/i18n/messages.ts new file mode 100644 index 0000000..ca85862 --- /dev/null +++ b/frontend/src/lib/i18n/messages.ts @@ -0,0 +1,9 @@ +import { common } from "#lib/i18n/messages/common.js"; +import { settings } from "#lib/i18n/messages/settings.js"; + +export const messages = { + ...common, + ...settings, +}; + +export type MessageKey = keyof typeof messages; diff --git a/frontend/src/lib/i18n/messages/common.ts b/frontend/src/lib/i18n/messages/common.ts new file mode 100644 index 0000000..5ea5c90 --- /dev/null +++ b/frontend/src/lib/i18n/messages/common.ts @@ -0,0 +1,9 @@ +import type { Messages } from "#lib/i18n/types.js"; + +export const common = { + "common.back": { en: "Back", ru: "Назад" }, + "common.cancel": { en: "Cancel", ru: "Отмена" }, + "common.close": { en: "Close", ru: "Закрыть" }, + "common.delete": { en: "Delete", ru: "Удалить" }, + "common.save": { en: "Save", ru: "Сохранить" }, +} satisfies Messages; diff --git a/frontend/src/lib/i18n/messages/settings.ts b/frontend/src/lib/i18n/messages/settings.ts new file mode 100644 index 0000000..d0d1f71 --- /dev/null +++ b/frontend/src/lib/i18n/messages/settings.ts @@ -0,0 +1,25 @@ +import type { Messages } from "#lib/i18n/types.js"; + +export const settings = { + "settings.about.license": { en: "GPL-3.0 license", ru: "Лицензия GPL-3.0" }, + "settings.about.port": { + en: "UI ported from Telegram A", + ru: "UI портирован из Telegram A", + }, + "settings.about.version": { en: "Version {version}", ru: "Версия {version}" }, + "settings.alerts": { en: "Alerts", ru: "Оповещения" }, + "settings.appearance": { en: "Appearance", ru: "Внешний вид" }, + "settings.bots": { en: "Bots", ru: "Боты" }, + "settings.jobs": { en: "Backfill and jobs", ru: "Бэкфилл и задачи" }, + "settings.language": { en: "Language", ru: "Язык" }, + "settings.language.system": { en: "System", ru: "Как в системе" }, + "settings.policy": { en: "Capture policy", ru: "Политика захвата" }, + "settings.shares": { en: "Files by link", ru: "Файлы по ссылке" }, + "settings.storage": { en: "Storage", ru: "Хранилище" }, + "settings.stories": { en: "Stories", ru: "Сторис" }, + "settings.theme.dark": { en: "Dark", ru: "Тёмная" }, + "settings.theme.light": { en: "Light", ru: "Светлая" }, + "settings.theme.system": { en: "System", ru: "Системная" }, + "settings.title": { en: "Settings", ru: "Настройки" }, + "settings.watches": { en: "Watches", ru: "Наблюдения" }, +} satisfies Messages; diff --git a/frontend/src/lib/i18n/types.ts b/frontend/src/lib/i18n/types.ts new file mode 100644 index 0000000..61fe1d7 --- /dev/null +++ b/frontend/src/lib/i18n/types.ts @@ -0,0 +1,9 @@ +export type Locale = "en" | "ru"; + +export type Plural = Partial> & { + other: string; +}; + +export type Text = string | Plural; + +export type Messages = Record>; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 68fe488..8a3ba87 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -4,6 +4,7 @@ import "#lib/styles/icons.css"; import "#lib/styles/global.scss"; import ToastHost from "#lib/components/ui/ToastHost.svelte"; + import { locale } from "#lib/i18n/locale.svelte.js"; import { auth } from "#lib/stores/auth.svelte.js"; import { theme } from "#lib/stores/theme.svelte.js"; import { goto } from "$app/navigation"; @@ -16,6 +17,10 @@ $effect(() => theme.watchSystem()); + $effect(() => { + document.documentElement.lang = locale.current; + }); + $effect(() => { const next = theme.resolved; const root = document.documentElement;