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