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:
loki5512344 2026-10-01 16:22:05 +02:00
parent 167405aed0
commit e00ea8eb11
Signed by: boba
GPG key ID: 253067914055423B
204 changed files with 6469 additions and 833 deletions

View file

@ -10,11 +10,8 @@
"@fontsource-variable/unbounded": "^5.3.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.103.2",
"i18next": "^26.4.2",
"i18next-browser-languagedetector": "^8.2.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.15",
"react-router": "^8.4.0",
"tailwindcss": "^4.3.3",
},
@ -306,12 +303,6 @@
"html-encoding-sniffer": ["html-encoding-sniffer@7.0.0", "", { "dependencies": { "@exodus/bytes": "^1.15.1" } }, "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w=="],
"html-parse-stringify": ["html-parse-stringify@4.0.1", "", {}, "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw=="],
"i18next": ["i18next@26.4.2", "", { "peerDependencies": { "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["typescript"] }, "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug=="],
"i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="],
"indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
@ -378,8 +369,6 @@
"react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
"react-i18next": ["react-i18next@17.0.15", "", { "dependencies": { "@babel/runtime": "^7.29.7", "html-parse-stringify": "^4.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "react-dom": "*", "react-native": "*", "typescript": "^5 || ^6 || ^7" }, "optionalPeers": ["react-dom", "react-native", "typescript"] }, "sha512-7uYpx/oBkesLto7cOzOmfITlUBaXHCAcFahmgETMfWr8nbbWRRnVGvz8wsH/xpbNcyoH+NpCcnOQEqWPMO6/hw=="],
"react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"react-router": ["react-router@8.4.0", "", { "dependencies": { "@remix-run/route-pattern": "^0.22.1", "cookie-es": "^3.1.1" }, "peerDependencies": { "react": ">=19.2.7", "react-dom": ">=19.2.7" }, "optionalPeers": ["react-dom"] }, "sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g=="],
@ -428,8 +417,6 @@
"undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="],
"vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="],
"vitest": ["vitest@5.0.1", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.1", "chai": "^6.2.2", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.2.3", "obug": "^2.1.4", "picomatch": "^4.0.7", "std-env": "^4.2.0", "tinybench": "6.1.4", "tinyexec": "1.3.0", "tinyglobby": "^0.2.17", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.1", "@vitest/browser-preview": "5.0.1", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.1", "@vitest/coverage-v8": "5.0.1", "@vitest/ui": "5.0.1", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg=="],

View file

@ -18,11 +18,8 @@
"@fontsource-variable/unbounded": "^5.3.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.103.2",
"i18next": "^26.4.2",
"i18next-browser-languagedetector": "^8.2.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.15",
"react-router": "^8.4.0",
"tailwindcss": "^4.3.3"
},

15
frontend/public/llms.txt Normal file
View file

@ -0,0 +1,15 @@
# LoVisual
> LoVisual is a free Fabric mod for Minecraft with 80+ visual modules: wings, particles, a custom HUD, weather and 18 built-in themes. It is a visual mod, not a cheat client. This site hosts the download, a browser theme editor, a showcase of community configs and account tools. The site is available in Russian and English.
## Main pages
- [Home](https://visual.loki-code.dev/): what the mod does, live ClickGui and HUD demos
- [Download](https://visual.loki-code.dev/download): the mod jar and release notes
- [Themes](https://visual.loki-code.dev/themes): edit and preview ClickGui themes in the browser
- [Showcase](https://visual.loki-code.dev/showcase): configs and themes published by players
## Notes for crawlers
- [Sitemap](https://visual.loki-code.dev/sitemap.xml): list of public pages
- Account pages (`/account`, `/configs`, `/link`) require a login and contain nothing public.

View file

@ -1,6 +1,5 @@
import i18next, { type i18n } from 'i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import { initReactI18next } from 'react-i18next'
import { I18n, type Lang } from '@shared/i18n/core'
import { setGlobalI18n } from '@shared/i18n/react'
import { accountEn } from '@features/account/i18n/en'
import { accountRu } from '@features/account/i18n/ru'
import { authEn } from '@features/auth/i18n/en'
@ -24,56 +23,39 @@ import { themesRu } from '@features/themes/i18n/ru'
import { commonEn } from '@shared/i18n/common.en'
import { commonRu } from '@shared/i18n/common.ru'
/** Creates and initializes an i18next instance. Each call is independent, so tests can run in isolation. */
export function initI18n(lng?: 'ru' | 'en'): i18n {
const instance = i18next.createInstance()
instance
.use(LanguageDetector)
.use(initReactI18next)
.init({
lng,
resources: {
ru: {
common: commonRu,
auth: authRu,
landing: landingRu,
themes: themesRu,
clickgui: clickguiRu,
download: downloadRu,
deviceLink: deviceLinkRu,
account: accountRu,
configs: configsRu,
showcase: showcaseRu,
profile: profileRu,
},
en: {
common: commonEn,
auth: authEn,
landing: landingEn,
themes: themesEn,
clickgui: clickguiEn,
download: downloadEn,
deviceLink: deviceLinkEn,
account: accountEn,
configs: configsEn,
showcase: showcaseEn,
profile: profileEn,
},
/** Creates an i18n instance and makes it the global one. Each call is independent, so tests can run in isolation. */
export function initI18n(lng?: Lang): I18n {
const instance = new I18n(
{
ru: {
common: commonRu,
auth: authRu,
landing: landingRu,
themes: themesRu,
clickgui: clickguiRu,
download: downloadRu,
deviceLink: deviceLinkRu,
account: accountRu,
configs: configsRu,
showcase: showcaseRu,
profile: profileRu,
},
// Browser language decides the default: only an actual `ru` locale gets Russian,
// every other system language falls back to English.
fallbackLng: 'en',
supportedLngs: ['ru', 'en'],
ns: ['common', 'auth', 'landing', 'themes', 'clickgui', 'download', 'deviceLink', 'account', 'configs', 'showcase', 'profile'],
defaultNS: 'common',
interpolation: { escapeValue: false },
detection: { order: ['localStorage', 'navigator'], caches: ['localStorage'], lookupLocalStorage: 'lv_lang' },
})
document.documentElement.lang = instance.language
instance.on('languageChanged', (next) => {
document.documentElement.lang = next
})
en: {
common: commonEn,
auth: authEn,
landing: landingEn,
themes: themesEn,
clickgui: clickguiEn,
download: downloadEn,
deviceLink: deviceLinkEn,
account: accountEn,
configs: configsEn,
showcase: showcaseEn,
profile: profileEn,
},
},
lng,
)
setGlobalI18n(instance)
return instance
}

View file

@ -1,31 +0,0 @@
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'
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'common'
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
// Later tasks extend this with their own feature namespaces.
}
}
}

View file

@ -1,5 +1,5 @@
import { api } from '@shared/api/client'
import type { TFunction } from 'i18next'
import type { TFunction } from '@shared/i18n/react'
export type DeviceLink = { id: string; linked_at: string; last_seen: string | null }

View file

@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Link } from 'react-router'
import { Button } from '@shared/ui/Button'
import { listDevices, revokeDevice, type DeviceLink } from '../api'

View file

@ -1,5 +1,5 @@
import { useCallback, useRef, useState, type DragEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { useNavigate } from 'react-router'
import { Button } from '@shared/ui/Button'
import { useSession } from '@features/auth/session'

View file

@ -1,5 +1,5 @@
import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Button } from '@shared/ui/Button'
import { clampPan, clampZoom, coverScale, MAX_ZOOM, MIN_ZOOM, sourceRect } from './cropMath'

View file

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { listAvatarHistory, type AvatarHistoryEntry } from '@features/account/api'
type Props = {

View file

@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useId, useState, type ChangeEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Notice } from '@shared/ui/Notice'
import { useSession } from '@features/auth/session'
import {

View file

@ -1,6 +1,6 @@
import { 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 { expect, test } from 'vitest'
import { checkAvatarFile } from '../api'
import { DeviceList } from '@features/account/components/DeviceList'

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Navigate, Outlet, useLocation } from 'react-router'
import { useSession } from './session'

View file

@ -1,5 +1,5 @@
import { useState, type FormEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { useLocation, useNavigate } from 'react-router'
import { describeError } from '@shared/api/errors'
import { Button } from '@shared/ui/Button'

View file

@ -1,5 +1,5 @@
import { useState, type FormEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { useLocation, useNavigate } from 'react-router'
import { describeError } from '@shared/api/errors'
import { Button } from '@shared/ui/Button'

View file

@ -1,4 +1,4 @@
import type { TFunction } from 'i18next'
import type { TFunction } from '@shared/i18n/react'
export function validateEmail(email: string, t: TFunction<'auth'>): string | undefined {
const v = email.trim()

View file

@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { prefersReducedMotion } from '@shared/motion/reducedMotion'
import type { ThemeEntry } from '../themes/types'
import { useActiveTheme } from '../themes/useActiveTheme'

View file

@ -1,5 +1,5 @@
import type { Dispatch } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import type { GuiAction, GuiState } from '../demo'
import { Icon } from '../parts/Glyphs'
import { type Category, CATEGORIES } from '../parts/modules'
@ -22,7 +22,7 @@ function Panel({ category, state, dispatch }: { category: Category } & Props) {
const { t } = useTranslation('clickgui')
const titleId = `lv-gui-panel-${category.id}`
return (
<section role="listitem" className="lv-gui-panel" data-category={category.id} aria-labelledby={titleId}>
<div role="listitem" className="lv-gui-panel" data-category={category.id} aria-labelledby={titleId}>
<header className="lv-gui-panel-head">
<h2 id={titleId} className="lv-gui-panel-title">
{category.name}
@ -51,6 +51,6 @@ function Panel({ category, state, dispatch }: { category: Category } & Props) {
)
})}
</ul>
</section>
</div>
)
}

View file

@ -1,5 +1,5 @@
import type { CSSProperties } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { PRESETS } from '@features/themes/presets.generated'
import { argbToCss, type GradientSpec, type ThemeColors, type ThemeEntry } from '@features/themes/types'
import { Icon } from '../parts/Glyphs'

View file

@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Button } from '@shared/ui/Button'
import { TextField } from '@shared/ui/text-field/TextField'
import { publish } from '../api'

View file

@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Button } from '@shared/ui/Button'
import { Notice } from '@shared/ui/Notice'
import { ShareCode } from '@shared/ui/share-code/ShareCode'

View file

@ -1,6 +1,6 @@
import { useMutation } from '@tanstack/react-query'
import { useState, type FormEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { describeError } from '@shared/api/errors'
import { Button } from '@shared/ui/Button'
import { Notice } from '@shared/ui/Notice'

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { DOWNLOAD_URL, formatSize } from './api'
import '../landing/styles/landing.css'

View file

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Markdown } from '@shared/ui/Markdown'
import { fetchReleases } from './api'

View file

@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { prefersReducedMotion } from '@shared/motion/reducedMotion'
import { ButtonLink } from '@shared/ui/Button'
import { useSession } from '../auth/session'

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Link } from 'react-router'
import '@features/landing/styles/sections.css'

View file

@ -119,15 +119,6 @@ function drawSky(ctx: CanvasRenderingContext2D, w: number, h: number) {
ctx.fillRect(sx - sun / 2, sy - sun / 2, sun, sun)
ctx.fillStyle = '#ffe7b8'
ctx.fillRect(sx - sun / 4, sy - sun / 4, sun / 2, sun / 2)
// Blocky clouds.
for (let c = 0; c < 7; c++) {
const cx = rnd() * w
const cy = h * (0.12 + rnd() * 0.3)
const u = 14
ctx.fillStyle = `rgba(${200 + rnd() * 40}, ${120 + rnd() * 40}, ${170 + rnd() * 40}, 0.2)`
const len = 4 + Math.floor(rnd() * 7)
for (let i = 0; i < len; i++) ctx.fillRect(cx + i * u, cy + (i % 3 === 0 ? -u : 0), u, u * (i % 3 === 0 ? 2 : 1))
}
}
/**

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Link } from 'react-router'
import { GITHUB_RELEASES_URL } from '@features/download/api'
import '@features/landing/styles/landing.css'

View file

@ -1,5 +1,5 @@
import { useLayoutEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Button } from '@shared/ui/Button'
import { themeToCssVars } from '@features/clickgui/themeVars'
import { parseArgb } from '@features/themes/types'
@ -97,7 +97,7 @@ export function HudPlayground() {
ref={(el) => {
widgetRefs.current[w.id] = el
}}
role="button"
role="group"
tabIndex={0}
title={w.label}
aria-label={t('hud.dragAria', { name: w.label })}

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { CATEGORIES } from '@features/clickgui/parts/modules'
import '@features/landing/styles/landing.css'

View file

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Link } from 'react-router'
import { browse } from '@features/showcase/api'
import '@features/landing/styles/sections.css'

View file

@ -1,5 +1,5 @@
import { lazy, Suspense, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Link } from 'react-router'
import { ClickGuiWindow } from '@features/clickgui/ClickGuiWindow'
import { PRESETS } from '@features/themes/presets.generated'

View file

@ -1,5 +1,5 @@
import { type MouseEvent, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { ButtonLink } from '@shared/ui/Button'
import { prefersReducedMotion } from '@shared/motion/reducedMotion'
import { RollText } from '@shared/motion/RollText'

View file

@ -43,7 +43,7 @@ test('picking a swatch repaints the theme section: pressed state and the big the
test('holding a HUD widget switches the screen into edit mode until it is released', () => {
reduceMotion()
withTheme(<HudPlayground />)
const widget = screen.getByRole('button', { name: /FPS/ })
const widget = screen.getByRole('group', { name: /FPS/ })
widget.setPointerCapture = () => {}
const grid = document.querySelector('.lv-hud-grid')
expect(grid).toHaveAttribute('data-on', 'false')
@ -60,11 +60,11 @@ test('holding a HUD widget switches the screen into edit mode until it is releas
test('"Put it back" restores the HUD after moving a widget', () => {
reduceMotion()
withTheme(<HudPlayground />)
const start = screen.getByRole('button', { name: /FPS/ }).style.left
fireEvent.keyDown(screen.getByRole('button', { name: /FPS/ }), { key: 'ArrowRight' })
expect(screen.getByRole('button', { name: /FPS/ }).style.left).not.toBe(start)
const start = screen.getByRole('group', { name: /FPS/ }).style.left
fireEvent.keyDown(screen.getByRole('group', { name: /FPS/ }), { key: 'ArrowRight' })
expect(screen.getByRole('group', { name: /FPS/ }).style.left).not.toBe(start)
fireEvent.click(screen.getByRole('button', { name: 'Вернуть как было' }))
expect(screen.getByRole('button', { name: /FPS/ }).style.left).toBe(start)
expect(screen.getByRole('group', { name: /FPS/ }).style.left).toBe(start)
})
test('module wall shows how many modules each category has; Visuals has the 52 the headline promises', () => {

View file

@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'
import { expect, test, vi } from 'vitest'
import { I18nextProvider } from 'react-i18next'
import { I18nProvider } from '@shared/i18n/I18nProvider'
import { CommandHero } from '../CommandHero'
import { Hero } from '../sections/hero/Hero'
import { createMemoryRouter, RouterProvider } from 'react-router'
@ -29,13 +29,13 @@ test('english locale renders the landing hero headline', async () => {
const i18n = initI18n('en')
const router = createMemoryRouter([{ path: '/', element: <Hero /> }])
render(
<I18nextProvider i18n={i18n}>
<I18nProvider i18n={i18n}>
<QueryClientProvider client={new QueryClient()}>
<SessionProvider>
<RouterProvider router={router} />
</SessionProvider>
</QueryClientProvider>
</I18nextProvider>,
</I18nProvider>,
)
expect(await screen.findByRole('heading', { level: 1 })).toHaveTextContent("you'll fall in love with")
})
@ -49,11 +49,11 @@ test('cloud section speaks in player terms in both languages', async () => {
] as const) {
const router = createMemoryRouter([{ path: '/', element: <CommandHero /> }])
const { unmount } = render(
<I18nextProvider i18n={initI18n(lng)}>
<I18nProvider i18n={initI18n(lng)}>
<QueryClientProvider client={new QueryClient()}>
<SessionProvider><RouterProvider router={router} /></SessionProvider>
</QueryClientProvider>
</I18nextProvider>,
</I18nProvider>,
)
expect(await screen.findByRole('heading', { level: 2 })).toHaveTextContent(title)
unmount()
@ -66,11 +66,11 @@ test('hero download CTA goes to the page with both download buttons', async () =
const router = createMemoryRouter([{ path: '/', element: <Hero /> }])
// Explicit ru: an earlier test in this file re-inits the default instance in English.
render(
<I18nextProvider i18n={initI18n('ru')}>
<I18nProvider i18n={initI18n('ru')}>
<QueryClientProvider client={new QueryClient()}>
<SessionProvider><RouterProvider router={router} /></SessionProvider>
</QueryClientProvider>
</I18nextProvider>,
</I18nProvider>,
)
const cta = await screen.findByRole('link', { name: 'Скачать бесплатно' })
expect(cta).toHaveAttribute('href', '/download')

View file

@ -26,17 +26,17 @@ afterEach(() => vi.unstubAllGlobals())
test('HudWidget moves by 8px with ArrowRight', () => {
reduceMotion()
withQuery(<HudPlayground />)
const widget = screen.getByRole('button', { name: /FPS/ })
const widget = screen.getByRole('group', { name: /FPS/ })
const before = Number.parseInt(widget.style.left, 10)
fireEvent.keyDown(widget, { key: 'ArrowRight' })
expect(Number.parseInt(screen.getByRole('button', { name: /FPS/ }).style.left, 10)).toBe(before + 8)
expect(Number.parseInt(screen.getByRole('group', { name: /FPS/ }).style.left, 10)).toBe(before + 8)
})
test('HudWidget stays inside the playfield bounds', () => {
reduceMotion()
withQuery(<HudPlayground />)
for (let i = 0; i < 200; i++) fireEvent.keyDown(screen.getByRole('button', { name: /FPS/ }), { key: 'ArrowRight' })
const left = Number.parseInt(screen.getByRole('button', { name: /FPS/ }).style.left, 10)
for (let i = 0; i < 200; i++) fireEvent.keyDown(screen.getByRole('group', { name: /FPS/ }), { key: 'ArrowRight' })
const left = Number.parseInt(screen.getByRole('group', { name: /FPS/ }).style.left, 10)
expect(left).toBeGreaterThanOrEqual(0)
expect(left).toBeLessThanOrEqual(680)
})

View file

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { ApiError } from '@shared/api/client'
import { Notice } from '@shared/ui/Notice'
import { ListingList } from '@features/showcase/components/ListingList'

View file

@ -1,6 +1,6 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Link, useLocation } from 'react-router'
import { Notice } from '@shared/ui/Notice'
import { ApiError } from '@shared/api/client'

View file

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Link } from 'react-router'
import { Notice } from '@shared/ui/Notice'
import { getListing } from '../api'

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Link } from 'react-router'
import { copiesText } from '../copies'
import type { Listing } from '../types'

View file

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { useSearchParams } from 'react-router'
import { Button } from '@shared/ui/Button'
import { Notice } from '@shared/ui/Notice'

View file

@ -1,4 +1,4 @@
import type { TFunction } from 'i18next'
import type { TFunction } from '@shared/i18n/react'
const RU_PLURAL = new Intl.PluralRules('ru')

View file

@ -1,5 +1,5 @@
import { type ReactNode, startTransition, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { themeToCssVars } from '@features/clickgui/themeVars'
import { PRESETS } from '../presets.generated'
import type { ThemeEntry } from '../types'

View file

@ -1,5 +1,5 @@
import { type CSSProperties, memo, useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { argbToCss, parseArgb } from '../../types'
import './fields.css'

View file

@ -1,5 +1,5 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { cssAngle } from '@features/clickgui/themeVars'
import { argbToCss, type GradientSpec } from '../../types'
import { ColorField } from './ColorField'

View file

@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { parseArgb, type ThemeEntry } from '../../types'
import { fromProfileJson, toProfileJson, toShareHash } from '../model/codec'

View file

@ -1,5 +1,5 @@
import { type KeyboardEvent, useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import type { GradientSpec, ThemeColors, ThemeEntry } from '../../types'
import { ColorField } from '../fields/ColorField'
import { GradientField } from '../fields/GradientField'

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import type { ThemeEntry } from '../../types'
type History = { undo: () => void; redo: () => void; reset: () => void; resetName: string; canUndo: boolean; canRedo: boolean }

View file

@ -1,5 +1,5 @@
import type { CSSProperties } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { themeToCssVars } from '@features/clickgui/themeVars'
import { PRESETS } from '../../presets.generated'

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { ParticleField } from '@features/landing/effects/ParticleField'
import { VoxelScene } from '@features/landing/effects/VoxelScene'
import { SlotGrid } from '@features/configs/components/SlotGrid'

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { LinkDeviceForm } from '@features/device-link/LinkDeviceForm'
import { PageTitle } from '@shared/layout/PageTitle'

View file

@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { DownloadButton } from '@features/download/DownloadButton'
import { ReleaseNotes } from '@features/download/ReleaseNotes'
import { GITHUB_RELEASES_URL, fetchReleases, releaseSize } from '@features/download/api'

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Link } from 'react-router'
import { BreakableBlock } from '@shared/easter/BreakableBlock'
import { PageTitle } from '@shared/layout/PageTitle'

View file

@ -1,5 +1,5 @@
import { type ReactNode, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { Link, useLocation } from 'react-router'
import { Backdrop } from '@features/clickgui/parts/Backdrop'
import '@features/clickgui/parts/clickgui.css'

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { LoginForm } from '@features/auth/forms/LoginForm'
import { AuthScreen } from './AuthScreen'

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { RegisterForm } from '@features/auth/forms/RegisterForm'
import { AuthScreen } from './AuthScreen'

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { ParticleField } from '@features/landing/effects/ParticleField'
import { VoxelScene } from '@features/landing/effects/VoxelScene'
import { ListingList } from '@features/showcase/components/ListingList'

View file

@ -1,5 +1,5 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
import { ThemeEditor } from '@features/themes/editor/ThemeEditor'
import { fromShareHash } from '@features/themes/editor/model/codec'
import { PRESETS } from '@features/themes/presets.generated'

View file

@ -1,4 +1,4 @@
import type { TFunction } from 'i18next'
import type { TFunction } from '@shared/i18n/react'
import { ApiError } from './client'
const BY_STATUS = {

View file

@ -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'

View file

@ -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() {

View 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>
}

View file

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@shared/i18n/react'
const LANGS = ['ru', 'en'] as const

View 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
}

View 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)
}
}

View 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 }
}

View 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()
})

View file

@ -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' }))

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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 }

View file

@ -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'

View file

@ -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'

View file

@ -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 }) {