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
+9 -31
View File
@@ -1,13 +1,12 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Link, NavLink, useNavigate } from 'react-router';
import { NavLink, useNavigate } from 'react-router';
import { createChannel, fetchChannels, guildChannelsQueryKey, leaveGuild } from '@/api/guilds';
import { createChannel, fetchChannels, guildChannelsQueryKey } from '@/api/guilds';
import type { Channel } from '@/api/types';
import { ChannelBadge } from '@/components/chat/ChannelBadge';
import { useChannelUnread } from '@/components/chat/useChannelUnread';
import { myGuildsQueryKey } from '@/api/users';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field, SelectField } from '@/components/ui/Field';
import { Modal } from '@/components/ui/Modal';
@@ -17,6 +16,7 @@ import { groupChannels } from '@/lib/channels';
import { canManageGuild, canManageGuildSettings } from '@/lib/identity';
import { useCurrentUser } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { useUiStore } from '@/stores/ui';
interface ChannelSidebarProps {
guildId: string | null;
@@ -79,7 +79,7 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
);
const setGuildChannels = useSessionStore((state) => state.setGuildChannels);
const upsertChannel = useSessionStore((state) => state.upsertChannel);
const removeGuild = useSessionStore((state) => state.removeGuild);
const openSettings = useUiStore((state) => state.openSettings);
const [createOpen, setCreateOpen] = useState(false);
const [channelName, setChannelName] = useState('');
@@ -121,17 +121,6 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
},
});
const leave = useMutation({
mutationFn: () => leaveGuild(guildId ?? ''),
onSuccess: () => {
if (guildId !== null) {
removeGuild(guildId);
}
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
void navigate('/app/empty', { replace: true });
},
});
const groups = useMemo(() => groupChannels(channels), [channels]);
const categories = useMemo(
() => channels.filter((channel) => channel.type === 'category'),
@@ -163,14 +152,15 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
</h1>
<div className="flex items-center gap-1">
{canManageSettings ? (
<Link
to={`/settings/servers/${guildId}`}
<button
type="button"
aria-label={t('channels.settings')}
title={t('channels.settings')}
className="rounded-[var(--radius-sm)] px-1 text-lg leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
onClick={() => openSettings({ section: 'profile', tab: 'server', guildId })}
>
⚙
</Link>
</button>
) : null}
{canManage ? (
<button
@@ -228,19 +218,7 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
</div>
<footer className="border-t border-border/40 px-2 py-2">
<Button
variant="ghost"
className="w-full"
disabled={leave.isPending}
onClick={() => {
if (window.confirm(t('guilds.leaveConfirm', { name: guild?.name ?? '' }))) {
leave.mutate();
}
}}
>
{t('guilds.leave')}
</Button>
{leave.isError ? <ErrorNotice className="mt-2" error={leave.error} /> : null}
<p className="px-1 text-xs text-fg-muted">{t('channels.leaveHint')}</p>
</footer>
<Modal