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
+112
View File
@@ -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>
);
}