feat: frontend perf and a11y pass, plus mod visuals, hero main menu, Simple Voice Chat and vanilla chat autocomplete
Frontend: - replace the i18next stack with a small typed i18n module - hero clouds removed, llms.txt, HUD widget ARIA fixes and tests Mod: - TargetESP constellation mode; tracker and colors extracted - JumpCircles: 7 new shapes, shared animation and color pipeline - KillEffect blood mode with Burst/Fountain/Ring/Spray styles - main menu: menuLayout setting (orbs/hero) and hero screen - Simple Voice Chat soft integration: VoiceChat module and HUD panel - renderer/quick: static Draw facade with glass outline - vanilla chat: Baritone-style autocomplete popup for the % command prefix
This commit is contained in:
parent
167405aed0
commit
e00ea8eb11
204 changed files with 6469 additions and 833 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import type { TFunction } from 'i18next'
|
||||
import type { TFunction } from '@shared/i18n/react'
|
||||
import { ApiError } from './client'
|
||||
|
||||
const BY_STATUS = {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTranslation } from '@shared/i18n/react'
|
||||
import '@shared/motion/motion.css'
|
||||
import './block.css'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTranslation } from '@shared/i18n/react'
|
||||
import { useKonami } from './useKonami'
|
||||
|
||||
export function KonamiEffect() {
|
||||
|
|
|
|||
8
frontend/src/shared/i18n/I18nProvider.tsx
Normal file
8
frontend/src/shared/i18n/I18nProvider.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import type { ReactNode } from 'react'
|
||||
import { I18nContext } from './context'
|
||||
import type { I18n } from './core'
|
||||
|
||||
/** Scopes an instance to a subtree (tests render the same page in both languages side by side). */
|
||||
export function I18nProvider({ i18n, children }: { i18n: I18n; children: ReactNode }) {
|
||||
return <I18nContext value={i18n}>{children}</I18nContext>
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { useTranslation } from '@shared/i18n/react'
|
||||
|
||||
const LANGS = ['ru', 'en'] as const
|
||||
|
||||
|
|
|
|||
16
frontend/src/shared/i18n/context.ts
Normal file
16
frontend/src/shared/i18n/context.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { createContext } from 'react'
|
||||
import type { I18n } from './core'
|
||||
|
||||
export const I18nContext = createContext<I18n | null>(null)
|
||||
|
||||
let current: I18n | null = null
|
||||
|
||||
/** Registers the instance hooks fall back to when no provider is mounted (the app itself uses this). */
|
||||
export function setGlobalI18n(instance: I18n): void {
|
||||
current = instance
|
||||
}
|
||||
|
||||
export function getI18n(): I18n {
|
||||
if (!current) throw new Error('i18n is not initialised')
|
||||
return current
|
||||
}
|
||||
124
frontend/src/shared/i18n/core.ts
Normal file
124
frontend/src/shared/i18n/core.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
/**
|
||||
* Tiny i18n core (replaces i18next): namespaced dictionaries, `{{var}}` interpolation,
|
||||
* CLDR plural suffixes (`key_one` / `key_few` / `key_other`), fallback to English.
|
||||
* Typed keys are derived from the Russian dictionaries (English must match them).
|
||||
*/
|
||||
|
||||
import type { accountRu } from '@features/account/i18n/ru'
|
||||
import type { authRu } from '@features/auth/i18n/ru'
|
||||
import type { clickguiRu } from '@features/clickgui/i18n/ru'
|
||||
import type { configsRu } from '@features/configs/i18n/ru'
|
||||
import type { deviceLinkRu } from '@features/device-link/i18n/ru'
|
||||
import type { downloadRu } from '@features/download/i18n/ru'
|
||||
import type { landingRu } from '@features/landing/i18n/ru'
|
||||
import type { profileRu } from '@features/profile/i18n/ru'
|
||||
import type { showcaseRu } from '@features/showcase/i18n/ru'
|
||||
import type { themesRu } from '@features/themes/i18n/ru'
|
||||
import type { commonRu } from '@shared/i18n/common.ru'
|
||||
|
||||
export type Lang = 'ru' | 'en'
|
||||
|
||||
export interface Resources {
|
||||
common: typeof commonRu
|
||||
auth: typeof authRu
|
||||
landing: typeof landingRu
|
||||
themes: typeof themesRu
|
||||
clickgui: typeof clickguiRu
|
||||
download: typeof downloadRu
|
||||
deviceLink: typeof deviceLinkRu
|
||||
account: typeof accountRu
|
||||
configs: typeof configsRu
|
||||
showcase: typeof showcaseRu
|
||||
profile: typeof profileRu
|
||||
}
|
||||
|
||||
export type Namespace = keyof Resources & string
|
||||
|
||||
type PluralSuffix = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other'
|
||||
type Paths<T> = T extends string
|
||||
? never
|
||||
: { [K in keyof T & string]: T[K] extends string ? (K extends `${infer B}_${PluralSuffix}` ? B : K) : `${K}.${Paths<T[K]>}` }[keyof T & string]
|
||||
|
||||
export type KeyOf<N extends Namespace> = Paths<Resources[N]>
|
||||
type Qualified = { [N in Namespace]: `${N}:${KeyOf<N>}` }[Namespace]
|
||||
|
||||
export type TOptions = { count?: number } & Record<string, string | number | undefined>
|
||||
export type TFunction<N extends Namespace = 'common'> = (key: KeyOf<N> | Qualified, options?: TOptions) => string
|
||||
|
||||
type Dict = { [k: string]: string | Dict }
|
||||
type Bundles = Record<Lang, Record<string, Dict>>
|
||||
|
||||
const STORAGE_KEY = 'lv_lang'
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function lookup(dict: Dict | undefined, path: string): string | undefined {
|
||||
let node: string | Dict | undefined = dict
|
||||
for (const part of path.split('.')) {
|
||||
if (node === undefined || typeof node === 'string') return undefined
|
||||
node = node[part]
|
||||
}
|
||||
return typeof node === 'string' ? node : undefined
|
||||
}
|
||||
|
||||
/** Only an actual `ru` locale gets Russian; every other language falls back to English. */
|
||||
export function detectLanguage(): Lang {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved === 'ru' || saved === 'en') return saved
|
||||
} catch {
|
||||
// Storage blocked: fall through to the browser language.
|
||||
}
|
||||
return typeof navigator !== 'undefined' && navigator.language?.toLowerCase().startsWith('ru') ? 'ru' : 'en'
|
||||
}
|
||||
|
||||
export class I18n {
|
||||
language: Lang
|
||||
private readonly bundles: Bundles
|
||||
|
||||
constructor(bundles: Bundles, lng?: Lang) {
|
||||
this.bundles = bundles
|
||||
this.language = lng ?? detectLanguage()
|
||||
document.documentElement.lang = this.language
|
||||
}
|
||||
|
||||
private resolve(lng: Lang, ns: string, key: string, count?: number): string | undefined {
|
||||
const dict = this.bundles[lng][ns]
|
||||
if (count !== undefined) {
|
||||
const plural = lookup(dict, `${key}_${new Intl.PluralRules(lng).select(count)}`) ?? lookup(dict, `${key}_other`)
|
||||
if (plural !== undefined) return plural
|
||||
}
|
||||
return lookup(dict, key)
|
||||
}
|
||||
|
||||
getFixedT<N extends Namespace = 'common'>(lng: Lang, ns: N | readonly N[]): TFunction<N> {
|
||||
const first = (Array.isArray(ns) ? ns[0] : ns) as string
|
||||
return ((rawKey: string, options: TOptions = {}) => {
|
||||
const colon = rawKey.indexOf(':')
|
||||
const [space, key] = colon === -1 ? [first, rawKey] : [rawKey.slice(0, colon), rawKey.slice(colon + 1)]
|
||||
const text = this.resolve(lng, space, key, options.count) ?? this.resolve('en', space, key, options.count)
|
||||
if (text === undefined) return key
|
||||
return text.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, name: string) => String(options[name] ?? ''))
|
||||
}) as TFunction<N>
|
||||
}
|
||||
|
||||
t: TFunction<'common'> = (key, options) => this.getFixedT(this.language, 'common')(key, options)
|
||||
|
||||
changeLanguage(lng: Lang): Promise<void> {
|
||||
if (lng !== this.language) {
|
||||
this.language = lng
|
||||
document.documentElement.lang = lng
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, lng)
|
||||
} catch {
|
||||
// Private mode: the choice just lasts for this tab.
|
||||
}
|
||||
for (const notify of listeners) notify()
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
subscribe = (notify: () => void): (() => void) => {
|
||||
listeners.add(notify)
|
||||
return () => listeners.delete(notify)
|
||||
}
|
||||
}
|
||||
15
frontend/src/shared/i18n/react.ts
Normal file
15
frontend/src/shared/i18n/react.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { useContext, useMemo, useSyncExternalStore } from 'react'
|
||||
import { getI18n, I18nContext } from './context'
|
||||
import type { I18n, Namespace, TFunction } from './core'
|
||||
|
||||
export type { Lang, TFunction } from './core'
|
||||
export { getI18n, setGlobalI18n } from './context'
|
||||
|
||||
/** Returns `t` bound to the first namespace (others via `ns:key`) plus the instance; re-renders on language change. */
|
||||
export function useTranslation<N extends Namespace = 'common'>(ns: N | readonly N[] = 'common' as N): { t: TFunction<N>; i18n: I18n } {
|
||||
const i18n = useContext(I18nContext) ?? getI18n()
|
||||
const language = useSyncExternalStore(i18n.subscribe, () => i18n.language)
|
||||
const first = Array.isArray(ns) ? ns[0] : ns
|
||||
const t = useMemo(() => i18n.getFixedT(language, first as N), [i18n, language, first])
|
||||
return { t: t as TFunction<N>, i18n }
|
||||
}
|
||||
48
frontend/src/shared/i18n/tests/core.test.ts
Normal file
48
frontend/src/shared/i18n/tests/core.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { afterEach, expect, test, vi } from 'vitest'
|
||||
import { I18n } from '../core'
|
||||
|
||||
const bundles = {
|
||||
ru: { common: { hi: 'Привет, {{name}}', only_ru: 'только ру', n_one: '{{count}} шт', n_other: '{{count}} шт.' } },
|
||||
en: { common: { hi: 'Hi, {{name}}', n_one: '{{count}} item', n_other: '{{count}} items' } },
|
||||
} as never
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
test('interpolates variables and picks plural forms', () => {
|
||||
const i18n = new I18n(bundles, 'en')
|
||||
const t = i18n.getFixedT('en', 'common' as never) as (k: string, o?: object) => string
|
||||
expect(t('hi', { name: 'Loki' })).toBe('Hi, Loki')
|
||||
expect(t('n', { count: 1 })).toBe('1 item')
|
||||
expect(t('n', { count: 4 })).toBe('4 items')
|
||||
})
|
||||
|
||||
test('missing Russian keys fall back to English, unknown keys echo the key', () => {
|
||||
const i18n = new I18n(bundles, 'ru')
|
||||
const t = i18n.getFixedT('ru', 'common' as never) as (k: string) => string
|
||||
expect(t('only_ru')).toBe('только ру')
|
||||
expect(t('nope')).toBe('nope')
|
||||
})
|
||||
|
||||
test('the saved language wins over the browser, and only ru browsers default to Russian', () => {
|
||||
vi.stubGlobal('navigator', { language: 'ru-RU' })
|
||||
expect(new I18n(bundles).language).toBe('ru')
|
||||
localStorage.setItem('lv_lang', 'en')
|
||||
expect(new I18n(bundles).language).toBe('en')
|
||||
localStorage.clear()
|
||||
vi.stubGlobal('navigator', { language: 'de-DE' })
|
||||
expect(new I18n(bundles).language).toBe('en')
|
||||
})
|
||||
|
||||
test('changeLanguage persists the choice and notifies subscribers', async () => {
|
||||
const i18n = new I18n(bundles, 'en')
|
||||
const seen = vi.fn()
|
||||
const off = i18n.subscribe(seen)
|
||||
await i18n.changeLanguage('ru')
|
||||
expect(seen).toHaveBeenCalledOnce()
|
||||
expect(localStorage.getItem('lv_lang')).toBe('ru')
|
||||
expect(document.documentElement.lang).toBe('ru')
|
||||
off()
|
||||
})
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { I18nextProvider, useTranslation } from 'react-i18next'
|
||||
import { I18nProvider } from '@shared/i18n/I18nProvider'
|
||||
import { useTranslation } from '@shared/i18n/react'
|
||||
import { expect, test } from 'vitest'
|
||||
import { initI18n } from '@app/i18n'
|
||||
import { LanguageSwitch } from '../LanguageSwitch'
|
||||
|
|
@ -14,10 +15,10 @@ test('switching language re-renders strings and sets <html lang>', async () => {
|
|||
const user = userEvent.setup()
|
||||
const i18n = initI18n('ru')
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<I18nProvider i18n={i18n}>
|
||||
<Probe />
|
||||
<LanguageSwitch />
|
||||
</I18nextProvider>,
|
||||
</I18nProvider>,
|
||||
)
|
||||
expect(screen.getByText('Войти')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'EN' }))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { useTranslation } from '@shared/i18n/react'
|
||||
import { Outlet } from 'react-router'
|
||||
import { KonamiEffect } from '../easter/KonamiEffect'
|
||||
import { Footer } from './Footer'
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { useTranslation } from '@shared/i18n/react'
|
||||
import { Link } from 'react-router'
|
||||
import { GITHUB_REPO } from '@features/download/api'
|
||||
import { LanguageSwitch } from '../i18n/LanguageSwitch'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { getI18n } from '@shared/i18n/react'
|
||||
import { afterEach, expect, test } from 'vitest'
|
||||
import { routes } from '@app/routes'
|
||||
import { renderApp } from '@test/render'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { type RefObject, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTranslation } from '@shared/i18n/react'
|
||||
import { NavLink } from 'react-router'
|
||||
import { usePresence } from '@shared/motion/usePresence'
|
||||
import { LanguageSwitch } from '../../i18n/LanguageSwitch'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useSharedThumb } from '@shared/motion/useSharedThumb'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTranslation } from '@shared/i18n/react'
|
||||
import { NavLink } from 'react-router'
|
||||
|
||||
export type NavItem = { to: string; label: string }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTranslation } from '@shared/i18n/react'
|
||||
import { Link, useLocation } from 'react-router'
|
||||
import { LanguageSwitch } from '../../i18n/LanguageSwitch'
|
||||
import { MobileMenu } from './MobileMenu'
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { useTranslation } from '@shared/i18n/react'
|
||||
import { Link } from 'react-router'
|
||||
import { useSession } from '@features/auth/session'
|
||||
import type { Me } from '../../types'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTranslation } from '@shared/i18n/react'
|
||||
import { Button } from '../Button'
|
||||
|
||||
export function ShareCode({ code, label }: { code: string; label?: string }) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue