mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +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,
|
expandViewport,
|
||||||
retrieveLaunchParams,
|
retrieveLaunchParams,
|
||||||
retrieveRawInitData,
|
retrieveRawInitData,
|
||||||
|
themeParamsState,
|
||||||
} from '@telegram-apps/sdk-react';
|
} from '@telegram-apps/sdk-react';
|
||||||
|
|
||||||
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled';
|
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 =
|
export type TelegramPlatform =
|
||||||
| 'android'
|
| 'android'
|
||||||
| 'ios'
|
| 'ios'
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
|||||||
import { EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme';
|
import { EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme';
|
||||||
import { themeColorsApi } from '../api/themeColors';
|
import { themeColorsApi } from '../api/themeColors';
|
||||||
import { STORAGE_KEYS } from '../config/constants';
|
import { STORAGE_KEYS } from '../config/constants';
|
||||||
|
import { getTelegramColorScheme } from './useTelegramSDK';
|
||||||
|
|
||||||
type Theme = 'dark' | 'light';
|
type Theme = 'dark' | 'light';
|
||||||
|
|
||||||
@@ -74,6 +75,13 @@ export function useTheme() {
|
|||||||
if (stored && !enabled[stored]) {
|
if (stored && !enabled[stored]) {
|
||||||
return enabled.dark ? 'dark' : 'light';
|
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
|
// Check system preference
|
||||||
if (window.matchMedia('(prefers-color-scheme: light)').matches && enabled.light) {
|
if (window.matchMedia('(prefers-color-scheme: light)').matches && enabled.light) {
|
||||||
return 'light';
|
return 'light';
|
||||||
|
|||||||
19
src/i18n.ts
19
src/i18n.ts
@@ -1,6 +1,7 @@
|
|||||||
import i18n, { type ResourceLanguage } from 'i18next';
|
import i18n, { type ResourceLanguage } from 'i18next';
|
||||||
import { initReactI18next } from 'react-i18next';
|
import { initReactI18next } from 'react-i18next';
|
||||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||||
|
import { getTelegramLanguageCode } from './hooks/useTelegramSDK';
|
||||||
|
|
||||||
const localeLoaders: Record<string, () => Promise<{ default: ResourceLanguage }>> = {
|
const localeLoaders: Record<string, () => Promise<{ default: ResourceLanguage }>> = {
|
||||||
ru: () => import('./locales/ru.json'),
|
ru: () => import('./locales/ru.json'),
|
||||||
@@ -11,6 +12,7 @@ const localeLoaders: Record<string, () => Promise<{ default: ResourceLanguage }>
|
|||||||
|
|
||||||
const SUPPORTED_LANGS = Object.keys(localeLoaders);
|
const SUPPORTED_LANGS = Object.keys(localeLoaders);
|
||||||
const FALLBACK_LNG = 'ru';
|
const FALLBACK_LNG = 'ru';
|
||||||
|
const LANGUAGE_STORAGE_KEY = 'cabinet_language';
|
||||||
|
|
||||||
const loadedLanguages = new Set<string>();
|
const loadedLanguages = new Set<string>();
|
||||||
|
|
||||||
@@ -61,4 +63,21 @@ i18n.on('languageChanged', (lng: string) => {
|
|||||||
loadLanguage(code);
|
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;
|
export default i18n;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { AppWithNavigator } from './AppWithNavigator';
|
|||||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
import { initLogoPreload } from './api/branding';
|
import { initLogoPreload } from './api/branding';
|
||||||
import { getCachedFullscreenEnabled, isTelegramMobile } from './hooks/useTelegramSDK';
|
import { getCachedFullscreenEnabled, isTelegramMobile } from './hooks/useTelegramSDK';
|
||||||
import './i18n';
|
import { applyTelegramLanguage } from './i18n';
|
||||||
import './styles/globals.css';
|
import './styles/globals.css';
|
||||||
|
|
||||||
// Polyfill Object.hasOwn for older iOS/Android WebViews (Safari < 15.4, old Chrome).
|
// Polyfill Object.hasOwn for older iOS/Android WebViews (Safari < 15.4, old Chrome).
|
||||||
@@ -55,6 +55,9 @@ if (isTelegramEnv && !alreadyInitialized) {
|
|||||||
|
|
||||||
clearStaleSessionIfNeeded(retrieveRawInitData() || null);
|
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.
|
// Each mount in its own try/catch so one failure doesn't block others.
|
||||||
// mountMiniApp() internally mounts themeParams in SDK v3,
|
// mountMiniApp() internally mounts themeParams in SDK v3,
|
||||||
// so we don't call mountThemeParams() separately to avoid ConcurrentCallError.
|
// so we don't call mountThemeParams() separately to avoid ConcurrentCallError.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { AdminBackButton } from '../components/admin/AdminBackButton';
|
import { AdminBackButton } from '../components/admin/AdminBackButton';
|
||||||
|
import { useFocusTrap } from '../hooks/useFocusTrap';
|
||||||
import {
|
import {
|
||||||
banSystemApi,
|
banSystemApi,
|
||||||
type BanSystemStatus,
|
type BanSystemStatus,
|
||||||
@@ -198,6 +199,9 @@ export default function AdminBanSystem() {
|
|||||||
const [stats, setStats] = useState<BanSystemStats | null>(null);
|
const [stats, setStats] = useState<BanSystemStats | null>(null);
|
||||||
const [users, setUsers] = useState<BanUsersListResponse | null>(null);
|
const [users, setUsers] = useState<BanUsersListResponse | null>(null);
|
||||||
const [selectedUser, setSelectedUser] = useState<BanUserDetailResponse | 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 [punishments, setPunishments] = useState<BanPunishmentsListResponse | null>(null);
|
||||||
const [nodes, setNodes] = useState<BanNodesListResponse | null>(null);
|
const [nodes, setNodes] = useState<BanNodesListResponse | null>(null);
|
||||||
const [agents, setAgents] = useState<BanAgentsListResponse | null>(null);
|
const [agents, setAgents] = useState<BanAgentsListResponse | null>(null);
|
||||||
@@ -1498,15 +1502,21 @@ export default function AdminBanSystem() {
|
|||||||
onClick={() => setSelectedUser(null)}
|
onClick={() => setSelectedUser(null)}
|
||||||
>
|
>
|
||||||
<div
|
<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"
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between border-b border-dark-700 p-4">
|
<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')}
|
{t('banSystem.userDetail.title')}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedUser(null)}
|
onClick={() => setSelectedUser(null)}
|
||||||
|
aria-label={t('common.close')}
|
||||||
className="text-dark-400 hover:text-dark-200"
|
className="text-dark-400 hover:text-dark-200"
|
||||||
>
|
>
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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 { PermissionGate } from '@/components/auth/PermissionGate';
|
||||||
import { usePermissionStore } from '@/store/permissions';
|
import { usePermissionStore } from '@/store/permissions';
|
||||||
import { usePlatform } from '@/platform/hooks/usePlatform';
|
import { usePlatform } from '@/platform/hooks/usePlatform';
|
||||||
|
import { useFocusTrap } from '@/hooks/useFocusTrap';
|
||||||
|
|
||||||
const BackIcon = () => (
|
const BackIcon = () => (
|
||||||
<svg
|
<svg
|
||||||
@@ -64,6 +65,9 @@ export default function AdminRoles() {
|
|||||||
|
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const deleteDialogRef = useFocusTrap<HTMLDivElement>(deleteConfirm !== null, {
|
||||||
|
onEscape: () => setDeleteConfirm(null),
|
||||||
|
});
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const {
|
const {
|
||||||
@@ -248,8 +252,15 @@ export default function AdminRoles() {
|
|||||||
onClick={() => setDeleteConfirm(null)}
|
onClick={() => setDeleteConfirm(null)}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<div className="relative w-full max-w-sm rounded-xl border border-dark-700 bg-dark-800 p-6">
|
<div
|
||||||
<h3 className="mb-2 text-lg font-semibold text-dark-100">
|
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')}
|
{t('admin.roles.confirm.title')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mb-6 text-dark-400">{t('admin.roles.confirm.text')}</p>
|
<p className="mb-6 text-dark-400">{t('admin.roles.confirm.text')}</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user