feat(web): вход с 2FA сразу, панель пользователя, модальные настройки, markdown
По правкам пользователя: - вход: поле «Код 2FA или резервный код» видно сразу, принимает 14-символьные резервные коды (раньше влезало 6 цифр), пустое значение не ломает вход; - нижняя панель: одна подложка, слева аватар и имя с мини-профилем и выбором статуса, справа микрофон, камера, наушники (переключатели, стрелка с выбором устройства и громкости, переход в «Аудио-видео») и шестерёнка; - кнопка «Покинуть сервер» из сайдбара убрана (выход — в «Опасной зоне»); - настройки открываются модальным окном со списком пунктов; «Профиль» имеет вкладки «Основной» и «Сервер» (список серверов, ник и «Применить ко всем серверам»), аватар загружается и удаляется, есть часовой пояс (МСК по умолчанию); - markdown: добавлены ==выделение==, __жирный__, ***оба***, ||спойлер||, #канал; - двойной клик по сообщению ставит ответ (не срабатывает на ссылках, кнопках и при выделенном тексте); - 219 тестов Vitest (было 176), check/lint/test/build зелёные.
This commit is contained in:
@@ -26,10 +26,12 @@ export interface MessageContentProps {
|
||||
/**
|
||||
* Порядок альтернатив важен: сначала более длинные маркеры (`***` раньше `**`
|
||||
* и `*`), ссылки — раньше `#канала`, чтобы `https://x/#якорь` целиком остался
|
||||
* ссылкой. Подчёркивания внутри слова (`foo_bar_baz`) курсивом не выделяются.
|
||||
* ссылкой. Одиночная `*` не считается маркером внутри ряда звёздочек
|
||||
* (`**фыв` не превращается в курсив со «звёздочкой» в тексте), а `_` — внутри
|
||||
* слова (`foo_bar_baz`).
|
||||
*/
|
||||
const INLINE_PATTERN =
|
||||
/(`[^`\n]+`)|(\*\*\*[^*\n]+\*\*\*)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\|\|[^|\n]+\|\|)|(\*[^*\n]+\*)|((?<![\p{L}\p{N}_])_[^_\n]+_(?![\p{L}\p{N}_]))|(==[^=\n]+==)|(<@\d+>)|(https?:\/\/[^\s<>()]+)|(#[\p{L}\p{N}_-]+)/gu;
|
||||
/(`[^`\n]+`)|(\*\*\*[^*\n]+\*\*\*)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\|\|[^|\n]+\|\|)|((?<!\*)\*(?!\*)[^*\n]+(?<!\*)\*(?!\*))|((?<![\p{L}\p{N}_])_(?!_)[^_\n]+(?<!_)_(?![\p{L}\p{N}_]))|(==[^=\n]+==)|(<@\d+>)|(https?:\/\/[^\s<>()]+)|(#[\p{L}\p{N}_-]+)/gu;
|
||||
|
||||
/** Ссылка: обрезаем хвостовую пунктуацию, которая не частью адреса. */
|
||||
function splitLink(raw: string): { href: string; trailing: string } {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useMediaDevices } from '@/lib/useMediaDevices';
|
||||
import type { VoiceDeviceKind } from '@/stores/voice';
|
||||
|
||||
/**
|
||||
* Список устройств ввода/вывода: используется и в поповерах панели
|
||||
* пользователя, и в разделе настроек «Аудио-видео».
|
||||
*
|
||||
* Если API недоступно (старый браузер), показываем «Устройства недоступны»;
|
||||
* пустой список без разрешения на доступ тоже честнее показать текстом.
|
||||
*/
|
||||
export function VoiceDeviceList({
|
||||
kind,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
kind: VoiceDeviceKind;
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (deviceId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { devices, supported } = useMediaDevices(kind);
|
||||
|
||||
const optionClass = (selected: boolean): string =>
|
||||
`w-full truncate rounded-[var(--radius-sm)] px-2 py-1 text-left text-sm transition-colors duration-[var(--duration-fast)] ${
|
||||
selected ? 'bg-accent/15 text-fg' : 'text-fg-muted hover:bg-surface-3 hover:text-fg'
|
||||
}`;
|
||||
|
||||
if (!supported) {
|
||||
return (
|
||||
<div>
|
||||
<p className="px-1 pb-1 text-xs font-semibold uppercase text-fg-muted">{label}</p>
|
||||
<p className="px-1 py-1 text-xs text-fg-muted">{t('user.devices.unavailable')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="px-1 pb-1 text-xs font-semibold uppercase text-fg-muted">{label}</p>
|
||||
{devices.length === 0 ? (
|
||||
<p className="px-1 py-1 text-xs text-fg-muted">{t('user.devices.empty')}</p>
|
||||
) : (
|
||||
<ul
|
||||
role="listbox"
|
||||
aria-label={label}
|
||||
className="flex max-h-40 flex-col gap-0.5 overflow-y-auto"
|
||||
>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={value === ''}
|
||||
className={optionClass(value === '')}
|
||||
onClick={() => onChange('')}
|
||||
>
|
||||
{t('user.devices.default')}
|
||||
</button>
|
||||
</li>
|
||||
{devices.map((device, index) => (
|
||||
<li key={device.deviceId}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={value === device.deviceId}
|
||||
className={optionClass(value === device.deviceId)}
|
||||
onClick={() => onChange(device.deviceId)}
|
||||
>
|
||||
{device.label === ''
|
||||
? t('user.devices.unnamed', { index: index + 1 })
|
||||
: device.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Слайдер громкости 0–100: значение уходит в zustand-стор и localStorage. */
|
||||
export function VolumeSlider({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1 px-1 pt-2">
|
||||
<span className="flex items-center justify-between gap-2 text-xs text-fg-muted">
|
||||
{label}
|
||||
<span aria-hidden="true">{value}%</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={value}
|
||||
aria-label={label}
|
||||
className="w-full accent-[var(--color-accent)]"
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user