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
+292 -42
View File
@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback, useRef, useState, type ReactNode, type RefObject } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
@@ -8,19 +8,123 @@ import { currentUserQueryKey, updateProfile } from '@/api/users';
import { userStatuses, type UserStatus } from '@/api/types';
import { Avatar } from '@/components/ui/Avatar';
import { Menu, MenuGroupLabel, MenuItem, MenuSeparator } from '@/components/ui/Menu';
import { Popover } from '@/components/ui/Popover';
import { VolumeSlider, VoiceDeviceList } from '@/components/voice/VoiceControls';
import { useCurrentUser } from '@/lib/hooks';
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
import { useDeviceChoice } from '@/lib/useMediaDevices';
import { useGatewayStore } from '@/stores/gateway';
import { useMessagesStore } from '@/stores/messages';
import { useSessionStore } from '@/stores/session';
import { useUiStore } from '@/stores/ui';
import { useVoiceStore } from '@/stores/voice';
import { SettingsModal } from '@/pages/settings/SettingsModal';
/** Статусы, которые можно выставить из меню: «не в сети» сервер выводит сам. */
/** Статусы, которые можно выставить из мини-профиля: «не в сети» сервер выводит сам. */
const selectableStatuses = userStatuses.filter((value) => value !== 'offline');
/** Цвет точки статуса — тот же, что в списках участников. */
const statusDotClass: Record<UserStatus, string> = {
online: 'bg-success',
idle: 'bg-warning',
dnd: 'bg-danger',
invisible: 'bg-fg-muted',
offline: 'bg-fg-muted',
};
/** Иконка с перечёркиванием для выключенного состояния (как в мессенджерах). */
function PanelIcon({ glyph, off }: { glyph: string; off: boolean }) {
return (
<span aria-hidden="true" className="relative inline-block leading-none">
<span className={off ? 'opacity-60' : ''}>{glyph}</span>
{off ? (
<span className="pointer-events-none absolute left-1/2 top-1/2 h-[1.5px] w-5 -translate-x-1/2 -translate-y-1/2 -rotate-45 rounded-full bg-current" />
) : null}
</span>
);
}
interface PanelControlProps {
glyph: string;
/** Включено ли устройство: от этого зависит вид и подпись кнопки. */
enabled: boolean;
/** Подпись кнопки-переключателя (озвучивает текущее действие). */
toggleLabel: string;
/** Подпись стрелки и поповера. */
menuLabel: string;
onToggle: () => void;
open: boolean;
onOpenMenu: () => void;
onCloseMenu: () => void;
children: ReactNode;
}
/**
* Панель пользователя внизу слева: аватар, имя и статус. Клик по аватару,
* имени или шестерёнке открывает всплывающее вверх меню с настройками,
* сменой статуса и выходом (см. `Menu`).
* Кнопка устройства со стрелкой слева: сама кнопка переключает состояние,
* стрелка открывает поповер с устройствами и громкостью. Это две отдельные
* кнопки, поэтому выпадающий список не перекрывает переключатель.
*/
function PanelControl({
glyph,
enabled,
toggleLabel,
menuLabel,
onToggle,
open,
onOpenMenu,
onCloseMenu,
children,
}: PanelControlProps) {
const anchorRef: RefObject<HTMLButtonElement | null> = useRef(null);
return (
<div className="relative flex items-center">
{/* Узкая стрелка: панель шириной 240px, место здесь на счету. */}
<button
type="button"
aria-haspopup="dialog"
aria-expanded={open}
aria-label={menuLabel}
title={menuLabel}
onClick={onOpenMenu}
className="rounded-[var(--radius-sm)] px-0.5 text-[9px] leading-none text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
>
▾
</button>
<button
ref={anchorRef}
type="button"
aria-pressed={enabled}
aria-label={toggleLabel}
title={toggleLabel}
onClick={onToggle}
className={`rounded-[var(--radius-sm)] px-1 py-1 text-sm leading-none transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 active:scale-95 ${
enabled ? 'text-fg' : 'text-danger'
}`}
>
<PanelIcon glyph={glyph} off={!enabled} />
</button>
<Popover
open={open}
label={menuLabel}
anchorRef={anchorRef}
align="right"
className="w-72"
onClose={onCloseMenu}
>
{children}
</Popover>
</div>
);
}
/**
* Панель пользователя внизу сайдбара: одна «подложка» с двумя зонами.
*
* Слева — аватар, имя, статус и мини-профиль (статус, настройки, выход),
* справа — микрофон, камера, наушники (каждое со своим поповером устройств)
* и шестерёнка, открывающая модальное окно настроек.
*/
export function UserPanel() {
const { t } = useTranslation();
@@ -30,12 +134,30 @@ export function UserPanel() {
const describeError = useApiErrorMessage();
const sessionUser = useSessionStore((state) => state.user);
const disconnect = useGatewayStore((state) => state.disconnect);
const openSettings = useUiStore((state) => state.openSettings);
const [menuOpen, setMenuOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const micEnabled = useVoiceStore((state) => state.micEnabled);
const cameraEnabled = useVoiceStore((state) => state.cameraEnabled);
const headphonesEnabled = useVoiceStore((state) => state.headphonesEnabled);
const toggleMic = useVoiceStore((state) => state.toggleMic);
const toggleCamera = useVoiceStore((state) => state.toggleCamera);
const toggleHeadphones = useVoiceStore((state) => state.toggleHeadphones);
const micVolume = useVoiceStore((state) => state.micVolume);
const outputVolume = useVoiceStore((state) => state.outputVolume);
const setMicVolume = useVoiceStore((state) => state.setMicVolume);
const setOutputVolume = useVoiceStore((state) => state.setOutputVolume);
const mic = useDeviceChoice('audioinput');
const camera = useDeviceChoice('videoinput');
const output = useDeviceChoice('audiooutput');
const [profileOpen, setProfileOpen] = useState(false);
/** Какое устройство сейчас раскрыто в поповере: одновременно только одно. */
const [openControl, setOpenControl] = useState<'mic' | 'camera' | 'headphones' | null>(null);
const profileRef = useRef<HTMLButtonElement | null>(null);
const name = sessionUser?.display_name ?? currentUser.data?.display_name ?? '';
const username = sessionUser?.username ?? currentUser.data?.username ?? '';
const bio = currentUser.data?.bio ?? '';
const displayName = name === '' ? username : name;
const status: UserStatus = sessionUser?.status ?? currentUser.data?.status ?? 'online';
const customStatus = sessionUser?.custom_status ?? '';
@@ -61,7 +183,7 @@ export function UserPanel() {
onSuccess: (updated) => {
queryClient.setQueryData(currentUserQueryKey, updated);
useSessionStore.getState().patchUser(updated);
setMenuOpen(false);
setProfileOpen(false);
},
});
@@ -74,14 +196,29 @@ export function UserPanel() {
return;
}
void navigator.clipboard?.writeText(currentUser.data.id);
setProfileOpen(false);
}, [currentUser.data]);
const toggleMenu = useCallback(() => setMenuOpen((value) => !value), []);
const closeMenu = useCallback(() => setMenuOpen(false), []);
const toggleProfile = useCallback(() => {
setOpenControl(null);
setProfileOpen((value) => !value);
}, []);
const closeProfile = useCallback(() => setProfileOpen(false), []);
const goTo = (path: string) => (): void => {
setMenuOpen(false);
void navigate(path);
/** Открыть поповер устройства: мини-профиль при этом закрывается. */
const toggleControl = useCallback((control: 'mic' | 'camera' | 'headphones') => {
setProfileOpen(false);
setOpenControl((current) => (current === control ? null : control));
}, []);
const openAudioSettings = (): void => {
setOpenControl(null);
openSettings({ section: 'voice' });
};
const goToSettings = (target: { section?: 'profile'; tab?: 'main' | 'server' }): void => {
setProfileOpen(false);
openSettings(target);
};
const error = changeStatus.error ?? signOut.error;
@@ -90,19 +227,27 @@ export function UserPanel() {
return (
<footer
aria-label={t('user.panelLabel')}
className="relative flex flex-col border-t border-border/40 bg-surface-2/80"
className="relative border-t border-border/40 bg-surface-2/80 p-2"
>
<div className="flex items-center gap-1 px-2 py-2">
{/* Подложка панели: слева профиль, справа кнопки устройств и настроек. */}
<div className="flex items-center gap-0.5 rounded-[var(--radius-md)] border border-border/60 bg-surface-1 px-1.5 py-1.5">
<button
ref={triggerRef}
ref={profileRef}
type="button"
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-expanded={profileOpen}
aria-label={t('user.menu.open')}
onClick={toggleMenu}
onClick={toggleProfile}
className="flex min-w-0 flex-1 items-center gap-2 rounded-[var(--radius-md)] px-1 py-1 text-left transition-colors duration-[var(--duration-fast)] hover:bg-surface-3/70 active:bg-surface-3"
>
<Avatar name={displayName} seed={seed} fileId={avatarFileId} size="md" />
<span className="relative shrink-0">
<Avatar name={displayName} seed={seed} fileId={avatarFileId} size="md" />
<span
aria-hidden="true"
data-testid="user-status-dot"
className={`absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-surface-1 ${statusDotClass[status]}`}
/>
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium" title={displayName}>
{displayName}
@@ -118,37 +263,109 @@ export function UserPanel() {
</span>
</button>
<button
type="button"
aria-label={t('user.copyId')}
title={t('user.copyId')}
className="rounded-[var(--radius-sm)] px-2 py-1 text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
onClick={onCopyId}
<PanelControl
glyph="🎤"
enabled={micEnabled}
toggleLabel={t(micEnabled ? 'user.voice.micOff' : 'user.voice.micOn')}
menuLabel={t('user.voice.micSettings')}
onToggle={toggleMic}
open={openControl === 'mic'}
onOpenMenu={() => toggleControl('mic')}
onCloseMenu={() => setOpenControl(null)}
>
#
</button>
<VoiceDeviceList
kind="audioinput"
label={t('user.voice.input')}
value={mic.value}
onChange={mic.select}
/>
<VolumeSlider
label={t('user.voice.micVolume')}
value={micVolume}
onChange={setMicVolume}
/>
<AudioSettingsLink label={t('user.voice.audioSettings')} onOpen={openAudioSettings} />
</PanelControl>
<PanelControl
glyph="🎥"
enabled={cameraEnabled}
toggleLabel={t(cameraEnabled ? 'user.voice.cameraOff' : 'user.voice.cameraOn')}
menuLabel={t('user.voice.cameraSettings')}
onToggle={toggleCamera}
open={openControl === 'camera'}
onOpenMenu={() => toggleControl('camera')}
onCloseMenu={() => setOpenControl(null)}
>
<VoiceDeviceList
kind="videoinput"
label={t('user.voice.camera')}
value={camera.value}
onChange={camera.select}
/>
<AudioSettingsLink label={t('user.voice.audioSettings')} onOpen={openAudioSettings} />
</PanelControl>
<PanelControl
glyph="🎧"
enabled={headphonesEnabled}
toggleLabel={t(headphonesEnabled ? 'user.voice.soundOff' : 'user.voice.soundOn')}
menuLabel={t('user.voice.soundSettings')}
onToggle={toggleHeadphones}
open={openControl === 'headphones'}
onOpenMenu={() => toggleControl('headphones')}
onCloseMenu={() => setOpenControl(null)}
>
<VoiceDeviceList
kind="audiooutput"
label={t('user.voice.output')}
value={output.value}
onChange={output.select}
/>
<VolumeSlider
label={t('user.voice.outputVolume')}
value={outputVolume}
onChange={setOutputVolume}
/>
<AudioSettingsLink label={t('user.voice.audioSettings')} onOpen={openAudioSettings} />
</PanelControl>
<button
type="button"
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-label={t('user.settings')}
title={t('user.settings')}
className="rounded-[var(--radius-sm)] px-2 py-1 text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
onClick={toggleMenu}
className="rounded-[var(--radius-sm)] px-1 py-1 text-sm 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={() => {
setOpenControl(null);
goToSettings({ section: 'profile', tab: 'main' });
}}
>
⚙
<span aria-hidden="true">⚙</span>
</button>
</div>
<Menu open={menuOpen} label={t('user.menu.label')} onClose={closeMenu} anchorRef={triggerRef}>
<MenuGroupLabel>{displayName}</MenuGroupLabel>
<MenuItem icon="👤" onClick={goTo('/settings/account/profile')}>
{t('user.menu.account')}
</MenuItem>
<MenuItem icon="🗄" onClick={goTo('/settings/servers')}>
{t('user.menu.guilds')}
</MenuItem>
<MenuSeparator />
<Menu
open={profileOpen}
label={t('user.menu.label')}
onClose={closeProfile}
anchorRef={profileRef}
className="w-64"
>
{/* Мини-профиль: аватар, имя, логин и «О себе». */}
<div
role="presentation"
className="flex flex-col gap-2 border-b border-border/60 px-2 pb-2"
>
<div className="flex items-center gap-2">
<Avatar name={displayName} seed={seed} fileId={avatarFileId} size="lg" />
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{displayName}</p>
<p className="truncate text-xs text-fg-muted">@{username}</p>
</div>
</div>
<p className="text-xs text-fg-muted">{bio === '' ? t('user.menu.noBio') : bio}</p>
</div>
<div role="group" aria-label={t('user.menu.status')}>
<MenuGroupLabel>{t('user.menu.status')}</MenuGroupLabel>
{selectableStatuses.map((value) => (
@@ -156,6 +373,12 @@ export function UserPanel() {
key={value}
role="menuitemradio"
selected={value === status}
alwaysShowIcon
icon={
<span
className={`inline-block h-2.5 w-2.5 rounded-full ${statusDotClass[value]}`}
/>
}
disabled={changeStatus.isPending}
onClick={() => {
changeStatus.reset();
@@ -166,6 +389,17 @@ export function UserPanel() {
</MenuItem>
))}
</div>
<MenuSeparator />
<MenuItem icon="👤" onClick={() => goToSettings({ section: 'profile', tab: 'main' })}>
{t('user.menu.account')}
</MenuItem>
<MenuItem icon="🗄" onClick={() => goToSettings({ section: 'profile', tab: 'server' })}>
{t('user.menu.guilds')}
</MenuItem>
<MenuItem icon="#" onClick={onCopyId}>
{t('user.copyId')}
</MenuItem>
<MenuSeparator />
<MenuItem
danger
@@ -181,10 +415,26 @@ export function UserPanel() {
</Menu>
{errorText === null ? null : (
<p role="alert" className="px-3 pb-2 text-xs text-danger">
<p role="alert" className="px-1 pt-2 text-xs text-danger">
{errorText}
</p>
)}
{/* Окно настроек живёт здесь же: панель — единственная точка входа. */}
<SettingsModal />
</footer>
);
}
/** Ссылка «Аудио-видео настройки» в поповере: открывает окно настроек. */
function AudioSettingsLink({ label, onOpen }: { label: string; onOpen: () => void }) {
return (
<button
type="button"
onClick={onOpen}
className="mt-2 w-full border-t border-border/60 px-1 pt-2 text-left text-xs text-accent underline decoration-accent/40 underline-offset-2 hover:decoration-accent"
>
{label}
</button>
);
}
@@ -10,6 +10,16 @@ import { Button } from '@/components/ui/primitives';
import { useSessionStore } from '@/stores/session';
import { SettingsSection } from '@/pages/settings/SettingsSection';
/** Массовое применение ника: прогресс и итог по всем серверам пользователя. */
export interface ApplyAllNickname {
pending: boolean;
/** Текущий прогресс: сколько серверов уже обработано. */
progress: { done: number; total: number } | null;
/** Итог последнего запуска: сколько серверов не удалось обновить. */
result: { failed: number; total: number } | null;
run: (nickname: string) => void;
}
interface GuildNicknameSectionProps {
guildId: string;
/** id текущего пользователя: сервер разрешает менять ник самому себе. */
@@ -18,6 +28,8 @@ interface GuildNicknameSectionProps {
currentNickname: string;
/** Участники сервера (для подсказки, если ник пришёл не из снапшота). */
members: GuildMember[] | undefined;
/** Кнопка «Применить ко всем серверам» (только во вкладке профиля сервера). */
applyAll?: ApplyAllNickname;
}
/** «Мой профиль на сервере»: свой никнейм, пустое значение его сбрасывает. */
@@ -26,6 +38,7 @@ export function GuildNicknameSection({
userId,
currentNickname,
members,
applyAll,
}: GuildNicknameSectionProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
@@ -92,6 +105,47 @@ export function GuildNicknameSection({
</Button>
</div>
</form>
{applyAll === undefined ? null : (
<div className="mt-4 border-t border-border/40 pt-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="max-w-md text-sm text-fg-muted">
{t('settings.servers.nickname.applyAllHint')}
</p>
<Button
variant="ghost"
disabled={applyAll.pending || userId === undefined}
onClick={() => applyAll.run(nickname.trim())}
>
{t(
applyAll.pending
? 'settings.servers.nickname.applyAllPending'
: 'settings.servers.nickname.applyAll',
)}
</Button>
</div>
{applyAll.progress === null ? null : (
<p role="status" className="mt-2 text-sm text-fg-muted">
{t('settings.servers.nickname.applyAllProgress', {
done: applyAll.progress.done,
total: applyAll.progress.total,
})}
</p>
)}
{applyAll.result === null ? null : applyAll.result.failed === 0 ? (
<p role="status" className="mt-2 text-sm text-success">
{t('settings.servers.nickname.applyAllDone', { total: applyAll.result.total })}
</p>
) : (
<p role="alert" className="mt-2 text-sm text-danger">
{t('settings.servers.nickname.applyAllFailed', {
failed: applyAll.result.failed,
})}
</p>
)}
</div>
)}
</SettingsSection>
);
}
@@ -0,0 +1,87 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { currentUserQueryKey, updateProfile } from '@/api/users';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { SelectField } from '@/components/ui/Field';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
import { useCurrentUser } from '@/lib/hooks';
import { DEFAULT_TIMEZONE, timezoneChoices, timezoneLabel } from '@/lib/timezones';
import { useSessionStore } from '@/stores/session';
import { SettingsSection } from '@/pages/settings/SettingsSection';
/**
* «Язык и регион»: часовой пояс (уходит на сервер сразу) и язык интерфейса
* (локально + в профиль, чтобы выбор пережил переустановку браузера).
*/
export function LanguageRegionSection() {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
const currentUser = useCurrentUser();
const locale = i18n.resolvedLanguage === 'en' ? 'en' : 'ru';
const save = useMutation({
mutationFn: (input: { timezone?: string; locale?: string }) => updateProfile(input),
onSuccess: (updated) => {
queryClient.setQueryData(currentUserQueryKey, updated);
useSessionStore.getState().patchUser(updated);
},
});
if (currentUser.isPending) {
return (
<SettingsSection title={t('settings.language.title')}>
<SkeletonLines rows={3} />
</SettingsSection>
);
}
const timezone = currentUser.data?.timezone ?? DEFAULT_TIMEZONE;
const language: SupportedLanguage = currentUser.data?.locale === 'en' ? 'en' : 'ru';
const changeLanguage = (value: SupportedLanguage): void => {
window.localStorage.setItem('glchat.language', value);
void i18n.changeLanguage(value);
save.mutate({ locale: value });
};
return (
<SettingsSection
title={t('settings.language.title')}
description={t('settings.language.description')}
>
<div className="mt-4 flex flex-col gap-4">
<SelectField
label={t('settings.language.timezone')}
hint={t('settings.language.timezoneHint')}
value={timezone}
onChange={(value) => save.mutate({ timezone: value })}
options={timezoneChoices(timezone).map((zone) => ({
value: zone,
label:
zone === DEFAULT_TIMEZONE
? `${timezoneLabel(zone, locale)} · ${t('settings.profile.timezoneDefault')}`
: timezoneLabel(zone, locale),
}))}
/>
<SelectField
label={t('settings.language.locale')}
hint={t('settings.language.localeHint')}
value={language}
onChange={(value) => changeLanguage(value === 'en' ? 'en' : 'ru')}
options={supportedLanguages.map((value) => ({
value,
label: t(`language.${value}`),
}))}
/>
{save.isError ? <ErrorNotice error={save.error} /> : null}
{save.isSuccess ? (
<p role="status" className="text-sm text-success">
{t('settings.language.saved')}
</p>
) : null}
</div>
</SettingsSection>
);
}
@@ -0,0 +1,22 @@
import { useTranslation } from 'react-i18next';
import { SettingsSection } from '@/pages/settings/SettingsSection';
/**
* «Уведомления»: пункт уже есть в списке настроек, но сами уведомления
* (веб-пуш и звуки) появятся в следующих фазах — показываем заглушку.
*/
export function NotificationsSettingsSection() {
const { t } = useTranslation();
return (
<SettingsSection
title={t('settings.notifications.title')}
description={t('settings.notifications.description')}
>
<p className="mt-3 text-sm text-fg-muted" role="status">
{t('settings.notifications.stub')}
</p>
</SettingsSection>
);
}
+292 -92
View File
@@ -1,47 +1,159 @@
import { useState, type FormEvent } from 'react';
import { useRef, useState, type ChangeEvent, type FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { updateProfile, currentUserQueryKey, type UpdateProfileInput } from '@/api/users';
import {
AVATAR_MAX_BYTES,
deleteAvatar,
updateProfile,
uploadAvatar,
currentUserQueryKey,
type UpdateProfileInput,
} from '@/api/users';
import { userStatuses, type User } from '@/api/types';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field, SelectField, TextAreaField } from '@/components/ui/Field';
import { Button, Card } from '@/components/ui/primitives';
import { Avatar } from '@/components/ui/Avatar';
import { Badge, Button, Card } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
import { formatBytes } from '@/lib/format';
import { useCurrentUser } from '@/lib/hooks';
import { DEFAULT_TIMEZONE, timezoneChoices, timezoneLabel } from '@/lib/timezones';
import { useSessionStore } from '@/stores/session';
import { SettingsSection } from '@/pages/settings/SettingsSection';
/** Ссылка на локальный файл: недоступна в части окружений (и в jsdom). */
function safeObjectUrl(file: File): string | null {
if (typeof URL.createObjectURL !== 'function') {
return null;
}
try {
return URL.createObjectURL(file);
} catch {
return null;
}
}
function revokeObjectUrl(url: string): void {
try {
URL.revokeObjectURL(url);
} catch {
// Ссылка уже недействительна — освобождать нечего.
}
}
/** Показываем выбранный файл до ответа сервера. */
function useAvatarPreview() {
const [preview, setPreview] = useState<string | null>(null);
const show = (file: File): void => {
const url = safeObjectUrl(file);
if (url === null) {
return;
}
setPreview((previous) => {
if (previous !== null) {
revokeObjectUrl(previous);
}
return url;
});
};
const clear = (): void => {
setPreview((previous) => {
if (previous !== null) {
revokeObjectUrl(previous);
}
return null;
});
};
return { preview, show, clear };
}
/**
* Форма профиля. Состояние инициализируется из `user` при монтировании,
* а после сохранения кэш React Query обновляется ответом сервера — поэтому
* синхронизация через эффект не нужна.
*/
function ProfileForm({ user }: { user: User }) {
export function ProfileForm({ user }: { user: User }) {
const { t, i18n } = useTranslation();
const queryClient = useQueryClient();
const locale = i18n.resolvedLanguage === 'en' ? 'en' : 'ru';
const [displayName, setDisplayName] = useState(user.display_name);
const [bio, setBio] = useState(user.bio);
const [customStatus, setCustomStatus] = useState(user.custom_status);
const [customEmoji, setCustomEmoji] = useState(user.custom_status_emoji);
const [status, setStatus] = useState<string>(user.status);
const [locale, setLocale] = useState<SupportedLanguage>(user.locale === 'en' ? 'en' : 'ru');
const [tz, setTz] = useState(user.timezone ?? DEFAULT_TIMEZONE);
const [language, setLanguage] = useState<SupportedLanguage>(user.locale === 'en' ? 'en' : 'ru');
const [saved, setSaved] = useState(false);
const [avatarError, setAvatarError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const avatar = useAvatarPreview();
/** Профиль из ответа сервера: кэш, снапшот сессии и язык интерфейса. */
const applyUser = (updated: User): void => {
queryClient.setQueryData(currentUserQueryKey, updated);
useSessionStore.getState().patchUser(updated);
if (updated.locale === 'en' || updated.locale === 'ru') {
window.localStorage.setItem('glchat.language', updated.locale);
void i18n.changeLanguage(updated.locale);
}
};
const save = useMutation({
mutationFn: (input: UpdateProfileInput) => updateProfile(input),
onSuccess: (updated) => {
queryClient.setQueryData(currentUserQueryKey, updated);
useSessionStore.getState().patchUser(updated);
applyUser(updated);
setSaved(true);
if (updated.locale === 'en' || updated.locale === 'ru') {
window.localStorage.setItem('glchat.language', updated.locale);
void i18n.changeLanguage(updated.locale);
}
},
});
const upload = useMutation({
mutationFn: (file: File) => uploadAvatar(file),
onSuccess: (updated) => {
avatar.clear();
setAvatarError(null);
if (updated === null) {
// Сервер ответил без профиля — перечитываем его запросом.
void queryClient.invalidateQueries({ queryKey: currentUserQueryKey });
return;
}
applyUser(updated);
},
onError: () => avatar.clear(),
});
const removeAvatar = useMutation({
mutationFn: () => deleteAvatar(),
onSuccess: (updated) => {
setAvatarError(null);
avatar.clear();
if (updated !== null) {
applyUser(updated);
}
void queryClient.invalidateQueries({ queryKey: currentUserQueryKey });
},
});
const onPickFile = (event: ChangeEvent<HTMLInputElement>): void => {
const file = event.target.files?.[0];
// Поле переиспользуется: иначе повторный выбор того же файла не сработает.
event.target.value = '';
if (file === undefined) {
return;
}
setAvatarError(null);
if (file.size > AVATAR_MAX_BYTES) {
setAvatarError(
t('settings.profile.avatar.tooLarge', { limit: formatBytes(AVATAR_MAX_BYTES, locale) }),
);
return;
}
avatar.show(file);
upload.mutate(file);
};
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
setSaved(false);
@@ -51,100 +163,188 @@ function ProfileForm({ user }: { user: User }) {
custom_status: customStatus.trim(),
custom_status_emoji: customEmoji.trim(),
status,
locale,
locale: language,
timezone: tz,
});
};
const avatarName = displayName === '' ? user.username : displayName;
return (
<Card>
<h2 className="text-lg font-semibold">{t('settings.profile.title')}</h2>
<p className="mt-1 text-sm text-fg-muted">{t('settings.profile.description')}</p>
<div className="flex flex-col gap-5">
<Card>
<h2 className="text-lg font-semibold">{t('settings.profile.title')}</h2>
<p className="mt-1 text-sm text-fg-muted">{t('settings.profile.description')}</p>
<form className="mt-4 flex flex-col gap-4" onSubmit={onSubmit} noValidate>
<Field
label={t('settings.profile.displayName')}
name="display_name"
value={displayName}
maxLength={64}
onChange={(event) => setDisplayName(event.target.value)}
/>
<TextAreaField
label={t('settings.profile.bio')}
placeholder={t('settings.profile.bioPlaceholder')}
name="bio"
rows={4}
maxLength={512}
value={bio}
onChange={(event) => setBio(event.target.value)}
/>
<div className="grid gap-4 sm:grid-cols-[1fr_6rem]">
<form className="mt-4 flex flex-col gap-4" onSubmit={onSubmit} noValidate>
<Field
label={t('settings.profile.customStatus')}
placeholder={t('settings.profile.customStatusPlaceholder')}
name="custom_status"
maxLength={128}
value={customStatus}
onChange={(event) => setCustomStatus(event.target.value)}
label={t('settings.profile.displayName')}
name="display_name"
value={displayName}
maxLength={64}
onChange={(event) => setDisplayName(event.target.value)}
/>
<Field
label={t('settings.profile.customStatusEmoji')}
placeholder={t('settings.profile.customStatusEmojiPlaceholder')}
name="custom_status_emoji"
maxLength={8}
value={customEmoji}
onChange={(event) => setCustomEmoji(event.target.value)}
<TextAreaField
label={t('settings.profile.bio')}
placeholder={t('settings.profile.bioPlaceholder')}
name="bio"
rows={4}
maxLength={512}
value={bio}
onChange={(event) => setBio(event.target.value)}
/>
<div className="grid gap-4 sm:grid-cols-[1fr_6rem]">
<Field
label={t('settings.profile.customStatus')}
placeholder={t('settings.profile.customStatusPlaceholder')}
name="custom_status"
maxLength={128}
value={customStatus}
onChange={(event) => setCustomStatus(event.target.value)}
/>
<Field
label={t('settings.profile.customStatusEmoji')}
placeholder={t('settings.profile.customStatusEmojiPlaceholder')}
name="custom_status_emoji"
maxLength={8}
value={customEmoji}
onChange={(event) => setCustomEmoji(event.target.value)}
/>
</div>
<SelectField
label={t('settings.profile.status')}
value={status}
onChange={setStatus}
options={userStatuses.map((value) => ({
value,
label: t(`user.status.${value}`),
}))}
/>
<SelectField
label={t('settings.profile.timezone')}
hint={t('settings.profile.timezoneHint')}
value={tz}
onChange={setTz}
options={timezoneChoices(tz).map((zone) => ({
value: zone,
label:
zone === DEFAULT_TIMEZONE
? `${timezoneLabel(zone, locale)} · ${t('settings.profile.timezoneDefault')}`
: timezoneLabel(zone, locale),
}))}
/>
<SelectField
label={t('settings.profile.locale')}
value={language}
onChange={(value) => setLanguage(value === 'en' ? 'en' : 'ru')}
options={supportedLanguages.map((value) => ({
value,
label: t(`language.${value}`),
}))}
/>
<dl className="text-sm text-fg-muted">
<div className="flex justify-between gap-4 border-b border-border/40 py-2">
<dt>{t('settings.profile.username')}</dt>
<dd className="font-medium text-fg">{user.username}</dd>
</div>
<div className="flex justify-between gap-4 border-b border-border/40 py-2">
<dt>{t('settings.profile.userId')}</dt>
<dd className="font-mono text-xs text-fg">{user.id}</dd>
</div>
<div className="flex justify-between gap-4 py-2">
<dt>{t('settings.profile.badges')}</dt>
<dd className="text-fg">
{user.badges.length === 0 ? t('settings.profile.noBadges') : user.badges.join(', ')}
</dd>
</div>
</dl>
{save.isError ? <ErrorNotice error={save.error} /> : null}
{saved && !save.isError ? (
<p className="text-sm text-success" role="status">
{t('settings.profile.saved')}
</p>
) : null}
<div className="flex justify-end">
<Button type="submit" disabled={save.isPending}>
{t(save.isPending ? 'common.saving' : 'common.save')}
</Button>
</div>
</form>
</Card>
<SettingsSection
title={t('settings.profile.avatar.title')}
description={t('settings.profile.avatar.hint')}
>
<div className="mt-4 flex flex-wrap items-center gap-4">
{avatar.preview === null ? (
<Avatar name={avatarName} seed={user.id} fileId={user.avatar_file_id} size="lg" />
) : (
<img
src={avatar.preview}
alt={t('settings.profile.avatar.preview')}
className="h-14 w-14 rounded-full object-cover"
/>
)}
<div className="flex flex-col items-start gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
aria-label={t('settings.profile.avatar.choose')}
className="sr-only"
onChange={onPickFile}
/>
<Button
variant="ghost"
disabled={upload.isPending}
onClick={() => fileInputRef.current?.click()}
>
{t(upload.isPending ? 'common.saving' : 'settings.profile.avatar.upload')}
</Button>
{user.avatar_file_id === undefined ? null : (
<Button
variant="ghost"
disabled={removeAvatar.isPending}
onClick={() => removeAvatar.mutate()}
>
{t('settings.profile.avatar.remove')}
</Button>
)}
<p className="text-xs text-fg-muted">
{t('settings.profile.avatar.limit', {
limit: formatBytes(AVATAR_MAX_BYTES, locale),
})}
</p>
</div>
</div>
<SelectField
label={t('settings.profile.status')}
value={status}
onChange={setStatus}
options={userStatuses.map((value) => ({
value,
label: t(`user.status.${value}`),
}))}
/>
<SelectField
label={t('settings.profile.locale')}
value={locale}
onChange={(value) => setLocale(value === 'en' ? 'en' : 'ru')}
options={supportedLanguages.map((language) => ({
value: language,
label: t(`language.${language}`),
}))}
/>
<dl className="text-sm text-fg-muted">
<div className="flex justify-between gap-4 border-b border-border/40 py-2">
<dt>{t('settings.profile.username')}</dt>
<dd className="font-medium text-fg">{user.username}</dd>
</div>
<div className="flex justify-between gap-4 border-b border-border/40 py-2">
<dt>{t('settings.profile.userId')}</dt>
<dd className="font-mono text-xs text-fg">{user.id}</dd>
</div>
<div className="flex justify-between gap-4 py-2">
<dt>{t('settings.profile.badges')}</dt>
<dd className="text-fg">
{user.badges.length === 0 ? t('settings.profile.noBadges') : user.badges.join(', ')}
</dd>
</div>
</dl>
{save.isError ? <ErrorNotice error={save.error} /> : null}
{saved && !save.isError ? (
<p className="text-sm text-success" role="status">
{t('settings.profile.saved')}
{avatarError === null ? null : (
<p role="alert" className="mt-3 text-sm text-danger">
{avatarError}
</p>
) : null}
)}
{upload.isError ? <ErrorNotice className="mt-3" error={upload.error} /> : null}
{removeAvatar.isError ? <ErrorNotice className="mt-3" error={removeAvatar.error} /> : null}
</SettingsSection>
<div className="flex justify-end">
<Button type="submit" disabled={save.isPending}>
{t(save.isPending ? 'common.saving' : 'common.save')}
</Button>
<SettingsSection
title={t('settings.profile.decorations.title')}
description={t('settings.profile.decorations.hint')}
>
<div className="mt-3 flex flex-wrap items-center gap-2">
{user.badges.length === 0 ? (
<p className="text-sm text-fg-muted">{t('settings.profile.decorations.empty')}</p>
) : (
user.badges.map((badge) => <Badge key={badge}>{badge}</Badge>)
)}
</div>
</form>
</Card>
<p className="mt-3 text-sm text-fg-muted">{t('settings.profile.decorations.stub')}</p>
</SettingsSection>
</div>
);
}
+159 -93
View File
@@ -1,12 +1,18 @@
import { useEffect, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { NavLink, useNavigate, useParams } from 'react-router';
import { fetchGuild, fetchMembers, guildMembersQueryKey, guildQueryKey } from '@/api/guilds';
import {
fetchGuild,
fetchMembers,
guildMembersQueryKey,
guildQueryKey,
updateGuildMember,
} from '@/api/guilds';
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
import { Avatar } from '@/components/ui/Avatar';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Menu, MenuGroupLabel, MenuItem } from '@/components/ui/Menu';
import { Card } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { canManageGuildSettings } from '@/lib/identity';
@@ -20,21 +26,21 @@ import { GuildRolesSection } from '@/pages/settings/GuildRolesSection';
import { SettingsSection } from '@/pages/settings/SettingsSection';
/**
* Настройки пользовательских серверов (`/settings/servers/:guildId`).
* Вкладка «Профиль → Сервер»: мой профиль на конкретном сервере и (для
* владельца и прав управления) администрирование сервера.
*
* Список серверов берём из READY-снапшота, а если шлюз ещё не подключился —
* из `GET /users/@me/guilds` (как в `AppLayout`). Выбранный сервер хранится
* в URL и дублируется в сторе, поэтому прямая ссылка на настройки работает.
* из `GET /users/@me/guilds`. Выбранный сервер лежит в сторе сессии: его же
* сбрасывает «Опасная зона» после выхода с сервера.
*/
export default function ServersSettingsPage() {
export function ServersSettingsPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const params = useParams<{ guildId?: string }>();
const routeGuildId = params.guildId;
const queryClient = useQueryClient();
const currentUser = useCurrentUser();
const sessionGuilds = useSessionStore((state) => state.guilds);
const upsertGuild = useSessionStore((state) => state.upsertGuild);
const selectedGuildId = useSessionStore((state) => state.settingsGuildId);
const selectSettingsGuild = useSessionStore((state) => state.selectSettingsGuild);
const myGuilds = useQuery({
@@ -58,25 +64,27 @@ export default function ServersSettingsPage() {
() => (sessionGuilds.length > 0 ? sessionGuilds : (myGuilds.data ?? [])),
[sessionGuilds, myGuilds.data],
);
const active = useMemo(
() => (routeGuildId === undefined ? null : (guilds.find((g) => g.id === routeGuildId) ?? null)),
[guilds, routeGuildId],
);
const activeId = active?.id ?? null;
// URL — источник правды; стор запоминает последний открытый сервер.
const active = useMemo(
() => guilds.find((guild) => guild.id === selectedGuildId) ?? null,
[guilds, selectedGuildId],
);
// Первый доступный сервер выбираем сами: список только что загрузился.
useEffect(() => {
if (activeId !== null) {
selectSettingsGuild(activeId);
if (active !== null || guilds.length === 0) {
return;
}
if (routeGuildId === undefined && guilds.length > 0) {
const first = guilds[0];
if (first !== undefined) {
void navigate(`/settings/servers/${first.id}`, { replace: true });
}
const first = guilds[0];
if (first !== undefined) {
selectSettingsGuild(first.id);
}
}, [activeId, routeGuildId, guilds, navigate, selectSettingsGuild]);
}, [active, guilds, selectSettingsGuild]);
const activeId = active?.id ?? null;
const [pickerOpen, setPickerOpen] = useState(false);
const pickerRef = useRef<HTMLButtonElement | null>(null);
const detail = useQuery({
queryKey: guildQueryKey(activeId ?? ''),
@@ -92,6 +100,35 @@ export default function ServersSettingsPage() {
retry: 0,
});
// Прогресс массового применения ника: `done` растёт после каждого сервера.
const [progress, setProgress] = useState<{ done: number; total: number } | null>(null);
const [applyResult, setApplyResult] = useState<{ failed: number; total: number } | null>(null);
const userId = currentUser.data?.id;
const applyAll = useMutation({
mutationFn: async (nickname: string): Promise<{ failed: number; total: number }> => {
const list = guilds;
let failed = 0;
setApplyResult(null);
setProgress({ done: 0, total: list.length });
// Последовательно, а не параллельно: серверные лимиты и понятный прогресс.
for (const [index, guild] of list.entries()) {
try {
await updateGuildMember(guild.id, userId ?? '', { nickname });
useSessionStore.getState().setMyNickname(guild.id, nickname);
} catch {
failed += 1;
}
void queryClient.invalidateQueries({ queryKey: guildMembersQueryKey(guild.id) });
setProgress({ done: index + 1, total: list.length });
}
return { failed, total: list.length };
},
onSuccess: (result) => setApplyResult(result),
onSettled: () => setProgress(null),
});
const guild = detail.data;
const sessionGuild = sessionGuilds.find((item) => item.id === activeId) ?? null;
const myPermissions = guild?.my_permissions ?? active?.my_permissions ?? [];
@@ -102,76 +139,97 @@ export default function ServersSettingsPage() {
if (fromSnapshot !== undefined && fromSnapshot !== '') {
return fromSnapshot;
}
const mine = members.data?.find((member) => member.user_id === currentUser.data?.id);
const mine = members.data?.find((member) => member.user_id === userId);
return mine?.nickname ?? '';
}, [sessionGuild, members.data, currentUser.data?.id]);
}, [sessionGuild, members.data, userId]);
const onApplyAll = useCallback(
(nickname: string) => {
applyAll.reset();
applyAll.mutate(nickname);
},
[applyAll],
);
if (guilds.length === 0) {
return myGuilds.isPending ? (
<Card>
<SkeletonLines rows={4} />
</Card>
) : (
<SettingsSection
title={t('settings.servers.emptyTitle')}
description={t('settings.servers.emptyHint')}
>
<p className="mt-4 text-sm text-fg-muted">{t('settings.servers.empty')}</p>
</SettingsSection>
);
}
return (
<div className="flex flex-col gap-5">
<Card className="p-3">
<h2 className="px-1 text-xs font-semibold uppercase tracking-wide text-fg-muted">
{t('settings.servers.pickerLabel')}
</h2>
{guilds.length === 0 ? (
myGuilds.isPending ? (
<SkeletonLines className="mt-3" rows={1} />
) : (
<p className="mt-2 px-1 text-sm text-fg-muted">{t('settings.servers.empty')}</p>
)
) : (
<nav
aria-label={t('settings.servers.pickerLabel')}
className="gl-stagger mt-2 flex flex-wrap gap-1.5"
data-testid="guild-picker"
>
{guilds.map((guildItem) => {
const selected = guildItem.id === activeId;
return (
<NavLink
key={guildItem.id}
to={`/settings/servers/${guildItem.id}`}
aria-current={selected ? 'page' : undefined}
aria-label={t('settings.servers.select', { name: guildItem.name })}
className={`flex max-w-56 items-center gap-2 rounded-[var(--radius-md)] border px-2 py-1.5 text-sm transition-colors duration-[var(--duration-fast)] ${
selected
? 'border-accent bg-surface-3 text-fg'
: 'border-border/60 bg-surface-1 text-fg-muted hover:bg-surface-2 hover:text-fg'
}`}
>
<Avatar
name={guildItem.name}
seed={guildItem.id}
fileId={guildItem.icon_file_id}
size="sm"
shape="square"
/>
<span className="truncate">{guildItem.name}</span>
</NavLink>
);
})}
</nav>
)}
</Card>
<div className="relative">
<button
ref={pickerRef}
type="button"
aria-haspopup="menu"
aria-expanded={pickerOpen}
aria-label={t('settings.servers.selectLabel')}
onClick={() => setPickerOpen((value) => !value)}
className="flex w-full items-center gap-2 rounded-[var(--radius-md)] border border-border/60 bg-surface-1 px-3 py-2 text-left text-sm transition-colors duration-[var(--duration-fast)] hover:bg-surface-2"
>
{active === null ? null : (
<Avatar
name={active.name}
seed={active.id}
fileId={active.icon_file_id}
size="sm"
shape="square"
/>
)}
<span className="min-w-0 flex-1 truncate font-medium">
{active?.name ?? t('settings.servers.selectLabel')}
</span>
<span aria-hidden="true" className="text-fg-muted">
▾
</span>
</button>
{guilds.length === 0 ? (
myGuilds.isPending ? (
<Card>
<SkeletonLines rows={4} />
</Card>
) : (
<SettingsSection
title={t('settings.servers.emptyTitle')}
description={t('settings.servers.emptyHint')}
>
<div className="mt-4">
<NavLink className="text-sm text-accent underline" to="/app">
{t('settings.back')}
</NavLink>
</div>
</SettingsSection>
)
) : activeId === null ? (
// Сервер из URL не найден среди доступных пользователю.
<Menu
open={pickerOpen}
label={t('settings.servers.pickerLabel')}
anchorRef={pickerRef}
placement="bottom"
onClose={() => setPickerOpen(false)}
>
<MenuGroupLabel>{t('settings.servers.pickerLabel')}</MenuGroupLabel>
{guilds.map((item) => (
<MenuItem
key={item.id}
role="menuitemradio"
selected={item.id === activeId}
alwaysShowIcon
icon={
<Avatar
name={item.name}
seed={item.id}
fileId={item.icon_file_id}
size="sm"
shape="square"
/>
}
onClick={() => {
selectSettingsGuild(item.id);
setPickerOpen(false);
}}
>
{item.name}
</MenuItem>
))}
</Menu>
</div>
{activeId === null ? (
<Card>
<p className="text-sm text-fg-muted">{t('settings.servers.notFound')}</p>
</Card>
@@ -185,13 +243,19 @@ export default function ServersSettingsPage() {
</Card>
) : (
<>
<GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} />
<GuildNicknameSection
guildId={guild.id}
userId={currentUser.data?.id}
userId={userId}
currentNickname={currentNickname}
members={members.data}
applyAll={{
pending: applyAll.isPending,
progress,
result: applyResult,
run: onApplyAll,
}}
/>
<GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} />
<GuildMembersSection
members={members.data}
isPending={members.isPending}
@@ -203,7 +267,7 @@ export default function ServersSettingsPage() {
key={`${guild.id}-danger`}
guildId={guild.id}
guildName={guild.name}
isOwner={currentUser.data?.id !== undefined && currentUser.data.id === guild.owner_id}
isOwner={userId !== undefined && userId === guild.owner_id}
isInstanceAdmin={currentUser.data?.is_instance_admin === true}
/>
</>
@@ -211,3 +275,5 @@ export default function ServersSettingsPage() {
</div>
);
}
export default ServersSettingsPage;
+15 -26
View File
@@ -1,35 +1,24 @@
import { useTranslation } from 'react-i18next';
import { NavLink, Outlet, useLocation } from 'react-router';
import { useEffect, useMemo } from 'react';
import { Navigate, useLocation } from 'react-router';
import { SettingsSwitcher } from '@/pages/settings/SettingsSwitcher';
import { SettingsSuspense } from '@/pages/settings/SettingsSection';
import { parseSettingsPath } from '@/pages/settings/settingsRoute';
import { useUiStore } from '@/stores/ui';
/**
* Каркас настроек: две соседние кнопки-раздела («Настройки сервера» и
* «Мои серверы») и содержимое активного раздела. Переключение разделов —
* обычная навигация react-router, без перезагрузки страницы.
* Старые страницы настроек (`/settings/*`) больше не отдельные экраны:
* настройки открываются модальным окном поверх приложения (см. `SettingsModal`).
*
* Маршрут остаётся, чтобы не ломать закладки: определяем нужный пункт по пути,
* открываем окно и заменяем адрес на `/app`.
*/
export default function SettingsLayout() {
const { t } = useTranslation();
const location = useLocation();
const openSettings = useUiStore((state) => state.openSettings);
const target = useMemo(() => parseSettingsPath(location.pathname), [location.pathname]);
return (
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col gap-5 px-4 py-8">
<header className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-semibold tracking-tight">{t('settings.title')}</h1>
<NavLink className="text-sm text-accent underline" to="/app">
{t('settings.back')}
</NavLink>
</header>
useEffect(() => {
openSettings(target);
}, [openSettings, target]);
<SettingsSwitcher />
{/* Ключ по пути: при смене вкладки/раздела контент появляется с анимацией. */}
<div key={location.pathname} className="gl-page flex flex-col gap-5">
<SettingsSuspense>
<Outlet />
</SettingsSuspense>
</div>
</div>
);
return <Navigate to="/app" replace />;
}
+131
View File
@@ -0,0 +1,131 @@
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { useCurrentUser } from '@/lib/hooks';
import { useUiStore, type SettingsSection } from '@/stores/ui';
import AppearanceSettingsPage from '@/pages/settings/AppearanceSettingsPage';
import InstanceSettingsPage from '@/pages/settings/InstanceSettingsPage';
import { LanguageRegionSection } from '@/pages/settings/LanguageRegionSection';
import { NotificationsSettingsSection } from '@/pages/settings/NotificationsSettingsSection';
import ProfileSettingsPage from '@/pages/settings/ProfileSettingsPage';
import SecuritySettingsPage from '@/pages/settings/SecuritySettingsPage';
import { ServersSettingsPage } from '@/pages/settings/ServersSettingsPage';
import { VoiceSettingsSection } from '@/pages/settings/VoiceSettingsSection';
/** Вкладки пункта «Профиль»: глобальный профиль и профиль на сервере. */
function ProfileTabs() {
const { t } = useTranslation();
const tab = useUiStore((state) => state.settingsProfileTab);
const setTab = useUiStore((state) => state.setSettingsProfileTab);
const tabs = [
{ id: 'main' as const, label: t('settings.profile.tabs.main') },
{ id: 'server' as const, label: t('settings.profile.tabs.server') },
];
return (
<div className="flex flex-col gap-5">
<div
role="tablist"
aria-label={t('settings.profile.tabsLabel')}
className="flex gap-1 border-b border-border/40"
>
{tabs.map((item) => (
<button
key={item.id}
type="button"
role="tab"
aria-selected={tab === item.id}
onClick={() => setTab(item.id)}
className={`-mb-px rounded-t-[var(--radius-sm)] border-b-2 px-3 py-1.5 text-sm transition-colors duration-[var(--duration-fast)] ${
tab === item.id
? 'border-accent text-fg'
: 'border-transparent text-fg-muted hover:text-fg'
}`}
>
{item.label}
</button>
))}
</div>
{tab === 'main' ? <ProfileSettingsPage /> : <ServersSettingsPage />}
</div>
);
}
/**
* Модальное окно настроек: слева вертикальный список пунктов, справа
* содержимое выбранного. Открывается из панели пользователя (шестерёнка) и из
* меню пользователя, а также при переходе на старые адреса `/settings/*`.
*
* Окно живёт в портале: панель пользователя — узкая колонка, и портал снимает
* вопросы к её `overflow` и анимационным трансформациям.
*/
export function SettingsModal() {
const { t } = useTranslation();
const open = useUiStore((state) => state.settingsOpen);
const section = useUiStore((state) => state.settingsSection);
const setSection = useUiStore((state) => state.setSettingsSection);
const closeSettings = useUiStore((state) => state.closeSettings);
const currentUser = useCurrentUser();
const isAdmin = currentUser.data?.is_instance_admin === true;
const items: { id: SettingsSection; label: string }[] = [
{ id: 'profile', label: t('settings.tabs.profile') },
{ id: 'security', label: t('settings.tabs.security') },
{ id: 'appearance', label: t('settings.tabs.appearance') },
{ id: 'notifications', label: t('settings.tabs.notifications') },
{ id: 'voice', label: t('settings.tabs.voice') },
{ id: 'language', label: t('settings.tabs.language') },
...(isAdmin ? [{ id: 'instance' as const, label: t('settings.tabs.instance') }] : []),
];
return (
<Modal
open={open}
title={t('settings.title')}
size="xl"
portal
panelClassName="flex max-h-[85vh] flex-col"
bodyClassName="flex min-h-0 flex-1 gap-4"
onClose={closeSettings}
>
<nav
aria-label={t('settings.navLabel')}
className="flex w-40 shrink-0 flex-col gap-0.5 overflow-y-auto border-r border-border/40 pr-2"
>
{items.map((item) => (
<button
key={item.id}
type="button"
aria-current={section === item.id ? 'true' : undefined}
onClick={() => setSection(item.id)}
className={`rounded-[var(--radius-sm)] px-2 py-1.5 text-left text-sm transition-colors duration-[var(--duration-fast)] ${
section === item.id
? 'bg-surface-3 text-fg'
: 'text-fg-muted hover:bg-surface-2 hover:text-fg'
}`}
>
{item.label}
</button>
))}
</nav>
{/* Ключ по пункту: при переключении прокрутка и локальное состояние
секции (например, шаг настройки 2FA) сбрасываются. */}
<div
key={section}
className="min-h-0 min-w-0 flex-1 overflow-y-auto pr-1"
data-testid="settings-content"
>
{section === 'security' ? <SecuritySettingsPage /> : null}
{section === 'appearance' ? <AppearanceSettingsPage /> : null}
{section === 'notifications' ? <NotificationsSettingsSection /> : null}
{section === 'voice' ? <VoiceSettingsSection /> : null}
{section === 'language' ? <LanguageRegionSection /> : null}
{section === 'instance' ? <InstanceSettingsPage /> : null}
{section === 'profile' ? <ProfileTabs /> : null}
</div>
</Modal>
);
}
@@ -0,0 +1,64 @@
import { useTranslation } from 'react-i18next';
import { VolumeSlider, VoiceDeviceList } from '@/components/voice/VoiceControls';
import { useDeviceChoice } from '@/lib/useMediaDevices';
import { useVoiceStore } from '@/stores/voice';
import { SettingsSection } from '@/pages/settings/SettingsSection';
/**
* «Аудио-видео»: выбор устройств ввода/вывода и громкости.
*
* Значения пока только сохраняются локально (zustand + localStorage):
* голосовые комнаты появятся в Фазе 3, тогда их начнёт использовать RTC.
*/
export function VoiceSettingsSection() {
const { t } = useTranslation();
const mic = useDeviceChoice('audioinput');
const camera = useDeviceChoice('videoinput');
const output = useDeviceChoice('audiooutput');
const micVolume = useVoiceStore((state) => state.micVolume);
const outputVolume = useVoiceStore((state) => state.outputVolume);
const setMicVolume = useVoiceStore((state) => state.setMicVolume);
const setOutputVolume = useVoiceStore((state) => state.setOutputVolume);
return (
<SettingsSection
title={t('settings.voice.title')}
description={t('settings.voice.description')}
>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<VoiceDeviceList
kind="audioinput"
label={t('settings.voice.input')}
value={mic.value}
onChange={mic.select}
/>
<VoiceDeviceList
kind="videoinput"
label={t('settings.voice.camera')}
value={camera.value}
onChange={camera.select}
/>
<VoiceDeviceList
kind="audiooutput"
label={t('settings.voice.output')}
value={output.value}
onChange={output.select}
/>
<div className="flex flex-col justify-end gap-2">
<VolumeSlider
label={t('settings.voice.micVolume')}
value={micVolume}
onChange={setMicVolume}
/>
<VolumeSlider
label={t('settings.voice.outputVolume')}
value={outputVolume}
onChange={setOutputVolume}
/>
</div>
</div>
<p className="mt-4 text-xs text-fg-muted">{t('settings.voice.stub')}</p>
</SettingsSection>
);
}
+42
View File
@@ -0,0 +1,42 @@
import type { SettingsTarget } from '@/stores/ui';
/**
* Разбор старых адресов настроек `/settings/*`.
*
* Раньше это были отдельные страницы, теперь они ведут в приложение с
* открытым модальным окном настроек: закладки и внешние ссылки продолжают
* работать, а сам раздел живёт в одном месте.
*/
export function parseSettingsPath(pathname: string): SettingsTarget {
// `/settings` → ['settings'], `/settings/servers/g-1` → ['settings','servers','g-1']
const segments = pathname.split('/').filter((segment) => segment !== '');
const rest = segments.slice(1);
if (rest[0] === 'servers') {
return {
section: 'profile',
tab: 'server',
guildId: rest[1] === undefined ? null : decodeURIComponent(rest[1]),
};
}
// `/settings/account/security` и старый `/settings/security` — одно и то же.
const key = rest[0] === 'account' ? (rest[1] ?? 'profile') : (rest[0] ?? 'profile');
switch (key) {
case 'security':
return { section: 'security' };
case 'appearance':
return { section: 'appearance' };
case 'notifications':
return { section: 'notifications' };
case 'voice':
return { section: 'voice' };
case 'language':
return { section: 'language' };
case 'instance':
return { section: 'instance' };
default:
return { section: 'profile', tab: 'main' };
}
}