feat(web): вход с 2FA сразу, панель пользователя, модальные настройки, markdown
По правкам пользователя: - вход: поле «Код 2FA или резервный код» видно сразу, принимает 14-символьные резервные коды (раньше влезало 6 цифр), пустое значение не ломает вход; - нижняя панель: одна подложка, слева аватар и имя с мини-профилем и выбором статуса, справа микрофон, камера, наушники (переключатели, стрелка с выбором устройства и громкости, переход в «Аудио-видео») и шестерёнка; - кнопка «Покинуть сервер» из сайдбара убрана (выход — в «Опасной зоне»); - настройки открываются модальным окном со списком пунктов; «Профиль» имеет вкладки «Основной» и «Сервер» (список серверов, ник и «Применить ко всем серверам»), аватар загружается и удаляется, есть часовой пояс (МСК по умолчанию); - markdown: добавлены ==выделение==, __жирный__, ***оба***, ||спойлер||, #канал; - двойной клик по сообщению ставит ответ (не срабатывает на ссылках, кнопках и при выделенном тексте); - 219 тестов Vitest (было 176), check/lint/test/build зелёные.
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user