Локализует приложение на 10 языков + RTL для арабского и персидского

Добавляет zh-CN, fa, ar, tr, uk, be, vi, uz локали (81 ключ, идентичная
структура) к существующим en/ru; ToggleLanguge.tsx переделан из
бинарного ru/en тумблера в выпадающий список всех 10 языков.

TelegramWeb.tsx был полностью на захардкоженном русском несмотря на импорт
useTranslation — переведён на i18n-ключи. rtlLanguages.ts выносит список
RTL-языков в отдельный .ts-файл (именованный импорт из .js падал на
allowJs). App.tsx реактивно переключает document.dir/lang при смене языка.

Логические CSS-свойства (ps-/pe-/start-/end-/border-e) вместо физических
в badge/button/dialog/select и BurgerMenu — Flexbox иначе визуально
переворачивает детей под dir=rtl, ломая анимацию бургер-меню.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-08 22:52:20 +07:00
parent 5f6e06589c
commit da77c8d755
22 changed files with 832 additions and 66 deletions
+13 -3
View File
@@ -1,5 +1,7 @@
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { RTL_LANGUAGES } from "@/lib/rtlLanguages";
interface BurgerMenuProps {
isOpen: boolean;
onClose: () => void;
@@ -7,7 +9,10 @@ interface BurgerMenuProps {
export function BurgerMenu({ isOpen, onClose }: BurgerMenuProps) {
if (!isOpen) return null;
const { t } = useTranslation();
const { t, i18n } = useTranslation();
// slide-in-from-left/right — буквальные keyframe-имена (не логические
// start/end), поэтому направление анимации приходится выбирать вручную.
const isRtl = RTL_LANGUAGES.includes((i18n.language || "").split("-")[0]);
return (
<>
{/* затемнение только под хедером */}
@@ -16,8 +21,13 @@ export function BurgerMenu({ isOpen, onClose }: BurgerMenuProps) {
onClick={onClose}
/>
{/* меню */}
<div className="fixed left-0 top-25 z-60 h-[calc(100vh-4rem)] w-64 bg-card border-r p-6 shadow-xl animate-in slide-in-from-left">
{/* меню — открывается от смыслового "начала" строки: слева в LTR, справа в RTL */}
<div
className={cn(
"fixed start-0 top-25 z-60 h-[calc(100vh-4rem)] w-64 bg-card border-e p-6 shadow-xl animate-in",
isRtl ? "slide-in-from-right" : "slide-in-from-left",
)}
>
<nav className="flex flex-col gap-6" onClick={onClose}>
<Link
to="/"
+4 -2
View File
@@ -2,6 +2,7 @@
import React, { useEffect, useState } from "react";
import { motion } from "framer-motion";
import { useTranslation } from "react-i18next";
interface SplashScreenProps {
onComplete: () => void;
@@ -9,6 +10,7 @@ interface SplashScreenProps {
export function SplashScreen({ onComplete }: SplashScreenProps) {
const [isFadingOut, setIsFadingOut] = useState(false);
const { t } = useTranslation();
useEffect(() => {
const timer = setTimeout(() => {
@@ -51,11 +53,11 @@ export function SplashScreen({ onComplete }: SplashScreenProps) {
{/* Контентная часть */}
<div className="flex flex-col gap-4">
<p className="text-[12px] font-mono text-white font-bold tracking-[0.5em] uppercase">
The System Watches Everyone
{t("splash_tagline_1")}
</p>
<p className="text-xl md:text-2xl font-mono font-black tracking-[0.8em] uppercase text-[#00E5F2] drop-shadow-[0_0_15px_#00E5F2] animate-pulse ml-[0.8em]">
Except You
{t("splash_tagline_2")}
</p>
</div>
</motion.div>
+3 -1
View File
@@ -1,9 +1,11 @@
import { Moon, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { useTheme } from "@/ThemeProvider";
export function ThemeToggle() {
const { isDark, toggleDark } = useTheme();
const { t } = useTranslation();
return (
<Button
@@ -21,7 +23,7 @@ export function ThemeToggle() {
) : (
<Moon className="h-[1.2rem] w-[1.2rem]" />
)}
<span className="sr-only">Переключить тему</span>
<span className="sr-only">{t("theme_toggle")}</span>
</Button>
);
}
+44 -17
View File
@@ -1,24 +1,51 @@
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
// Код -> нативное название языка (не переводим — так проще узнать свой язык
// в списке, даже если сейчас выставлен совсем другой).
const LANGUAGES: { code: string; label: string }[] = [
{ code: "en", label: "English" },
{ code: "ru", label: "Русский" },
{ code: "zh-CN", label: "中文" },
{ code: "fa", label: "فارسی" },
{ code: "ar", label: "العربية" },
{ code: "tr", label: "Türkçe" },
{ code: "uk", label: "Українська" },
{ code: "be", label: "Беларуская" },
{ code: "vi", label: "Tiếng Việt" },
{ code: "uz", label: "Oʻzbekcha" },
];
export function LanguageToggle() {
const { i18n } = useTranslation();
const toggleLanguage = () => {
const currentLang = i18n.language || "en";
const newLang = currentLang.startsWith("en") ? "ru" : "en";
i18n.changeLanguage(newLang);
};
const { i18n, t } = useTranslation();
const current =
LANGUAGES.find((l) => l.code === i18n.language)?.code ??
LANGUAGES.find((l) => i18n.language?.startsWith(l.code.split("-")[0]))
?.code ??
"en";
return (
<Button
variant="outline"
size="icon"
onClick={toggleLanguage}
className="rounded-full transition-all duration-300 hover:scale-105 font-bold text-xs"
>
{i18n.language?.substring(0, 2).toUpperCase() || "EN"}
<span className="sr-only">Toggle language</span>
</Button>
<Select value={current} onValueChange={(lang) => i18n.changeLanguage(lang)}>
<SelectTrigger
size="sm"
aria-label={t("language_toggle")}
className="rounded-full font-bold text-xs px-3"
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
{LANGUAGES.map(({ code, label }) => (
<SelectItem key={code} value={code}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}