mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
feat(a11y,i18n): more modal focus-traps + Telegram theme/language sync
Modal a11y: - focus-trap + role=dialog/aria-modal/aria-labelledby on AdminRoles delete confirm and AdminBanSystem user-detail modal (+ close aria-label) Telegram theme/language sync (first run only, explicit choice always wins): - getTelegramColorScheme() / getTelegramLanguageCode() helpers in useTelegramSDK - useTheme initial state follows the Telegram client color scheme when no stored theme - applyTelegramLanguage() adopts the Telegram client language when no stored choice, called from main.tsx after SDK init (launch params unavailable before init)
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
expandViewport,
|
||||
retrieveLaunchParams,
|
||||
retrieveRawInitData,
|
||||
themeParamsState,
|
||||
} from '@telegram-apps/sdk-react';
|
||||
|
||||
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled';
|
||||
@@ -66,6 +67,46 @@ export function getTelegramInitData(): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function isDarkHexColor(hex: string): boolean {
|
||||
const m = hex.replace('#', '');
|
||||
const full = m.length === 3 ? m.replace(/(.)/g, '$1$1') : m;
|
||||
if (full.length !== 6) return false;
|
||||
const r = parseInt(full.slice(0, 2), 16);
|
||||
const g = parseInt(full.slice(2, 4), 16);
|
||||
const b = parseInt(full.slice(4, 6), 16);
|
||||
// Perceived sRGB luminance; below 0.5 reads as a dark surface.
|
||||
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Telegram client's effective color scheme ('light' | 'dark'), derived from
|
||||
* the theme background color. Returns null outside Telegram or before theme params load.
|
||||
*/
|
||||
export function getTelegramColorScheme(): 'light' | 'dark' | null {
|
||||
if (!detectTelegram()) return null;
|
||||
try {
|
||||
const bg = themeParamsState()?.bgColor;
|
||||
return bg ? (isDarkHexColor(bg) ? 'dark' : 'light') : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's Telegram client language as a 2-letter code (e.g. 'en'), or null
|
||||
* outside Telegram / when unavailable.
|
||||
*/
|
||||
export function getTelegramLanguageCode(): string | null {
|
||||
if (!detectTelegram()) return null;
|
||||
try {
|
||||
const user = retrieveLaunchParams().tgWebAppData?.user as { languageCode?: string } | undefined;
|
||||
const code = user?.languageCode;
|
||||
return code ? code.split('-')[0].toLowerCase() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type TelegramPlatform =
|
||||
| 'android'
|
||||
| 'ios'
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme';
|
||||
import { themeColorsApi } from '../api/themeColors';
|
||||
import { STORAGE_KEYS } from '../config/constants';
|
||||
import { getTelegramColorScheme } from './useTelegramSDK';
|
||||
|
||||
type Theme = 'dark' | 'light';
|
||||
|
||||
@@ -74,6 +75,13 @@ export function useTheme() {
|
||||
if (stored && !enabled[stored]) {
|
||||
return enabled.dark ? 'dark' : 'light';
|
||||
}
|
||||
// No stored preference: follow the Telegram client's color scheme in a Mini App.
|
||||
if (!stored) {
|
||||
const tgScheme = getTelegramColorScheme();
|
||||
if (tgScheme && enabled[tgScheme]) {
|
||||
return tgScheme;
|
||||
}
|
||||
}
|
||||
// Check system preference
|
||||
if (window.matchMedia('(prefers-color-scheme: light)').matches && enabled.light) {
|
||||
return 'light';
|
||||
|
||||
19
src/i18n.ts
19
src/i18n.ts
@@ -1,6 +1,7 @@
|
||||
import i18n, { type ResourceLanguage } from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
import { getTelegramLanguageCode } from './hooks/useTelegramSDK';
|
||||
|
||||
const localeLoaders: Record<string, () => Promise<{ default: ResourceLanguage }>> = {
|
||||
ru: () => import('./locales/ru.json'),
|
||||
@@ -11,6 +12,7 @@ const localeLoaders: Record<string, () => Promise<{ default: ResourceLanguage }>
|
||||
|
||||
const SUPPORTED_LANGS = Object.keys(localeLoaders);
|
||||
const FALLBACK_LNG = 'ru';
|
||||
const LANGUAGE_STORAGE_KEY = 'cabinet_language';
|
||||
|
||||
const loadedLanguages = new Set<string>();
|
||||
|
||||
@@ -61,4 +63,21 @@ i18n.on('languageChanged', (lng: string) => {
|
||||
loadLanguage(code);
|
||||
});
|
||||
|
||||
/**
|
||||
* On first run inside Telegram (no explicit stored choice), adopt the user's
|
||||
* Telegram client language. Must be called after the Telegram SDK is initialised
|
||||
* (e.g. from main.tsx), since launch params are unavailable before init().
|
||||
*/
|
||||
export function applyTelegramLanguage(): void {
|
||||
try {
|
||||
if (localStorage.getItem(LANGUAGE_STORAGE_KEY)) return; // explicit choice wins
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const code = getTelegramLanguageCode();
|
||||
if (code && SUPPORTED_LANGS.includes(code) && i18n.language?.split('-')[0] !== code) {
|
||||
i18n.changeLanguage(code);
|
||||
}
|
||||
}
|
||||
|
||||
export default i18n;
|
||||
|
||||
@@ -24,7 +24,7 @@ import { AppWithNavigator } from './AppWithNavigator';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import { initLogoPreload } from './api/branding';
|
||||
import { getCachedFullscreenEnabled, isTelegramMobile } from './hooks/useTelegramSDK';
|
||||
import './i18n';
|
||||
import { applyTelegramLanguage } from './i18n';
|
||||
import './styles/globals.css';
|
||||
|
||||
// Polyfill Object.hasOwn for older iOS/Android WebViews (Safari < 15.4, old Chrome).
|
||||
@@ -55,6 +55,9 @@ if (isTelegramEnv && !alreadyInitialized) {
|
||||
|
||||
clearStaleSessionIfNeeded(retrieveRawInitData() || null);
|
||||
|
||||
// Adopt the user's Telegram client language on first run (no explicit choice yet).
|
||||
applyTelegramLanguage();
|
||||
|
||||
// Each mount in its own try/catch so one failure doesn't block others.
|
||||
// mountMiniApp() internally mounts themeParams in SDK v3,
|
||||
// so we don't call mountThemeParams() separately to avoid ConcurrentCallError.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AdminBackButton } from '../components/admin/AdminBackButton';
|
||||
import { useFocusTrap } from '../hooks/useFocusTrap';
|
||||
import {
|
||||
banSystemApi,
|
||||
type BanSystemStatus,
|
||||
@@ -198,6 +199,9 @@ export default function AdminBanSystem() {
|
||||
const [stats, setStats] = useState<BanSystemStats | null>(null);
|
||||
const [users, setUsers] = useState<BanUsersListResponse | null>(null);
|
||||
const [selectedUser, setSelectedUser] = useState<BanUserDetailResponse | null>(null);
|
||||
const userDetailRef = useFocusTrap<HTMLDivElement>(selectedUser !== null, {
|
||||
onEscape: () => setSelectedUser(null),
|
||||
});
|
||||
const [punishments, setPunishments] = useState<BanPunishmentsListResponse | null>(null);
|
||||
const [nodes, setNodes] = useState<BanNodesListResponse | null>(null);
|
||||
const [agents, setAgents] = useState<BanAgentsListResponse | null>(null);
|
||||
@@ -1498,15 +1502,21 @@ export default function AdminBanSystem() {
|
||||
onClick={() => setSelectedUser(null)}
|
||||
>
|
||||
<div
|
||||
ref={userDetailRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="ban-user-detail-title"
|
||||
tabIndex={-1}
|
||||
className="max-h-[80vh] w-full max-w-2xl overflow-y-auto rounded-xl border border-dark-700 bg-dark-800"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-dark-700 p-4">
|
||||
<h3 className="text-lg font-semibold text-dark-100">
|
||||
<h3 id="ban-user-detail-title" className="text-lg font-semibold text-dark-100">
|
||||
{t('banSystem.userDetail.title')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setSelectedUser(null)}
|
||||
aria-label={t('common.close')}
|
||||
className="text-dark-400 hover:text-dark-200"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { rbacApi } from '@/api/rbac';
|
||||
import { PermissionGate } from '@/components/auth/PermissionGate';
|
||||
import { usePermissionStore } from '@/store/permissions';
|
||||
import { usePlatform } from '@/platform/hooks/usePlatform';
|
||||
import { useFocusTrap } from '@/hooks/useFocusTrap';
|
||||
|
||||
const BackIcon = () => (
|
||||
<svg
|
||||
@@ -64,6 +65,9 @@ export default function AdminRoles() {
|
||||
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const deleteDialogRef = useFocusTrap<HTMLDivElement>(deleteConfirm !== null, {
|
||||
onEscape: () => setDeleteConfirm(null),
|
||||
});
|
||||
|
||||
// Queries
|
||||
const {
|
||||
@@ -248,8 +252,15 @@ export default function AdminRoles() {
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="relative w-full max-w-sm rounded-xl border border-dark-700 bg-dark-800 p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold text-dark-100">
|
||||
<div
|
||||
ref={deleteDialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="role-delete-title"
|
||||
tabIndex={-1}
|
||||
className="relative w-full max-w-sm rounded-xl border border-dark-700 bg-dark-800 p-6"
|
||||
>
|
||||
<h3 id="role-delete-title" className="mb-2 text-lg font-semibold text-dark-100">
|
||||
{t('admin.roles.confirm.title')}
|
||||
</h3>
|
||||
<p className="mb-6 text-dark-400">{t('admin.roles.confirm.text')}</p>
|
||||
|
||||
Reference in New Issue
Block a user