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