feat(web): вход с 2FA сразу, панель пользователя, модальные настройки, markdown

По правкам пользователя:

- вход: поле «Код 2FA или резервный код» видно сразу, принимает 14-символьные
  резервные коды (раньше влезало 6 цифр), пустое значение не ломает вход;
- нижняя панель: одна подложка, слева аватар и имя с мини-профилем и выбором
  статуса, справа микрофон, камера, наушники (переключатели, стрелка с выбором
  устройства и громкости, переход в «Аудио-видео») и шестерёнка;
- кнопка «Покинуть сервер» из сайдбара убрана (выход — в «Опасной зоне»);
- настройки открываются модальным окном со списком пунктов; «Профиль» имеет
  вкладки «Основной» и «Сервер» (список серверов, ник и «Применить ко всем
  серверам»), аватар загружается и удаляется, есть часовой пояс (МСК по
  умолчанию);
- markdown: добавлены ==выделение==, __жирный__, ***оба***, ||спойлер||, #канал;
- двойной клик по сообщению ставит ответ (не срабатывает на ссылках, кнопках и
  при выделенном тексте);
- 219 тестов Vitest (было 176), check/lint/test/build зелёные.
This commit is contained in:
2026-09-20 01:32:11 +03:00
parent f26728113d
commit 20eed227ca
31 changed files with 2731 additions and 478 deletions
+7 -1
View File
@@ -168,6 +168,11 @@ interface MenuItemProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Для `role="menuitemradio"`: выбран ли пункт (рисуется галочка). */
selected?: boolean;
danger?: boolean;
/**
* Показывать иконку и у выбранного пункта (галочка не заменяет её).
* Нужно для цветных точек статуса: цвет важнее отметки выбора.
*/
alwaysShowIcon?: boolean;
}
/** Пункт меню: обычный (`menuitem`) или переключатель (`menuitemradio`). */
@@ -175,6 +180,7 @@ export function MenuItem({
icon,
selected = false,
danger = false,
alwaysShowIcon = false,
className = '',
children,
...rest
@@ -192,7 +198,7 @@ export function MenuItem({
} ${className}`}
>
<span aria-hidden="true" className="w-4 shrink-0 text-center">
{selected ? '✓' : (icon ?? '')}
{selected && !alwaysShowIcon ? '✓' : (icon ?? '')}
</span>
<span className="min-w-0 flex-1 truncate">{children}</span>
</button>
+52 -8
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { motionDelay } from '@/lib/motion';
@@ -9,20 +10,53 @@ interface ModalProps {
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
/** Ширина панели: обычные окна — `md`, настройки — `xl`. */
size?: 'sm' | 'md' | 'lg' | 'xl';
/** Дополнительные классы панели (например, ограничение высоты). */
panelClassName?: string;
/** Дополнительные классы области содержимого (прокрутка длинных форм). */
bodyClassName?: string;
/**
* Рендерить окно в `document.body`. Нужно, когда модалка открывается из
* узкой колонки (панель пользователя): портал снимает вопросы к вложенным
* `overflow` и трансформациям родителей.
*/
portal?: boolean;
}
/** Длительность анимации закрытия: столько панель живёт в DOM после `open=false`. */
const EXIT_MS = 140;
const sizes = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
} as const;
/** Открытые окна в порядке появления: последнее получает Escape. */
const openModals: symbol[] = [];
/**
* Простое модальное окно: закрывается по Escape и по клику на подложку,
* focus-lock обеспечивается браузером за счёт role="dialog" и автофокуса.
* Модальное окно: закрывается по Escape, по клику на подложку и по крестику
* в правом верхнем углу. Escape обрабатывает только верхнее окно — вложенное
* подтверждение (например, «выйти с сервера») не закрывает родителя.
*
* Появление и исчезновение анимированы (см. `.gl-pop-in`/`.gl-pop-out`):
* при закрытии узел остаётся в DOM до конца обратной анимации, а при
* `prefers-reduced-motion` удаляется сразу.
*/
export function Modal({ open, title, onClose, children, footer }: ModalProps) {
export function Modal({
open,
title,
onClose,
children,
footer,
size = 'md',
panelClassName = '',
bodyClassName = '',
portal = false,
}: ModalProps) {
const { t } = useTranslation();
const panelRef = useRef<HTMLDivElement | null>(null);
const [mounted, setMounted] = useState(open);
@@ -64,8 +98,11 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) {
if (!open) {
return;
}
// Стек открытых окон: Escape закрывает только верхнее.
const token = Symbol('modal');
openModals.push(token);
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
if (event.key === 'Escape' && openModals[openModals.length - 1] === token) {
onCloseRef.current();
}
};
@@ -77,6 +114,10 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) {
firstField?.focus();
return () => {
document.removeEventListener('keydown', onKeyDown);
const index = openModals.indexOf(token);
if (index !== -1) {
openModals.splice(index, 1);
}
// Возвращаем фокус туда, откуда окно открыли.
previous?.focus();
};
@@ -86,8 +127,9 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) {
return null;
}
return (
const node = (
<div
data-testid="modal-backdrop"
className={`fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 ${
closing ? 'gl-backdrop-out' : 'gl-backdrop-in'
}`}
@@ -102,9 +144,9 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) {
role="dialog"
aria-modal="true"
aria-label={title}
className={`w-full max-w-md rounded-[var(--radius-lg)] border border-border/60 bg-surface-1 p-5 shadow-[var(--shadow-3)] ${
className={`w-full ${sizes[size]} rounded-[var(--radius-lg)] border border-border/60 bg-surface-1 shadow-[var(--shadow-3)] ${
closing ? 'gl-pop-out' : 'gl-pop-in'
}`}
} ${panelClassName === '' ? 'p-5' : panelClassName}`}
>
<div className="flex items-start justify-between gap-4">
<h2 className="text-lg font-semibold">{title}</h2>
@@ -117,9 +159,11 @@ export function Modal({ open, title, onClose, children, footer }: ModalProps) {
×
</button>
</div>
<div className="mt-4">{children}</div>
<div className={`mt-4 ${bodyClassName}`}>{children}</div>
{footer === undefined ? null : <div className="mt-5 flex justify-end gap-2">{footer}</div>}
</div>
</div>
);
return portal ? createPortal(node, document.body) : node;
}