feat(web): профиль с аватаром, панель в углу, настройки сервера, скрытые ID
Правки пользователя: - в «Профиль → Основной» сверху квадратный аватар слева, справа от него настройки имени, «О себе», состояния и эмодзи во всю высоту; смена аватара — клик по нему, форматы (JPEG, PNG, WebP, GIF до 8 МБ) — в подсказке; - убраны дубли: часовой пояс только в «Язык и регион», статус — только в мини-профиле панели; - панель пользователя переехала в нижний левый угол под сайдбар, подсказка о выходе из сервера — на её прежнее место; панель ужата под 240 px; - шестерёнка сервера открывает новый пункт настроек «Сервер» (список серверов, общее, участники, роли, опасная зона), а «Профиль → Сервер» оставлен только для ника и «Применить ко всем серверам»; - окно настроек получило статический размер 70% рабочей области со скроллом; - ID пользователей скрыты везде, кроме раздела «Инстанс» у администратора; - список друзей свёрнут под кнопкой «Друзья (N)» и раскрывается снизу вверх; - 249 тестов Vitest (было 239), check/lint/test/build зелёные.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useId, useState } from 'react';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ import { SkeletonLines } from '@/components/ui/Skeleton';
|
|||||||
import { Button } from '@/components/ui/primitives';
|
import { Button } from '@/components/ui/primitives';
|
||||||
import { useFriendsStore } from '@/stores/friends';
|
import { useFriendsStore } from '@/stores/friends';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
import { useUiStore } from '@/stores/ui';
|
||||||
|
|
||||||
/** Имя для заявки: список связей не хранит отдельного «ника». */
|
/** Имя для заявки: список связей не хранит отдельного «ника». */
|
||||||
function displayName(user: RelationshipUser): string {
|
function displayName(user: RelationshipUser): string {
|
||||||
@@ -33,6 +34,9 @@ export function FriendsView() {
|
|||||||
const load = useFriendsStore((state) => state.load);
|
const load = useFriendsStore((state) => state.load);
|
||||||
const reload = useFriendsStore((state) => state.reload);
|
const reload = useFriendsStore((state) => state.reload);
|
||||||
const dmChannels = useSessionStore((state) => state.dmChannels);
|
const dmChannels = useSessionStore((state) => state.dmChannels);
|
||||||
|
const listOpen = useUiStore((state) => state.friendsListOpen);
|
||||||
|
const toggleFriendsList = useUiStore((state) => state.toggleFriendsList);
|
||||||
|
const listId = useId();
|
||||||
|
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
|
||||||
@@ -176,22 +180,45 @@ export function FriendsView() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<section aria-label={t('friends.list.label')} className="mt-4">
|
<section aria-label={t('friends.list.label')} className="mt-4">
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-fg-muted">
|
{/*
|
||||||
{t('friends.friendsSection')}
|
Список свёрнут по умолчанию: раскрывается снизу вверх, состояние
|
||||||
</h3>
|
переживает перезагрузку страницы (zustand + localStorage).
|
||||||
{friends.length === 0 ? (
|
*/}
|
||||||
<p className="px-2 py-1 text-sm text-fg-muted">{t('friends.friendsEmpty')}</p>
|
<button
|
||||||
) : (
|
type="button"
|
||||||
<ul
|
aria-expanded={listOpen}
|
||||||
className="gl-stagger mt-1 flex flex-col gap-0.5"
|
aria-controls={listId}
|
||||||
data-testid="friends-list"
|
data-testid="friends-list-toggle"
|
||||||
aria-label={t('friends.list.label')}
|
disabled={friends.length === 0}
|
||||||
>
|
className="flex items-center gap-1 rounded-[var(--radius-sm)] px-2 py-1 text-xs font-semibold uppercase tracking-wide text-fg-muted transition-colors duration-[var(--duration-fast)] hover:text-fg disabled:cursor-default disabled:hover:text-fg-muted"
|
||||||
{friends.map((friend) => (
|
onClick={toggleFriendsList}
|
||||||
<FriendRow key={friend.user_id} friend={friend} />
|
>
|
||||||
))}
|
<span aria-hidden="true" className={listOpen ? 'gl-caret-open' : 'gl-caret'}>
|
||||||
</ul>
|
▾
|
||||||
)}
|
</span>
|
||||||
|
{t('friends.list.toggle', { count: friends.length })}
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
id={listId}
|
||||||
|
className="gl-reveal-up"
|
||||||
|
data-state={listOpen ? 'open' : 'closed'}
|
||||||
|
data-testid="friends-list-region"
|
||||||
|
aria-hidden={listOpen ? undefined : true}
|
||||||
|
>
|
||||||
|
{!listOpen ? null : friends.length === 0 ? (
|
||||||
|
<p className="px-2 py-1 text-sm text-fg-muted">{t('friends.friendsEmpty')}</p>
|
||||||
|
) : (
|
||||||
|
<ul
|
||||||
|
className="gl-stagger mt-1 flex flex-col gap-0.5"
|
||||||
|
data-testid="friends-list"
|
||||||
|
aria-label={t('friends.list.label')}
|
||||||
|
>
|
||||||
|
{friends.map((friend) => (
|
||||||
|
<FriendRow key={friend.user_id} friend={friend} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ interface AvatarProps {
|
|||||||
/** Стабильный id для оттенка заглушки. */
|
/** Стабильный id для оттенка заглушки. */
|
||||||
seed: string;
|
seed: string;
|
||||||
fileId?: string | undefined;
|
fileId?: string | undefined;
|
||||||
size?: 'sm' | 'md' | 'lg';
|
/** Локальный предпросмотр (свежевыбранный файл) вместо `fileId`. */
|
||||||
|
preview?: string | undefined;
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
/** Квадратная заглушка для серверов, круглая — для пользователей. */
|
/** Квадратная заглушка для серверов, круглая — для пользователей. */
|
||||||
shape?: 'circle' | 'square';
|
shape?: 'circle' | 'square';
|
||||||
}
|
}
|
||||||
@@ -14,13 +16,25 @@ const sizes = {
|
|||||||
sm: 'h-7 w-7 text-[11px]',
|
sm: 'h-7 w-7 text-[11px]',
|
||||||
md: 'h-9 w-9 text-xs',
|
md: 'h-9 w-9 text-xs',
|
||||||
lg: 'h-14 w-14 text-lg',
|
lg: 'h-14 w-14 text-lg',
|
||||||
|
/** Крупный аватар в настройках профиля: занимает родителя целиком. */
|
||||||
|
xl: 'h-full w-full text-4xl',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Аватар: картинка файлового сервиса либо инициалы на цветной заглушке. */
|
/**
|
||||||
export function Avatar({ name, seed, fileId, size = 'md', shape = 'circle' }: AvatarProps) {
|
* Аватар: картинка файлового сервиса либо инициалы на цветной заглушке.
|
||||||
const src = avatarUrl(fileId);
|
* `preview` перекрывает `fileId` — так выбранный файл видно до ответа сервера.
|
||||||
|
*/
|
||||||
|
export function Avatar({
|
||||||
|
name,
|
||||||
|
seed,
|
||||||
|
fileId,
|
||||||
|
preview,
|
||||||
|
size = 'md',
|
||||||
|
shape = 'circle',
|
||||||
|
}: AvatarProps) {
|
||||||
|
const src = preview ?? avatarUrl(fileId);
|
||||||
const hue = hueFromId(seed);
|
const hue = hueFromId(seed);
|
||||||
const radius = shape === 'circle' ? 'rounded-full' : 'rounded-[var(--radius-md)]';
|
const radius = shape === 'circle' ? 'rounded-full' : 'rounded-[var(--radius-lg)]';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -14,6 +14,12 @@ interface ModalProps {
|
|||||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
/** Дополнительные классы панели (например, ограничение высоты). */
|
/** Дополнительные классы панели (например, ограничение высоты). */
|
||||||
panelClassName?: string;
|
panelClassName?: string;
|
||||||
|
/**
|
||||||
|
* Инлайновые размеры панели. Нужны там, где размер считается от окна
|
||||||
|
* браузера (окно настроек занимает фиксированные 70% площади) и не должен
|
||||||
|
* зависеть от содержимого.
|
||||||
|
*/
|
||||||
|
panelStyle?: CSSProperties;
|
||||||
/** Дополнительные классы области содержимого (прокрутка длинных форм). */
|
/** Дополнительные классы области содержимого (прокрутка длинных форм). */
|
||||||
bodyClassName?: string;
|
bodyClassName?: string;
|
||||||
/**
|
/**
|
||||||
@@ -54,6 +60,7 @@ export function Modal({
|
|||||||
footer,
|
footer,
|
||||||
size = 'md',
|
size = 'md',
|
||||||
panelClassName = '',
|
panelClassName = '',
|
||||||
|
panelStyle,
|
||||||
bodyClassName = '',
|
bodyClassName = '',
|
||||||
portal = false,
|
portal = false,
|
||||||
}: ModalProps) {
|
}: ModalProps) {
|
||||||
@@ -144,6 +151,7 @@ export function Modal({
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
|
style={panelStyle}
|
||||||
className={`w-full ${sizes[size]} rounded-[var(--radius-lg)] border border-border/60 bg-surface-1 shadow-[var(--shadow-3)] ${
|
className={`w-full ${sizes[size]} rounded-[var(--radius-lg)] border border-border/60 bg-surface-1 shadow-[var(--shadow-3)] ${
|
||||||
closing ? 'gl-pop-out' : 'gl-pop-in'
|
closing ? 'gl-pop-out' : 'gl-pop-in'
|
||||||
} ${panelClassName === '' ? 'p-5' : panelClassName}`}
|
} ${panelClassName === '' ? 'p-5' : panelClassName}`}
|
||||||
|
|||||||
@@ -266,7 +266,8 @@
|
|||||||
"more": "Actions: {{name}}",
|
"more": "Actions: {{name}}",
|
||||||
"menu": "Actions for {{name}}",
|
"menu": "Actions for {{name}}",
|
||||||
"remove": "Remove friend",
|
"remove": "Remove friend",
|
||||||
"block": "Block"
|
"block": "Block",
|
||||||
|
"toggle": "Friends ({{count}})"
|
||||||
},
|
},
|
||||||
"lastSeen": {
|
"lastSeen": {
|
||||||
"label": "Last seen",
|
"label": "Last seen",
|
||||||
@@ -406,7 +407,6 @@
|
|||||||
"logout": "Sign out",
|
"logout": "Sign out",
|
||||||
"loggingOut": "Signing out…",
|
"loggingOut": "Signing out…",
|
||||||
"statusLabel": "Status: {{status}}",
|
"statusLabel": "Status: {{status}}",
|
||||||
"copyId": "Copy ID",
|
|
||||||
"status": {
|
"status": {
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
"idle": "Idle",
|
"idle": "Idle",
|
||||||
@@ -463,11 +463,14 @@
|
|||||||
"security": "Security",
|
"security": "Security",
|
||||||
"appearance": "Appearance",
|
"appearance": "Appearance",
|
||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
|
"server": "Server",
|
||||||
"voice": "Audio & video",
|
"voice": "Audio & video",
|
||||||
"language": "Language & region",
|
"language": "Language & region",
|
||||||
"instance": "Instance"
|
"instance": "Instance"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
|
"avatar.change": "Change avatar",
|
||||||
|
"avatar.uploading": "Uploading…",
|
||||||
"title": "Profile",
|
"title": "Profile",
|
||||||
"description": "How other members see you.",
|
"description": "How other members see you.",
|
||||||
"displayName": "Display name",
|
"displayName": "Display name",
|
||||||
@@ -477,11 +480,8 @@
|
|||||||
"customStatusPlaceholder": "For example, “Writing code”",
|
"customStatusPlaceholder": "For example, “Writing code”",
|
||||||
"customStatusEmoji": "Status emoji",
|
"customStatusEmoji": "Status emoji",
|
||||||
"customStatusEmojiPlaceholder": "🚀",
|
"customStatusEmojiPlaceholder": "🚀",
|
||||||
"status": "Presence status",
|
|
||||||
"locale": "Interface language",
|
|
||||||
"saved": "Profile saved.",
|
"saved": "Profile saved.",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
"userId": "User ID",
|
|
||||||
"badges": "Badges",
|
"badges": "Badges",
|
||||||
"noBadges": "none",
|
"noBadges": "none",
|
||||||
"tabsLabel": "Profile tabs",
|
"tabsLabel": "Profile tabs",
|
||||||
@@ -489,18 +489,15 @@
|
|||||||
"main": "General",
|
"main": "General",
|
||||||
"server": "Server"
|
"server": "Server"
|
||||||
},
|
},
|
||||||
"timezone": "Time zone",
|
|
||||||
"timezoneHint": "Used to show message times.",
|
|
||||||
"timezoneDefault": "default (MSK)",
|
"timezoneDefault": "default (MSK)",
|
||||||
"avatar": {
|
"avatar": {
|
||||||
"title": "Avatar",
|
"change": "Change avatar",
|
||||||
"hint": "Everyone can see this picture.",
|
"choose": "Choose avatar file",
|
||||||
"preview": "Avatar preview",
|
|
||||||
"choose": "Choose an avatar file",
|
|
||||||
"upload": "Upload avatar",
|
|
||||||
"remove": "Remove avatar",
|
"remove": "Remove avatar",
|
||||||
"limit": "Up to {{limit}}; PNG, JPEG, WebP or GIF.",
|
"formats": "JPEG, PNG, WebP, GIF",
|
||||||
"tooLarge": "The file is larger than {{limit}} — pick a smaller image."
|
"limit": "{{formats}} up to {{limit}}",
|
||||||
|
"tooLarge": "The file is larger than {{limit}} — pick a smaller image.",
|
||||||
|
"uploading": "Uploading…"
|
||||||
},
|
},
|
||||||
"decorations": {
|
"decorations": {
|
||||||
"title": "Decorations",
|
"title": "Decorations",
|
||||||
@@ -568,13 +565,15 @@
|
|||||||
"settingsEmpty": "No settings configured.",
|
"settingsEmpty": "No settings configured.",
|
||||||
"guilds": "Servers ({{count}})",
|
"guilds": "Servers ({{count}})",
|
||||||
"guildsEmpty": "No servers.",
|
"guildsEmpty": "No servers.",
|
||||||
|
"userId": "User ID",
|
||||||
"users": "Users ({{count}})",
|
"users": "Users ({{count}})",
|
||||||
"usersEmpty": "No users.",
|
"usersEmpty": "No users.",
|
||||||
"audit": "Audit log (last {{count}})",
|
"audit": "Audit log (last {{count}})",
|
||||||
"auditEmpty": "No entries.",
|
"auditEmpty": "No entries.",
|
||||||
"memberCount": "{{count}} members",
|
"memberCount": "{{count}} members",
|
||||||
"adminBadge": "admin",
|
"adminBadge": "admin",
|
||||||
"mainBadge": "main"
|
"mainBadge": "main",
|
||||||
|
"usersIdHint": "User IDs are visible to the instance administrator only."
|
||||||
},
|
},
|
||||||
"servers": {
|
"servers": {
|
||||||
"pickerLabel": "My servers",
|
"pickerLabel": "My servers",
|
||||||
|
|||||||
@@ -266,7 +266,8 @@
|
|||||||
"more": "Действия: {{name}}",
|
"more": "Действия: {{name}}",
|
||||||
"menu": "Действия с {{name}}",
|
"menu": "Действия с {{name}}",
|
||||||
"remove": "Удалить из друзей",
|
"remove": "Удалить из друзей",
|
||||||
"block": "Заблокировать"
|
"block": "Заблокировать",
|
||||||
|
"toggle": "Друзья ({{count}})"
|
||||||
},
|
},
|
||||||
"lastSeen": {
|
"lastSeen": {
|
||||||
"label": "Последний вход",
|
"label": "Последний вход",
|
||||||
@@ -406,7 +407,6 @@
|
|||||||
"logout": "Выйти",
|
"logout": "Выйти",
|
||||||
"loggingOut": "Выходим…",
|
"loggingOut": "Выходим…",
|
||||||
"statusLabel": "Статус: {{status}}",
|
"statusLabel": "Статус: {{status}}",
|
||||||
"copyId": "Скопировать ID",
|
|
||||||
"status": {
|
"status": {
|
||||||
"online": "В сети",
|
"online": "В сети",
|
||||||
"idle": "Неактивен",
|
"idle": "Неактивен",
|
||||||
@@ -463,11 +463,14 @@
|
|||||||
"security": "Безопасность",
|
"security": "Безопасность",
|
||||||
"appearance": "Внешний вид",
|
"appearance": "Внешний вид",
|
||||||
"notifications": "Уведомления",
|
"notifications": "Уведомления",
|
||||||
|
"server": "Сервер",
|
||||||
"voice": "Аудио-видео",
|
"voice": "Аудио-видео",
|
||||||
"language": "Язык и регион",
|
"language": "Язык и регион",
|
||||||
"instance": "Инстанс"
|
"instance": "Инстанс"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
|
"avatar.change": "Сменить аватар",
|
||||||
|
"avatar.uploading": "Загружаем…",
|
||||||
"title": "Профиль",
|
"title": "Профиль",
|
||||||
"description": "Как вас видят другие участники.",
|
"description": "Как вас видят другие участники.",
|
||||||
"displayName": "Отображаемое имя",
|
"displayName": "Отображаемое имя",
|
||||||
@@ -477,11 +480,8 @@
|
|||||||
"customStatusPlaceholder": "Например, «Пишу код»",
|
"customStatusPlaceholder": "Например, «Пишу код»",
|
||||||
"customStatusEmoji": "Эмодзи состояния",
|
"customStatusEmoji": "Эмодзи состояния",
|
||||||
"customStatusEmojiPlaceholder": "🚀",
|
"customStatusEmojiPlaceholder": "🚀",
|
||||||
"status": "Статус присутствия",
|
|
||||||
"locale": "Язык интерфейса",
|
|
||||||
"saved": "Профиль сохранён.",
|
"saved": "Профиль сохранён.",
|
||||||
"username": "Имя пользователя",
|
"username": "Имя пользователя",
|
||||||
"userId": "ID пользователя",
|
|
||||||
"badges": "Значки",
|
"badges": "Значки",
|
||||||
"noBadges": "нет",
|
"noBadges": "нет",
|
||||||
"tabsLabel": "Вкладки профиля",
|
"tabsLabel": "Вкладки профиля",
|
||||||
@@ -489,18 +489,15 @@
|
|||||||
"main": "Основной",
|
"main": "Основной",
|
||||||
"server": "Сервер"
|
"server": "Сервер"
|
||||||
},
|
},
|
||||||
"timezone": "Часовой пояс",
|
|
||||||
"timezoneHint": "По нему показывается время сообщений.",
|
|
||||||
"timezoneDefault": "по умолчанию (МСК)",
|
"timezoneDefault": "по умолчанию (МСК)",
|
||||||
"avatar": {
|
"avatar": {
|
||||||
"title": "Аватар",
|
"change": "Сменить аватар",
|
||||||
"hint": "Картинку видят все участники.",
|
|
||||||
"preview": "Предпросмотр аватара",
|
|
||||||
"choose": "Выбрать файл аватара",
|
"choose": "Выбрать файл аватара",
|
||||||
"upload": "Загрузить аватар",
|
|
||||||
"remove": "Удалить аватар",
|
"remove": "Удалить аватар",
|
||||||
"limit": "До {{limit}}; PNG, JPEG, WebP или GIF.",
|
"formats": "JPEG, PNG, WebP, GIF",
|
||||||
"tooLarge": "Файл больше {{limit}} — выберите картинку поменьше."
|
"limit": "{{formats}} до {{limit}}",
|
||||||
|
"tooLarge": "Файл больше {{limit}} — выберите картинку поменьше.",
|
||||||
|
"uploading": "Загружаем…"
|
||||||
},
|
},
|
||||||
"decorations": {
|
"decorations": {
|
||||||
"title": "Украшения",
|
"title": "Украшения",
|
||||||
@@ -568,13 +565,15 @@
|
|||||||
"settingsEmpty": "Настройки не заданы.",
|
"settingsEmpty": "Настройки не заданы.",
|
||||||
"guilds": "Серверы ({{count}})",
|
"guilds": "Серверы ({{count}})",
|
||||||
"guildsEmpty": "Серверов нет.",
|
"guildsEmpty": "Серверов нет.",
|
||||||
|
"userId": "ID пользователя",
|
||||||
"users": "Пользователи ({{count}})",
|
"users": "Пользователи ({{count}})",
|
||||||
"usersEmpty": "Пользователей нет.",
|
"usersEmpty": "Пользователей нет.",
|
||||||
"audit": "Журнал аудита (последние {{count}})",
|
"audit": "Журнал аудита (последние {{count}})",
|
||||||
"auditEmpty": "Записей нет.",
|
"auditEmpty": "Записей нет.",
|
||||||
"memberCount": "{{count}} участн.",
|
"memberCount": "{{count}} участн.",
|
||||||
"adminBadge": "админ",
|
"adminBadge": "админ",
|
||||||
"mainBadge": "главный"
|
"mainBadge": "главный",
|
||||||
|
"usersIdHint": "ID пользователей видны только администратору инстанса."
|
||||||
},
|
},
|
||||||
"servers": {
|
"servers": {
|
||||||
"pickerLabel": "Мои серверы",
|
"pickerLabel": "Мои серверы",
|
||||||
|
|||||||
@@ -322,6 +322,54 @@ body {
|
|||||||
animation: gl-shimmer 1.4s linear infinite;
|
animation: gl-shimmer 1.4s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Список друзей раскрывается снизу вверх: высота контейнера растёт от нуля
|
||||||
|
* (grid 0fr → 1fr), а содержимое въезжает снизу. Свёрнутый список не занимает
|
||||||
|
* места и не доступен скринридеру (`aria-hidden` ставит компонент).
|
||||||
|
*/
|
||||||
|
@keyframes gl-reveal-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-reveal-up {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
transition: grid-template-rows var(--duration-base) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-reveal-up > * {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-reveal-up[data-state='open'] {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-reveal-up[data-state='open'] > * {
|
||||||
|
animation: gl-reveal-up var(--duration-base) var(--ease-out) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стрелка-раскрывашка: развёрнутое состояние поворачивает её вниз. */
|
||||||
|
.gl-caret {
|
||||||
|
display: inline-block;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
transition: transform var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gl-caret-open {
|
||||||
|
display: inline-block;
|
||||||
|
transform: rotate(0deg);
|
||||||
|
transition: transform var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Доступность: при `prefers-reduced-motion: reduce` анимации становятся
|
* Доступность: при `prefers-reduced-motion: reduce` анимации становятся
|
||||||
* мгновенными. Задержки каскада тоже обнуляем — иначе элементы списка
|
* мгновенными. Задержки каскада тоже обнуляем — иначе элементы списка
|
||||||
|
|||||||
@@ -85,6 +85,19 @@ export function canManageGuildSettings(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Может ли пользователь открыть администрирование сервера: владелец,
|
||||||
|
* ADMINISTRATOR/MANAGE_GUILD или администратор инстанса.
|
||||||
|
*/
|
||||||
|
export function canAdministerGuild(
|
||||||
|
permissions: readonly string[],
|
||||||
|
ownerId: string,
|
||||||
|
userId: string | undefined,
|
||||||
|
isInstanceAdmin = false,
|
||||||
|
): boolean {
|
||||||
|
return isInstanceAdmin || canManageGuildSettings(permissions, ownerId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Может ли пользователь модерировать сообщения (удалять и закреплять чужие).
|
* Может ли пользователь модерировать сообщения (удалять и закреплять чужие).
|
||||||
* Права приходят на уровне сервера, поэтому это приближение: сервер всё равно
|
* Права приходят на уровне сервера, поэтому это приближение: сервер всё равно
|
||||||
|
|||||||
@@ -156,11 +156,21 @@ export default function AppLayout() {
|
|||||||
canCreate={canCreate}
|
canCreate={canCreate}
|
||||||
onCreate={openGuildCreate}
|
onCreate={openGuildCreate}
|
||||||
/>
|
/>
|
||||||
{isFriends ? (
|
{/*
|
||||||
<FriendsSidebar channelId={dmChannelId} />
|
Левая колонка: сайдбар раздела и панель пользователя под ним.
|
||||||
) : (
|
Ширину задаёт колонка, поэтому сайдбары её больше не дублируют.
|
||||||
<ChannelSidebar guildId={guildId} channelId={channelId} />
|
*/}
|
||||||
)}
|
<div
|
||||||
|
data-testid="app-sidebar-column"
|
||||||
|
className="flex w-60 shrink-0 flex-col border-r border-border/40 bg-surface-1"
|
||||||
|
>
|
||||||
|
{isFriends ? (
|
||||||
|
<FriendsSidebar channelId={dmChannelId} />
|
||||||
|
) : (
|
||||||
|
<ChannelSidebar guildId={guildId} channelId={channelId} />
|
||||||
|
)}
|
||||||
|
<UserPanel />
|
||||||
|
</div>
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
{/*
|
{/*
|
||||||
Ключ по пути: смена комнаты/сервера появляется с анимацией.
|
Ключ по пути: смена комнаты/сервера появляется с анимацией.
|
||||||
@@ -169,8 +179,16 @@ export default function AppLayout() {
|
|||||||
*/}
|
*/}
|
||||||
<div key={location.pathname} className="gl-page relative min-h-0 flex-1 overflow-auto">
|
<div key={location.pathname} className="gl-page relative min-h-0 flex-1 overflow-auto">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
{/* Подсказка про выход с сервера — внизу области контента. */}
|
||||||
|
{realGuildId === null ? null : (
|
||||||
|
<p
|
||||||
|
data-testid="guild-leave-hint"
|
||||||
|
className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-surface-1 via-surface-1/80 to-transparent px-4 py-1.5 text-center text-xs text-fg-muted"
|
||||||
|
>
|
||||||
|
{t('channels.leaveHint')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<UserPanel />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { Modal } from '@/components/ui/Modal';
|
|||||||
import { Button } from '@/components/ui/primitives';
|
import { Button } from '@/components/ui/primitives';
|
||||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||||
import { groupChannels } from '@/lib/channels';
|
import { groupChannels } from '@/lib/channels';
|
||||||
import { canManageGuild, canManageGuildSettings } from '@/lib/identity';
|
import { canAdministerGuild, canManageGuild } from '@/lib/identity';
|
||||||
import { useCurrentUser } from '@/lib/hooks';
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
import { useUiStore } from '@/stores/ui';
|
import { useUiStore } from '@/stores/ui';
|
||||||
@@ -127,25 +127,26 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
|
|||||||
[channels],
|
[channels],
|
||||||
);
|
);
|
||||||
const canManage =
|
const canManage =
|
||||||
guild !== null && canManageGuild(guild.my_permissions, guild.owner_id, currentUser.data?.id);
|
guild !== null &&
|
||||||
|
canManageGuild(guild.my_permissions ?? [], guild.owner_id, currentUser.data?.id);
|
||||||
// Шестерёнка настроек сервера — только владельцу и правам управления.
|
// Шестерёнка настроек сервера — только владельцу и правам управления.
|
||||||
const canManageSettings =
|
const canManageSettings =
|
||||||
guild !== null &&
|
guild !== null &&
|
||||||
canManageGuildSettings(guild.my_permissions, guild.owner_id, currentUser.data?.id);
|
canAdministerGuild(
|
||||||
|
guild.my_permissions ?? [],
|
||||||
|
guild.owner_id,
|
||||||
|
currentUser.data?.id,
|
||||||
|
currentUser.data?.is_instance_admin === true,
|
||||||
|
);
|
||||||
// Каналы ещё едут по REST — показываем скелетон вместо «пусто».
|
// Каналы ещё едут по REST — показываем скелетон вместо «пусто».
|
||||||
const channelsLoading = needsChannels && remoteChannels.isPending;
|
const channelsLoading = needsChannels && remoteChannels.isPending;
|
||||||
|
|
||||||
if (guildId === null) {
|
if (guildId === null) {
|
||||||
return (
|
return <aside className="hidden min-h-0 flex-1 bg-surface-1 p-3 md:block" />;
|
||||||
<aside className="hidden w-60 shrink-0 border-r border-border/40 bg-surface-1 p-3 md:block" />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside aria-label={t('channels.title')} className="flex min-h-0 flex-1 flex-col bg-surface-1">
|
||||||
aria-label={t('channels.title')}
|
|
||||||
className="flex w-60 shrink-0 flex-col border-r border-border/40 bg-surface-1"
|
|
||||||
>
|
|
||||||
<header className="flex items-center justify-between gap-2 border-b border-border/40 px-3 py-3">
|
<header className="flex items-center justify-between gap-2 border-b border-border/40 px-3 py-3">
|
||||||
<h1 className="truncate text-sm font-semibold" title={guild?.name}>
|
<h1 className="truncate text-sm font-semibold" title={guild?.name}>
|
||||||
{guild?.name ?? t('channels.notInGuild.title')}
|
{guild?.name ?? t('channels.notInGuild.title')}
|
||||||
@@ -156,8 +157,8 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={t('channels.settings')}
|
aria-label={t('channels.settings')}
|
||||||
title={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"
|
className="rounded-[var(--radius-sm)] px-1 text-base 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 })}
|
onClick={() => openSettings({ section: 'server', guildId })}
|
||||||
>
|
>
|
||||||
⚙
|
⚙
|
||||||
</button>
|
</button>
|
||||||
@@ -217,10 +218,6 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="border-t border-border/40 px-2 py-2">
|
|
||||||
<p className="px-1 text-xs text-fg-muted">{t('channels.leaveHint')}</p>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
title={t('channels.create.title')}
|
title={t('channels.create.title')}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export function FriendsSidebar({ channelId }: { channelId: string | null }) {
|
|||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
aria-label={t('friends.sidebarLabel')}
|
aria-label={t('friends.sidebarLabel')}
|
||||||
className="flex w-60 shrink-0 flex-col border-r border-border/40 bg-surface-1"
|
className="flex min-h-0 flex-1 flex-col bg-surface-1"
|
||||||
>
|
>
|
||||||
<header className="flex items-center gap-2 border-b border-border/40 px-3 py-3">
|
<header className="flex items-center gap-2 border-b border-border/40 px-3 py-3">
|
||||||
<h1 className="truncate text-sm font-semibold">{t('friends.title')}</h1>
|
<h1 className="truncate text-sm font-semibold">{t('friends.title')}</h1>
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ function PanelControl({
|
|||||||
aria-label={menuLabel}
|
aria-label={menuLabel}
|
||||||
title={menuLabel}
|
title={menuLabel}
|
||||||
onClick={onOpenMenu}
|
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"
|
className="rounded-[var(--radius-sm)] px-0.5 text-[8px] leading-none text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
|
||||||
>
|
>
|
||||||
▾
|
▾
|
||||||
</button>
|
</button>
|
||||||
@@ -98,7 +98,7 @@ function PanelControl({
|
|||||||
aria-label={toggleLabel}
|
aria-label={toggleLabel}
|
||||||
title={toggleLabel}
|
title={toggleLabel}
|
||||||
onClick={onToggle}
|
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 ${
|
className={`rounded-[var(--radius-sm)] px-1 py-1 text-base leading-none transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 active:scale-95 ${
|
||||||
enabled ? 'text-fg' : 'text-danger'
|
enabled ? 'text-fg' : 'text-danger'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -110,7 +110,7 @@ function PanelControl({
|
|||||||
label={menuLabel}
|
label={menuLabel}
|
||||||
anchorRef={anchorRef}
|
anchorRef={anchorRef}
|
||||||
align="right"
|
align="right"
|
||||||
className="w-72"
|
className="w-64 max-w-[70vw]"
|
||||||
onClose={onCloseMenu}
|
onClose={onCloseMenu}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -191,14 +191,6 @@ export function UserPanel() {
|
|||||||
? t(`user.status.${status}`)
|
? t(`user.status.${status}`)
|
||||||
: t('user.status.offline');
|
: t('user.status.offline');
|
||||||
|
|
||||||
const onCopyId = useCallback(() => {
|
|
||||||
if (currentUser.data === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void navigator.clipboard?.writeText(currentUser.data.id);
|
|
||||||
setProfileOpen(false);
|
|
||||||
}, [currentUser.data]);
|
|
||||||
|
|
||||||
const toggleProfile = useCallback(() => {
|
const toggleProfile = useCallback(() => {
|
||||||
setOpenControl(null);
|
setOpenControl(null);
|
||||||
setProfileOpen((value) => !value);
|
setProfileOpen((value) => !value);
|
||||||
@@ -241,7 +233,7 @@ export function UserPanel() {
|
|||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<span className="relative shrink-0">
|
<span className="relative shrink-0">
|
||||||
<Avatar name={displayName} seed={seed} fileId={avatarFileId} size="md" />
|
<Avatar name={displayName} seed={seed} fileId={avatarFileId} size="sm" />
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
data-testid="user-status-dot"
|
data-testid="user-status-dot"
|
||||||
@@ -334,7 +326,7 @@ export function UserPanel() {
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={t('user.settings')}
|
aria-label={t('user.settings')}
|
||||||
title={t('user.settings')}
|
title={t('user.settings')}
|
||||||
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"
|
className="rounded-[var(--radius-sm)] px-1 py-1 text-base 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={() => {
|
onClick={() => {
|
||||||
setOpenControl(null);
|
setOpenControl(null);
|
||||||
goToSettings({ section: 'profile', tab: 'main' });
|
goToSettings({ section: 'profile', tab: 'main' });
|
||||||
@@ -397,9 +389,6 @@ export function UserPanel() {
|
|||||||
<MenuItem icon="🗄" onClick={() => goToSettings({ section: 'profile', tab: 'server' })}>
|
<MenuItem icon="🗄" onClick={() => goToSettings({ section: 'profile', tab: 'server' })}>
|
||||||
{t('user.menu.guilds')}
|
{t('user.menu.guilds')}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem icon="#" onClick={onCopyId}>
|
|
||||||
{t('user.copyId')}
|
|
||||||
</MenuItem>
|
|
||||||
<MenuSeparator />
|
<MenuSeparator />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
danger
|
danger
|
||||||
|
|||||||
@@ -19,4 +19,4 @@ export const ProfileSettingsPage = lazy(() => import('@/pages/settings/ProfileSe
|
|||||||
export const SecuritySettingsPage = lazy(() => import('@/pages/settings/SecuritySettingsPage'));
|
export const SecuritySettingsPage = lazy(() => import('@/pages/settings/SecuritySettingsPage'));
|
||||||
export const AppearanceSettingsPage = lazy(() => import('@/pages/settings/AppearanceSettingsPage'));
|
export const AppearanceSettingsPage = lazy(() => import('@/pages/settings/AppearanceSettingsPage'));
|
||||||
export const InstanceSettingsPage = lazy(() => import('@/pages/settings/InstanceSettingsPage'));
|
export const InstanceSettingsPage = lazy(() => import('@/pages/settings/InstanceSettingsPage'));
|
||||||
export const ServersSettingsPage = lazy(() => import('@/pages/settings/ServersSettingsPage'));
|
export const ProfileServerTab = lazy(() => import('@/pages/settings/ProfileServerTab'));
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { GuildSummary } from '@/api/types';
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
import { Menu, MenuGroupLabel, MenuItem } from '@/components/ui/Menu';
|
||||||
|
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||||
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
|
||||||
|
interface GuildPickerProps {
|
||||||
|
/** Серверы, доступные пользователю (снапшот шлюза или REST-ответ). */
|
||||||
|
guilds: GuildSummary[];
|
||||||
|
/** Список ещё едет: показываем скелетон вместо «не найдено». */
|
||||||
|
isPending?: boolean;
|
||||||
|
onSelect: (guildId: string) => void;
|
||||||
|
/** Подпись выпадающего списка (у админского пункта она своя). */
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выпадающий список серверов с иконкой и именем: общий элемент пункта
|
||||||
|
* «Сервер» и вкладки «Профиль → Сервер». Выбранный сервер живёт в сторе
|
||||||
|
* сессии, поэтому компонент только сообщает о смене через `onSelect`.
|
||||||
|
*/
|
||||||
|
export function GuildPicker({ guilds, isPending = false, onSelect, label }: GuildPickerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const selectedGuildId = useSessionStore((state) => state.settingsGuildId);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
const pickerLabel = label ?? t('settings.servers.pickerLabel');
|
||||||
|
const active = guilds.find((guild) => guild.id === selectedGuildId) ?? null;
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return <SkeletonLines rows={2} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
ref={anchorRef}
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={t('settings.servers.selectLabel')}
|
||||||
|
onClick={() => setOpen((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>
|
||||||
|
|
||||||
|
<Menu
|
||||||
|
open={open}
|
||||||
|
label={pickerLabel}
|
||||||
|
anchorRef={anchorRef}
|
||||||
|
placement="bottom"
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<MenuGroupLabel>{pickerLabel}</MenuGroupLabel>
|
||||||
|
{guilds.map((guild) => (
|
||||||
|
<MenuItem
|
||||||
|
key={guild.id}
|
||||||
|
role="menuitemradio"
|
||||||
|
selected={guild.id === active?.id}
|
||||||
|
alwaysShowIcon
|
||||||
|
icon={
|
||||||
|
<Avatar
|
||||||
|
name={guild.name}
|
||||||
|
seed={guild.id}
|
||||||
|
fileId={guild.icon_file_id}
|
||||||
|
size="sm"
|
||||||
|
shape="square"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(guild.id);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{guild.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useEffect, useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { fetchGuild, fetchMembers, guildMembersQueryKey, guildQueryKey } from '@/api/guilds';
|
||||||
|
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
|
||||||
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
|
import { Card } from '@/components/ui/primitives';
|
||||||
|
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||||
|
import { canAdministerGuild } from '@/lib/identity';
|
||||||
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
import { GuildDangerSection } from '@/pages/settings/GuildDangerSection';
|
||||||
|
import { GuildGeneralSection } from '@/pages/settings/GuildGeneralSection';
|
||||||
|
import { GuildMembersSection } from '@/pages/settings/GuildMembersSection';
|
||||||
|
import { GuildPicker } from '@/pages/settings/GuildPicker';
|
||||||
|
import { GuildRolesSection } from '@/pages/settings/GuildRolesSection';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пункт «Сервер» окна настроек: администрирование одного сервера —
|
||||||
|
* общее (имя и описание), участники, роли и «Опасная зона».
|
||||||
|
*
|
||||||
|
* В отличие от вкладки «Профиль → Сервер» (там только свой никнейм), сюда
|
||||||
|
* попадают через шестерёнку в шапке сервера или через пункт меню настроек.
|
||||||
|
* Сервер можно переключить тем же выпадающим списком.
|
||||||
|
*/
|
||||||
|
export function GuildSettingsPage() {
|
||||||
|
const sessionGuilds = useSessionStore((state) => state.guilds);
|
||||||
|
const upsertGuild = useSessionStore((state) => state.upsertGuild);
|
||||||
|
const selectSettingsGuild = useSessionStore((state) => state.selectSettingsGuild);
|
||||||
|
|
||||||
|
// Подстраховка снапшота: без неё выбранный сервер не найти в сторе.
|
||||||
|
const myGuilds = useQuery({
|
||||||
|
queryKey: myGuildsQueryKey,
|
||||||
|
queryFn: ({ signal }) => fetchMyGuilds(signal),
|
||||||
|
retry: 0,
|
||||||
|
enabled: sessionGuilds.length === 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessionGuilds.length > 0 || myGuilds.data === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const guild of myGuilds.data) {
|
||||||
|
upsertGuild(guild);
|
||||||
|
}
|
||||||
|
}, [sessionGuilds.length, myGuilds.data, upsertGuild]);
|
||||||
|
|
||||||
|
const guilds = useMemo(
|
||||||
|
() => (sessionGuilds.length > 0 ? sessionGuilds : (myGuilds.data ?? [])),
|
||||||
|
[sessionGuilds, myGuilds.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<GuildPicker
|
||||||
|
guilds={guilds}
|
||||||
|
isPending={myGuilds.isPending && guilds.length === 0}
|
||||||
|
onSelect={selectSettingsGuild}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GuildSettingsBody guilds={guilds} />
|
||||||
|
{myGuilds.isError ? (
|
||||||
|
<Card>
|
||||||
|
<ErrorNotice error={myGuilds.error} onRetry={() => void myGuilds.refetch()} />
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Секции выбранного сервера: данные тянутся по REST только для него. */
|
||||||
|
function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const currentUser = useCurrentUser();
|
||||||
|
const selectedGuildId = useSessionStore((state) => state.settingsGuildId);
|
||||||
|
const selectSettingsGuild = useSessionStore((state) => state.selectSettingsGuild);
|
||||||
|
|
||||||
|
const active = useMemo(
|
||||||
|
() => guilds.find((guild) => guild.id === selectedGuildId) ?? null,
|
||||||
|
[guilds, selectedGuildId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Первый доступный сервер выбираем сами: список только что загрузился.
|
||||||
|
useEffect(() => {
|
||||||
|
if (active !== null || guilds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const first = guilds[0];
|
||||||
|
if (first !== undefined) {
|
||||||
|
selectSettingsGuild(first.id);
|
||||||
|
}
|
||||||
|
}, [active, guilds, selectSettingsGuild]);
|
||||||
|
|
||||||
|
const activeId = active?.id ?? null;
|
||||||
|
|
||||||
|
const detail = useQuery({
|
||||||
|
queryKey: guildQueryKey(activeId ?? ''),
|
||||||
|
queryFn: ({ signal }) => fetchGuild(activeId ?? '', signal),
|
||||||
|
enabled: activeId !== null,
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const members = useQuery({
|
||||||
|
queryKey: guildMembersQueryKey(activeId ?? ''),
|
||||||
|
queryFn: ({ signal }) => fetchMembers(activeId ?? '', signal),
|
||||||
|
enabled: activeId !== null,
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeId === null) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<p className="text-sm text-fg-muted">{t('settings.servers.notFound')}</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detail.isPending) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<SkeletonLines rows={5} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = detail.data;
|
||||||
|
if (guild === undefined) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<ErrorNotice error={detail.error} onRetry={() => void detail.refetch()} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canManage = canAdministerGuild(
|
||||||
|
guild.my_permissions,
|
||||||
|
guild.owner_id,
|
||||||
|
currentUser.data?.id,
|
||||||
|
currentUser.data?.is_instance_admin === true,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} />
|
||||||
|
<GuildMembersSection
|
||||||
|
members={members.data}
|
||||||
|
isPending={members.isPending}
|
||||||
|
error={members.error}
|
||||||
|
onRetry={() => void members.refetch()}
|
||||||
|
/>
|
||||||
|
<GuildRolesSection key={`${guild.id}-roles`} guildId={guild.id} />
|
||||||
|
<GuildDangerSection
|
||||||
|
key={`${guild.id}-danger`}
|
||||||
|
guildId={guild.id}
|
||||||
|
guildName={guild.name}
|
||||||
|
isOwner={currentUser.data?.id === guild.owner_id}
|
||||||
|
isInstanceAdmin={currentUser.data?.is_instance_admin === true}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GuildSettingsPage;
|
||||||
@@ -136,6 +136,9 @@ export default function InstanceSettingsPage() {
|
|||||||
<h3 className="text-base font-semibold">
|
<h3 className="text-base font-semibold">
|
||||||
{t('settings.instance.users', { count: users.data?.length ?? 0 })}
|
{t('settings.instance.users', { count: users.data?.length ?? 0 })}
|
||||||
</h3>
|
</h3>
|
||||||
|
{/* ID пользователей — единственное место, где он виден: раздел открыт
|
||||||
|
только администратору инстанса. */}
|
||||||
|
<p className="mt-1 text-xs text-fg-muted">{t('settings.instance.usersIdHint')}</p>
|
||||||
{users.isPending ? <LoadingNotice /> : null}
|
{users.isPending ? <LoadingNotice /> : null}
|
||||||
{users.isError ? (
|
{users.isError ? (
|
||||||
<ErrorNotice className="mt-3" error={users.error} onRetry={() => void users.refetch()} />
|
<ErrorNotice className="mt-3" error={users.error} onRetry={() => void users.refetch()} />
|
||||||
@@ -150,9 +153,16 @@ export default function InstanceSettingsPage() {
|
|||||||
key={user.id}
|
key={user.id}
|
||||||
className="flex items-center justify-between gap-3 border-b border-border/40 py-2 last:border-b-0"
|
className="flex items-center justify-between gap-3 border-b border-border/40 py-2 last:border-b-0"
|
||||||
>
|
>
|
||||||
<span className="truncate">
|
<span className="min-w-0 flex-1 truncate">
|
||||||
{user.display_name} <span className="text-fg-muted">@{user.username}</span>
|
{user.display_name} <span className="text-fg-muted">@{user.username}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
className="shrink-0 font-mono text-xs text-fg-muted"
|
||||||
|
data-testid={`instance-user-id-${user.id}`}
|
||||||
|
title={t('settings.instance.userId')}
|
||||||
|
>
|
||||||
|
{user.id}
|
||||||
|
</span>
|
||||||
{user.is_instance_admin ? (
|
{user.is_instance_admin ? (
|
||||||
<span className="shrink-0 rounded-full border border-accent/50 px-2 text-xs text-accent">
|
<span className="shrink-0 rounded-full border border-accent/50 px-2 text-xs text-accent">
|
||||||
{t('settings.instance.adminBadge')}
|
{t('settings.instance.adminBadge')}
|
||||||
|
|||||||
+22
-131
@@ -1,39 +1,28 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import {
|
import { fetchMembers, guildMembersQueryKey, updateGuildMember } from '@/api/guilds';
|
||||||
fetchGuild,
|
|
||||||
fetchMembers,
|
|
||||||
guildMembersQueryKey,
|
|
||||||
guildQueryKey,
|
|
||||||
updateGuildMember,
|
|
||||||
} from '@/api/guilds';
|
|
||||||
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
|
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 { Card } from '@/components/ui/primitives';
|
||||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||||
import { canManageGuildSettings } from '@/lib/identity';
|
|
||||||
import { useCurrentUser } from '@/lib/hooks';
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
import { GuildDangerSection } from '@/pages/settings/GuildDangerSection';
|
|
||||||
import { GuildGeneralSection } from '@/pages/settings/GuildGeneralSection';
|
|
||||||
import { GuildMembersSection } from '@/pages/settings/GuildMembersSection';
|
|
||||||
import { GuildNicknameSection } from '@/pages/settings/GuildNicknameSection';
|
import { GuildNicknameSection } from '@/pages/settings/GuildNicknameSection';
|
||||||
import { GuildRolesSection } from '@/pages/settings/GuildRolesSection';
|
import { GuildPicker } from '@/pages/settings/GuildPicker';
|
||||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Вкладка «Профиль → Сервер»: мой профиль на конкретном сервере и (для
|
* Вкладка «Профиль → Сервер»: только личный ник на конкретном сервере и
|
||||||
* владельца и прав управления) администрирование сервера.
|
* кнопка «Применить ко всем серверам». Администрирование сервера живёт в
|
||||||
|
* отдельном пункте «Сервер» (см. `GuildSettingsPage`) — так эта вкладка
|
||||||
|
* доступна и обычному участнику.
|
||||||
*
|
*
|
||||||
* Список серверов берём из READY-снапшота, а если шлюз ещё не подключился —
|
* Список серверов берём из READY-снапшота, а если шлюз ещё не подключился —
|
||||||
* из `GET /users/@me/guilds`. Выбранный сервер лежит в сторе сессии: его же
|
* из `GET /users/@me/guilds`. Выбранный сервер лежит в сторе сессии: его же
|
||||||
* сбрасывает «Опасная зона» после выхода с сервера.
|
* сбрасывает «Опасная зона» после выхода с сервера.
|
||||||
*/
|
*/
|
||||||
export function ServersSettingsPage() {
|
export function ProfileServerTab() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
@@ -83,16 +72,6 @@ export function ServersSettingsPage() {
|
|||||||
|
|
||||||
const activeId = active?.id ?? null;
|
const activeId = active?.id ?? null;
|
||||||
|
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
|
||||||
const pickerRef = useRef<HTMLButtonElement | null>(null);
|
|
||||||
|
|
||||||
const detail = useQuery({
|
|
||||||
queryKey: guildQueryKey(activeId ?? ''),
|
|
||||||
queryFn: ({ signal }) => fetchGuild(activeId ?? '', signal),
|
|
||||||
enabled: activeId !== null,
|
|
||||||
retry: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const members = useQuery({
|
const members = useQuery({
|
||||||
queryKey: guildMembersQueryKey(activeId ?? ''),
|
queryKey: guildMembersQueryKey(activeId ?? ''),
|
||||||
queryFn: ({ signal }) => fetchMembers(activeId ?? '', signal),
|
queryFn: ({ signal }) => fetchMembers(activeId ?? '', signal),
|
||||||
@@ -129,11 +108,7 @@ export function ServersSettingsPage() {
|
|||||||
onSettled: () => setProgress(null),
|
onSettled: () => setProgress(null),
|
||||||
});
|
});
|
||||||
|
|
||||||
const guild = detail.data;
|
|
||||||
const sessionGuild = sessionGuilds.find((item) => item.id === activeId) ?? null;
|
const sessionGuild = sessionGuilds.find((item) => item.id === activeId) ?? null;
|
||||||
const myPermissions = guild?.my_permissions ?? active?.my_permissions ?? [];
|
|
||||||
const ownerId = guild?.owner_id ?? active?.owner_id ?? '';
|
|
||||||
const canManage = canManageGuildSettings(myPermissions, ownerId, currentUser.data?.id);
|
|
||||||
const currentNickname = useMemo(() => {
|
const currentNickname = useMemo(() => {
|
||||||
const fromSnapshot = sessionGuild?.my_nickname;
|
const fromSnapshot = sessionGuild?.my_nickname;
|
||||||
if (fromSnapshot !== undefined && fromSnapshot !== '') {
|
if (fromSnapshot !== undefined && fromSnapshot !== '') {
|
||||||
@@ -168,112 +143,28 @@ export function ServersSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<div className="relative">
|
<GuildPicker guilds={guilds} onSelect={selectSettingsGuild} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<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 ? (
|
{activeId === null ? (
|
||||||
<Card>
|
<Card>
|
||||||
<p className="text-sm text-fg-muted">{t('settings.servers.notFound')}</p>
|
<p className="text-sm text-fg-muted">{t('settings.servers.notFound')}</p>
|
||||||
</Card>
|
</Card>
|
||||||
) : detail.isPending ? (
|
|
||||||
<Card>
|
|
||||||
<SkeletonLines rows={5} />
|
|
||||||
</Card>
|
|
||||||
) : guild === undefined ? (
|
|
||||||
<Card>
|
|
||||||
<ErrorNotice error={detail.error} onRetry={() => void detail.refetch()} />
|
|
||||||
</Card>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<GuildNicknameSection
|
||||||
<GuildNicknameSection
|
guildId={activeId}
|
||||||
guildId={guild.id}
|
userId={userId}
|
||||||
userId={userId}
|
currentNickname={currentNickname}
|
||||||
currentNickname={currentNickname}
|
members={members.data}
|
||||||
members={members.data}
|
applyAll={{
|
||||||
applyAll={{
|
pending: applyAll.isPending,
|
||||||
pending: applyAll.isPending,
|
progress,
|
||||||
progress,
|
result: applyResult,
|
||||||
result: applyResult,
|
run: onApplyAll,
|
||||||
run: onApplyAll,
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
<GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} />
|
|
||||||
<GuildMembersSection
|
|
||||||
members={members.data}
|
|
||||||
isPending={members.isPending}
|
|
||||||
error={members.error}
|
|
||||||
onRetry={() => void members.refetch()}
|
|
||||||
/>
|
|
||||||
<GuildRolesSection key={`${guild.id}-roles`} guildId={guild.id} />
|
|
||||||
<GuildDangerSection
|
|
||||||
key={`${guild.id}-danger`}
|
|
||||||
guildId={guild.id}
|
|
||||||
guildName={guild.name}
|
|
||||||
isOwner={userId !== undefined && userId === guild.owner_id}
|
|
||||||
isInstanceAdmin={currentUser.data?.is_instance_admin === true}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ServersSettingsPage;
|
export default ProfileServerTab;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, useState, type ChangeEvent, type FormEvent } from 'react';
|
import { useCallback, useRef, useState, type ChangeEvent, type FormEvent } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -10,16 +10,14 @@ import {
|
|||||||
currentUserQueryKey,
|
currentUserQueryKey,
|
||||||
type UpdateProfileInput,
|
type UpdateProfileInput,
|
||||||
} from '@/api/users';
|
} from '@/api/users';
|
||||||
import { userStatuses, type User } from '@/api/types';
|
import type { User } from '@/api/types';
|
||||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
import { Field, SelectField, TextAreaField } from '@/components/ui/Field';
|
import { Field, TextAreaField } from '@/components/ui/Field';
|
||||||
import { Avatar } from '@/components/ui/Avatar';
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
import { Badge, Button, Card } from '@/components/ui/primitives';
|
import { Badge, Button, Card } from '@/components/ui/primitives';
|
||||||
import { SkeletonLines } from '@/components/ui/Skeleton';
|
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||||
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
|
|
||||||
import { formatBytes } from '@/lib/format';
|
import { formatBytes } from '@/lib/format';
|
||||||
import { useCurrentUser } from '@/lib/hooks';
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
import { DEFAULT_TIMEZONE, timezoneChoices, timezoneLabel } from '@/lib/timezones';
|
|
||||||
import { useSessionStore } from '@/stores/session';
|
import { useSessionStore } from '@/stores/session';
|
||||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||||
|
|
||||||
@@ -73,6 +71,11 @@ function useAvatarPreview() {
|
|||||||
* Форма профиля. Состояние инициализируется из `user` при монтировании,
|
* Форма профиля. Состояние инициализируется из `user` при монтировании,
|
||||||
* а после сохранения кэш React Query обновляется ответом сервера — поэтому
|
* а после сохранения кэш React Query обновляется ответом сервера — поэтому
|
||||||
* синхронизация через эффект не нужна.
|
* синхронизация через эффект не нужна.
|
||||||
|
*
|
||||||
|
* Сверху формы — крупный аватар слева и поля справа на его высоту: имя,
|
||||||
|
* «О себе» и своё состояние с эмодзи. Статус присутствия и часовой пояс здесь
|
||||||
|
* намеренно отсутствуют (статус меняется в мини-профиле панели, часовой пояс —
|
||||||
|
* в разделе «Язык и регион»), чтобы настройки не дублировались.
|
||||||
*/
|
*/
|
||||||
export function ProfileForm({ user }: { user: User }) {
|
export function ProfileForm({ user }: { user: User }) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
@@ -83,22 +86,15 @@ export function ProfileForm({ user }: { user: User }) {
|
|||||||
const [bio, setBio] = useState(user.bio);
|
const [bio, setBio] = useState(user.bio);
|
||||||
const [customStatus, setCustomStatus] = useState(user.custom_status);
|
const [customStatus, setCustomStatus] = useState(user.custom_status);
|
||||||
const [customEmoji, setCustomEmoji] = useState(user.custom_status_emoji);
|
const [customEmoji, setCustomEmoji] = useState(user.custom_status_emoji);
|
||||||
const [status, setStatus] = useState<string>(user.status);
|
|
||||||
const [tz, setTz] = useState(user.timezone ?? DEFAULT_TIMEZONE);
|
|
||||||
const [language, setLanguage] = useState<SupportedLanguage>(user.locale === 'en' ? 'en' : 'ru');
|
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const avatar = useAvatarPreview();
|
const avatar = useAvatarPreview();
|
||||||
|
|
||||||
/** Профиль из ответа сервера: кэш, снапшот сессии и язык интерфейса. */
|
/** Профиль из ответа сервера: кэш и снапшот сессии. */
|
||||||
const applyUser = (updated: User): void => {
|
const applyUser = (updated: User): void => {
|
||||||
queryClient.setQueryData(currentUserQueryKey, updated);
|
queryClient.setQueryData(currentUserQueryKey, updated);
|
||||||
useSessionStore.getState().patchUser(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({
|
const save = useMutation({
|
||||||
@@ -154,21 +150,29 @@ export function ProfileForm({ user }: { user: User }) {
|
|||||||
upload.mutate(file);
|
upload.mutate(file);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openFilePicker = useCallback(() => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSaved(false);
|
setSaved(false);
|
||||||
|
// Часовой пояс и статус присутствия форма не трогает: у них свои места.
|
||||||
save.mutate({
|
save.mutate({
|
||||||
display_name: displayName.trim(),
|
display_name: displayName.trim(),
|
||||||
bio: bio.trim(),
|
bio: bio.trim(),
|
||||||
custom_status: customStatus.trim(),
|
custom_status: customStatus.trim(),
|
||||||
custom_status_emoji: customEmoji.trim(),
|
custom_status_emoji: customEmoji.trim(),
|
||||||
status,
|
|
||||||
locale: language,
|
|
||||||
timezone: tz,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const avatarName = displayName === '' ? user.username : displayName;
|
const avatarName = displayName === '' ? user.username : displayName;
|
||||||
|
const avatarBusy = upload.isPending || removeAvatar.isPending;
|
||||||
|
// Форматы и лимит живут в подсказке к аватару, а не в тексте формы.
|
||||||
|
const avatarHint = t('settings.profile.avatar.limit', {
|
||||||
|
formats: t('settings.profile.avatar.formats'),
|
||||||
|
limit: formatBytes(AVATAR_MAX_BYTES, locale),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
@@ -177,81 +181,105 @@ export function ProfileForm({ user }: { user: User }) {
|
|||||||
<p className="mt-1 text-sm text-fg-muted">{t('settings.profile.description')}</p>
|
<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>
|
<form className="mt-4 flex flex-col gap-4" onSubmit={onSubmit} noValidate>
|
||||||
<Field
|
{/* Аватар слева, поля справа — на его высоту. */}
|
||||||
label={t('settings.profile.displayName')}
|
<div className="grid gap-4 sm:grid-cols-[auto_minmax(0,1fr)]">
|
||||||
name="display_name"
|
<div className="min-h-36 sm:min-h-40">
|
||||||
value={displayName}
|
<div className="group relative h-full w-36 sm:w-40" data-testid="profile-avatar">
|
||||||
maxLength={64}
|
<input
|
||||||
onChange={(event) => setDisplayName(event.target.value)}
|
ref={fileInputRef}
|
||||||
/>
|
type="file"
|
||||||
<TextAreaField
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
label={t('settings.profile.bio')}
|
aria-label={t('settings.profile.avatar.choose')}
|
||||||
placeholder={t('settings.profile.bioPlaceholder')}
|
className="sr-only"
|
||||||
name="bio"
|
onChange={onPickFile}
|
||||||
rows={4}
|
/>
|
||||||
maxLength={512}
|
<Avatar
|
||||||
value={bio}
|
name={avatarName}
|
||||||
onChange={(event) => setBio(event.target.value)}
|
seed={user.id}
|
||||||
/>
|
fileId={user.avatar_file_id}
|
||||||
<div className="grid gap-4 sm:grid-cols-[1fr_6rem]">
|
preview={avatar.preview ?? undefined}
|
||||||
<Field
|
size="xl"
|
||||||
label={t('settings.profile.customStatus')}
|
shape="square"
|
||||||
placeholder={t('settings.profile.customStatusPlaceholder')}
|
/>
|
||||||
name="custom_status"
|
{/*
|
||||||
maxLength={128}
|
Клик по аватару открывает выбор файла, поэтому кнопка лежит
|
||||||
value={customStatus}
|
поверх картинки; «Удалить» — отдельная кнопка рядом.
|
||||||
onChange={(event) => setCustomStatus(event.target.value)}
|
*/}
|
||||||
/>
|
<button
|
||||||
<Field
|
type="button"
|
||||||
label={t('settings.profile.customStatusEmoji')}
|
disabled={avatarBusy}
|
||||||
placeholder={t('settings.profile.customStatusEmojiPlaceholder')}
|
onClick={openFilePicker}
|
||||||
name="custom_status_emoji"
|
title={avatarHint}
|
||||||
maxLength={8}
|
aria-label={`${t('settings.profile.avatar.change')}. ${avatarHint}`}
|
||||||
value={customEmoji}
|
className="absolute inset-0 z-10 flex items-center justify-center rounded-[var(--radius-lg)] bg-black/60 px-2 text-center text-xs font-medium text-white opacity-0 transition-opacity duration-[var(--duration-fast)] focus-visible:opacity-100 group-hover:opacity-100 disabled:cursor-not-allowed"
|
||||||
onChange={(event) => setCustomEmoji(event.target.value)}
|
>
|
||||||
/>
|
{t(
|
||||||
|
upload.isPending
|
||||||
|
? 'settings.profile.avatar.uploading'
|
||||||
|
: 'settings.profile.avatar.change',
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{user.avatar_file_id === undefined ? null : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute bottom-1 left-1 z-20 px-2 py-0.5 text-xs"
|
||||||
|
disabled={avatarBusy}
|
||||||
|
aria-label={t('settings.profile.avatar.remove')}
|
||||||
|
onClick={() => removeAvatar.mutate()}
|
||||||
|
>
|
||||||
|
{t('settings.profile.avatar.remove')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-col gap-4">
|
||||||
|
<Field
|
||||||
|
label={t('settings.profile.displayName')}
|
||||||
|
name="display_name"
|
||||||
|
value={displayName}
|
||||||
|
maxLength={64}
|
||||||
|
disabled={save.isPending}
|
||||||
|
onChange={(event) => setDisplayName(event.target.value)}
|
||||||
|
/>
|
||||||
|
<TextAreaField
|
||||||
|
label={t('settings.profile.bio')}
|
||||||
|
placeholder={t('settings.profile.bioPlaceholder')}
|
||||||
|
name="bio"
|
||||||
|
rows={3}
|
||||||
|
maxLength={512}
|
||||||
|
value={bio}
|
||||||
|
disabled={save.isPending}
|
||||||
|
onChange={(event) => setBio(event.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-[minmax(0,1fr)_6rem]">
|
||||||
|
<Field
|
||||||
|
label={t('settings.profile.customStatus')}
|
||||||
|
placeholder={t('settings.profile.customStatusPlaceholder')}
|
||||||
|
name="custom_status"
|
||||||
|
maxLength={128}
|
||||||
|
value={customStatus}
|
||||||
|
disabled={save.isPending}
|
||||||
|
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}
|
||||||
|
disabled={save.isPending}
|
||||||
|
onChange={(event) => setCustomEmoji(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</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.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">
|
<dl className="text-sm text-fg-muted">
|
||||||
<div className="flex justify-between gap-4 border-b border-border/40 py-2">
|
<div className="flex justify-between gap-4 border-b border-border/40 py-2">
|
||||||
<dt>{t('settings.profile.username')}</dt>
|
<dt>{t('settings.profile.username')}</dt>
|
||||||
<dd className="font-medium text-fg">{user.username}</dd>
|
<dd className="font-medium text-fg">{user.username}</dd>
|
||||||
</div>
|
</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">
|
<div className="flex justify-between gap-4 py-2">
|
||||||
<dt>{t('settings.profile.badges')}</dt>
|
<dt>{t('settings.profile.badges')}</dt>
|
||||||
<dd className="text-fg">
|
<dd className="text-fg">
|
||||||
@@ -260,6 +288,13 @@ export function ProfileForm({ user }: { user: User }) {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
{avatarError === null ? null : (
|
||||||
|
<p role="alert" className="text-sm text-danger">
|
||||||
|
{avatarError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{upload.isError ? <ErrorNotice error={upload.error} /> : null}
|
||||||
|
{removeAvatar.isError ? <ErrorNotice error={removeAvatar.error} /> : null}
|
||||||
{save.isError ? <ErrorNotice error={save.error} /> : null}
|
{save.isError ? <ErrorNotice error={save.error} /> : null}
|
||||||
{saved && !save.isError ? (
|
{saved && !save.isError ? (
|
||||||
<p className="text-sm text-success" role="status">
|
<p className="text-sm text-success" role="status">
|
||||||
@@ -275,62 +310,6 @@ export function ProfileForm({ user }: { user: User }) {
|
|||||||
</form>
|
</form>
|
||||||
</Card>
|
</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>
|
|
||||||
|
|
||||||
{avatarError === null ? null : (
|
|
||||||
<p role="alert" className="mt-3 text-sm text-danger">
|
|
||||||
{avatarError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{upload.isError ? <ErrorNotice className="mt-3" error={upload.error} /> : null}
|
|
||||||
{removeAvatar.isError ? <ErrorNotice className="mt-3" error={removeAvatar.error} /> : null}
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title={t('settings.profile.decorations.title')}
|
title={t('settings.profile.decorations.title')}
|
||||||
description={t('settings.profile.decorations.hint')}
|
description={t('settings.profile.decorations.hint')}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { canAdministerGuild } from '@/lib/identity';
|
||||||
import { useCurrentUser } from '@/lib/hooks';
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
|
import { useSessionStore } from '@/stores/session';
|
||||||
import { useUiStore, type SettingsSection } from '@/stores/ui';
|
import { useUiStore, type SettingsSection } from '@/stores/ui';
|
||||||
import AppearanceSettingsPage from '@/pages/settings/AppearanceSettingsPage';
|
import AppearanceSettingsPage from '@/pages/settings/AppearanceSettingsPage';
|
||||||
|
import GuildSettingsPage from '@/pages/settings/GuildSettingsPage';
|
||||||
import InstanceSettingsPage from '@/pages/settings/InstanceSettingsPage';
|
import InstanceSettingsPage from '@/pages/settings/InstanceSettingsPage';
|
||||||
import { LanguageRegionSection } from '@/pages/settings/LanguageRegionSection';
|
import { LanguageRegionSection } from '@/pages/settings/LanguageRegionSection';
|
||||||
import { NotificationsSettingsSection } from '@/pages/settings/NotificationsSettingsSection';
|
import { NotificationsSettingsSection } from '@/pages/settings/NotificationsSettingsSection';
|
||||||
|
import ProfileServerTab from '@/pages/settings/ProfileServerTab';
|
||||||
import ProfileSettingsPage from '@/pages/settings/ProfileSettingsPage';
|
import ProfileSettingsPage from '@/pages/settings/ProfileSettingsPage';
|
||||||
import SecuritySettingsPage from '@/pages/settings/SecuritySettingsPage';
|
import SecuritySettingsPage from '@/pages/settings/SecuritySettingsPage';
|
||||||
import { ServersSettingsPage } from '@/pages/settings/ServersSettingsPage';
|
|
||||||
import { VoiceSettingsSection } from '@/pages/settings/VoiceSettingsSection';
|
import { VoiceSettingsSection } from '@/pages/settings/VoiceSettingsSection';
|
||||||
|
|
||||||
/** Вкладки пункта «Профиль»: глобальный профиль и профиль на сервере. */
|
/** Вкладки пункта «Профиль»: глобальный профиль и профиль на сервере. */
|
||||||
@@ -48,16 +51,32 @@ function ProfileTabs() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === 'main' ? <ProfileSettingsPage /> : <ServersSettingsPage />}
|
{tab === 'main' ? <ProfileSettingsPage /> : <ProfileServerTab />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Есть ли у пользователя хотя бы один сервер, которым он управляет. */
|
||||||
|
function useCanManageGuilds(): boolean {
|
||||||
|
const currentUser = useCurrentUser();
|
||||||
|
const guilds = useSessionStore((state) => state.guilds);
|
||||||
|
const isInstanceAdmin = currentUser.data?.is_instance_admin === true;
|
||||||
|
|
||||||
|
if (isInstanceAdmin) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return guilds.some((guild) =>
|
||||||
|
canAdministerGuild(guild.my_permissions, guild.owner_id, currentUser.data?.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Модальное окно настроек: слева вертикальный список пунктов, справа
|
* Модальное окно настроек: слева вертикальный список пунктов, справа
|
||||||
* содержимое выбранного. Открывается из панели пользователя (шестерёнка) и из
|
* содержимое выбранного. Открывается из панели пользователя (шестерёнка) и из
|
||||||
* меню пользователя, а также при переходе на старые адреса `/settings/*`.
|
* меню пользователя, а также при переходе на старые адреса `/settings/*`.
|
||||||
*
|
*
|
||||||
|
* Размер окна фиксирован (70% ширины и высоты окна браузера): при переключении
|
||||||
|
* пунктов оно не подстраивается под содержимое, а прокручивается изнутри.
|
||||||
* Окно живёт в портале: панель пользователя — узкая колонка, и портал снимает
|
* Окно живёт в портале: панель пользователя — узкая колонка, и портал снимает
|
||||||
* вопросы к её `overflow` и анимационным трансформациям.
|
* вопросы к её `overflow` и анимационным трансформациям.
|
||||||
*/
|
*/
|
||||||
@@ -69,6 +88,9 @@ export function SettingsModal() {
|
|||||||
const closeSettings = useUiStore((state) => state.closeSettings);
|
const closeSettings = useUiStore((state) => state.closeSettings);
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
const isAdmin = currentUser.data?.is_instance_admin === true;
|
const isAdmin = currentUser.data?.is_instance_admin === true;
|
||||||
|
const canManageGuilds = useCanManageGuilds();
|
||||||
|
|
||||||
|
const selectedGuildId = useSessionStore((state) => state.settingsGuildId);
|
||||||
|
|
||||||
const items: { id: SettingsSection; label: string }[] = [
|
const items: { id: SettingsSection; label: string }[] = [
|
||||||
{ id: 'profile', label: t('settings.tabs.profile') },
|
{ id: 'profile', label: t('settings.tabs.profile') },
|
||||||
@@ -77,6 +99,7 @@ export function SettingsModal() {
|
|||||||
{ id: 'notifications', label: t('settings.tabs.notifications') },
|
{ id: 'notifications', label: t('settings.tabs.notifications') },
|
||||||
{ id: 'voice', label: t('settings.tabs.voice') },
|
{ id: 'voice', label: t('settings.tabs.voice') },
|
||||||
{ id: 'language', label: t('settings.tabs.language') },
|
{ id: 'language', label: t('settings.tabs.language') },
|
||||||
|
...(canManageGuilds ? [{ id: 'server' as const, label: t('settings.tabs.server') }] : []),
|
||||||
...(isAdmin ? [{ id: 'instance' as const, label: t('settings.tabs.instance') }] : []),
|
...(isAdmin ? [{ id: 'instance' as const, label: t('settings.tabs.instance') }] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -84,15 +107,23 @@ export function SettingsModal() {
|
|||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
title={t('settings.title')}
|
title={t('settings.title')}
|
||||||
size="xl"
|
|
||||||
portal
|
portal
|
||||||
panelClassName="flex max-h-[85vh] flex-col"
|
panelClassName="flex flex-col overflow-hidden"
|
||||||
bodyClassName="flex min-h-0 flex-1 gap-4"
|
bodyClassName="flex min-h-0 flex-1 gap-4"
|
||||||
|
panelStyle={{
|
||||||
|
// 70% доступной области с границами: на маленьких экранах окно
|
||||||
|
// остаётся читаемым, на больших — не растягивается во весь экран.
|
||||||
|
width: 'clamp(20rem, 70vw, 72rem)',
|
||||||
|
height: 'clamp(22rem, 70vh, 52rem)',
|
||||||
|
maxWidth: 'calc(100vw - 2rem)',
|
||||||
|
maxHeight: 'calc(100vh - 2rem)',
|
||||||
|
}}
|
||||||
onClose={closeSettings}
|
onClose={closeSettings}
|
||||||
>
|
>
|
||||||
<nav
|
<nav
|
||||||
aria-label={t('settings.navLabel')}
|
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"
|
className="flex w-40 shrink-0 flex-col gap-0.5 overflow-y-auto border-r border-border/40 pr-2"
|
||||||
|
data-testid="settings-nav"
|
||||||
>
|
>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<button
|
<button
|
||||||
@@ -123,6 +154,7 @@ export function SettingsModal() {
|
|||||||
{section === 'notifications' ? <NotificationsSettingsSection /> : null}
|
{section === 'notifications' ? <NotificationsSettingsSection /> : null}
|
||||||
{section === 'voice' ? <VoiceSettingsSection /> : null}
|
{section === 'voice' ? <VoiceSettingsSection /> : null}
|
||||||
{section === 'language' ? <LanguageRegionSection /> : null}
|
{section === 'language' ? <LanguageRegionSection /> : null}
|
||||||
|
{section === 'server' ? <GuildSettingsPage key={selectedGuildId ?? 'none'} /> : null}
|
||||||
{section === 'instance' ? <InstanceSettingsPage /> : null}
|
{section === 'instance' ? <InstanceSettingsPage /> : null}
|
||||||
{section === 'profile' ? <ProfileTabs /> : null}
|
{section === 'profile' ? <ProfileTabs /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ export function parseSettingsPath(pathname: string): SettingsTarget {
|
|||||||
const rest = segments.slice(1);
|
const rest = segments.slice(1);
|
||||||
|
|
||||||
if (rest[0] === 'servers') {
|
if (rest[0] === 'servers') {
|
||||||
|
// Старые адреса серверов теперь ведут в администрирование сервера.
|
||||||
return {
|
return {
|
||||||
section: 'profile',
|
section: 'server',
|
||||||
tab: 'server',
|
|
||||||
guildId: rest[1] === undefined ? null : decodeURIComponent(rest[1]),
|
guildId: rest[1] === undefined ? null : decodeURIComponent(rest[1]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -13,9 +13,9 @@ import {
|
|||||||
NoGuildView,
|
NoGuildView,
|
||||||
OnboardingPage,
|
OnboardingPage,
|
||||||
ProfileSettingsPage,
|
ProfileSettingsPage,
|
||||||
|
ProfileServerTab,
|
||||||
RegisterPage,
|
RegisterPage,
|
||||||
SecuritySettingsPage,
|
SecuritySettingsPage,
|
||||||
ServersSettingsPage,
|
|
||||||
SettingsLayout,
|
SettingsLayout,
|
||||||
StatusPage,
|
StatusPage,
|
||||||
} from '@/pages/lazyPages';
|
} from '@/pages/lazyPages';
|
||||||
@@ -73,8 +73,8 @@ export const routes = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
// Настройки пользовательских серверов: /settings/servers/:guildId
|
// Настройки пользовательских серверов: /settings/servers/:guildId
|
||||||
{ path: 'servers', element: <ServersSettingsPage /> },
|
{ path: 'servers', element: <ProfileServerTab /> },
|
||||||
{ path: 'servers/:guildId', element: <ServersSettingsPage /> },
|
{ path: 'servers/:guildId', element: <ProfileServerTab /> },
|
||||||
// Старые пути Фазы 1: закладки и внешние ссылки продолжают работать.
|
// Старые пути Фазы 1: закладки и внешние ссылки продолжают работать.
|
||||||
{ path: 'profile', element: <Navigate to="/settings/account/profile" replace /> },
|
{ path: 'profile', element: <Navigate to="/settings/account/profile" replace /> },
|
||||||
{ path: 'security', element: <Navigate to="/settings/account/security" replace /> },
|
{ path: 'security', element: <Navigate to="/settings/account/security" replace /> },
|
||||||
|
|||||||
+35
-1
@@ -12,6 +12,8 @@ export const settingsSections = [
|
|||||||
'notifications',
|
'notifications',
|
||||||
'voice',
|
'voice',
|
||||||
'language',
|
'language',
|
||||||
|
/** Администрирование выбранного сервера. */
|
||||||
|
'server',
|
||||||
'instance',
|
'instance',
|
||||||
] as const;
|
] as const;
|
||||||
export type SettingsSection = (typeof settingsSections)[number];
|
export type SettingsSection = (typeof settingsSections)[number];
|
||||||
@@ -24,7 +26,7 @@ export type ProfileTab = (typeof profileTabs)[number];
|
|||||||
export interface SettingsTarget {
|
export interface SettingsTarget {
|
||||||
section?: SettingsSection;
|
section?: SettingsSection;
|
||||||
tab?: ProfileTab;
|
tab?: ProfileTab;
|
||||||
/** Сервер для вкладки «Профиль → Сервер». */
|
/** Сервер для пункта «Сервер» и вкладки «Профиль → Сервер». */
|
||||||
guildId?: string | null;
|
guildId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,13 +46,20 @@ interface UiState {
|
|||||||
settingsOpen: boolean;
|
settingsOpen: boolean;
|
||||||
settingsSection: SettingsSection;
|
settingsSection: SettingsSection;
|
||||||
settingsProfileTab: ProfileTab;
|
settingsProfileTab: ProfileTab;
|
||||||
|
/**
|
||||||
|
* Развёрнут ли список друзей во вкладке «Друзья». Свёрнут по умолчанию, а
|
||||||
|
* выбор пользователя переживает перезагрузку страницы.
|
||||||
|
*/
|
||||||
|
friendsListOpen: boolean;
|
||||||
openSettings: (target?: SettingsTarget) => void;
|
openSettings: (target?: SettingsTarget) => void;
|
||||||
closeSettings: () => void;
|
closeSettings: () => void;
|
||||||
setSettingsSection: (section: SettingsSection) => void;
|
setSettingsSection: (section: SettingsSection) => void;
|
||||||
setSettingsProfileTab: (tab: ProfileTab) => void;
|
setSettingsProfileTab: (tab: ProfileTab) => void;
|
||||||
|
toggleFriendsList: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'glchat.theme';
|
const STORAGE_KEY = 'glchat.theme';
|
||||||
|
const FRIENDS_KEY = 'glchat.friends.expanded';
|
||||||
|
|
||||||
function initialTheme(): Theme {
|
function initialTheme(): Theme {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
@@ -63,6 +72,14 @@ function initialTheme(): Theme {
|
|||||||
return 'dark';
|
return 'dark';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Список друзей свёрнут, пока пользователь сам его не раскрыл. */
|
||||||
|
function initialFriendsListOpen(): boolean {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return window.localStorage.getItem(FRIENDS_KEY) === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
export function applyTheme(theme: Theme): void {
|
export function applyTheme(theme: Theme): void {
|
||||||
if (typeof document === 'undefined') {
|
if (typeof document === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -71,6 +88,8 @@ export function applyTheme(theme: Theme): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useUiStore = create<UiState>((set, get) => ({
|
export const useUiStore = create<UiState>((set, get) => ({
|
||||||
|
friendsListOpen: initialFriendsListOpen(),
|
||||||
|
|
||||||
theme: initialTheme(),
|
theme: initialTheme(),
|
||||||
setTheme: (theme) => {
|
setTheme: (theme) => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -101,6 +120,10 @@ export const useUiStore = create<UiState>((set, get) => ({
|
|||||||
settingsOpen: true,
|
settingsOpen: true,
|
||||||
...(target?.section === undefined ? {} : { settingsSection: target.section }),
|
...(target?.section === undefined ? {} : { settingsSection: target.section }),
|
||||||
...(target?.tab === undefined ? {} : { settingsProfileTab: target.tab }),
|
...(target?.tab === undefined ? {} : { settingsProfileTab: target.tab }),
|
||||||
|
// Профиль на сервере живёт во вкладке «Сервер» пункта «Профиль».
|
||||||
|
...(target?.section === 'profile' && target.tab === 'server'
|
||||||
|
? { settingsProfileTab: 'server' as ProfileTab }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -109,4 +132,15 @@ export const useUiStore = create<UiState>((set, get) => ({
|
|||||||
setSettingsSection: (section) => set({ settingsSection: section }),
|
setSettingsSection: (section) => set({ settingsSection: section }),
|
||||||
|
|
||||||
setSettingsProfileTab: (tab) => set({ settingsProfileTab: tab }),
|
setSettingsProfileTab: (tab) => set({ settingsProfileTab: tab }),
|
||||||
|
|
||||||
|
toggleFriendsList: () => {
|
||||||
|
const next = !get().friendsListOpen;
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem(FRIENDS_KEY, String(next));
|
||||||
|
}
|
||||||
|
set({ friendsListOpen: next });
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/** Ключ localStorage: нужен тестам и сбросу состояния. */
|
||||||
|
export const FRIENDS_LIST_STORAGE_KEY = FRIENDS_KEY;
|
||||||
|
|||||||
@@ -109,6 +109,18 @@ async function dispatchEvent(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Раскрывает список друзей: по умолчанию он свёрнут под кнопкой
|
||||||
|
* «Друзья (N)» и разворачивается снизу вверх.
|
||||||
|
*/
|
||||||
|
async function expandFriends(): Promise<HTMLElement> {
|
||||||
|
const toggle = await screen.findByTestId('friends-list-toggle');
|
||||||
|
if (toggle.getAttribute('aria-expanded') !== 'true') {
|
||||||
|
await userEvent.click(toggle);
|
||||||
|
}
|
||||||
|
return screen.findByTestId('friends-list');
|
||||||
|
}
|
||||||
|
|
||||||
describe('раздел друзей', () => {
|
describe('раздел друзей', () => {
|
||||||
it('первой иконкой рейки открывает раздел друзей', async () => {
|
it('первой иконкой рейки открывает раздел друзей', async () => {
|
||||||
const visitor = userEvent.setup();
|
const visitor = userEvent.setup();
|
||||||
@@ -259,7 +271,8 @@ describe('раздел друзей', () => {
|
|||||||
expect(
|
expect(
|
||||||
findRequest(fetchMock, { url: '/users/@me/relationships/user-3/accept', method: 'POST' }),
|
findRequest(fetchMock, { url: '/users/@me/relationships/user-3/accept', method: 'POST' }),
|
||||||
).toBeDefined();
|
).toBeDefined();
|
||||||
expect(await screen.findByTestId('friends-list')).toHaveTextContent('Кэрол');
|
await expandFriends();
|
||||||
|
expect(screen.getByTestId('friends-list')).toHaveTextContent('Кэрол');
|
||||||
|
|
||||||
const outgoing = screen.getByTestId('friends-outgoing');
|
const outgoing = screen.getByTestId('friends-outgoing');
|
||||||
await visitor.click(within(outgoing).getByRole('button', { name: 'Отменить' }));
|
await visitor.click(within(outgoing).getByRole('button', { name: 'Отменить' }));
|
||||||
@@ -307,6 +320,84 @@ describe('раздел друзей', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('список друзей свёрнут по умолчанию и раскрывается кнопкой', async () => {
|
||||||
|
const visitor = userEvent.setup();
|
||||||
|
const fetchMock = installFetch(
|
||||||
|
baseRoutes({ ...empty, friends: [bob] }, [
|
||||||
|
{
|
||||||
|
match: '/api/v1/users/@me/channels',
|
||||||
|
method: 'POST',
|
||||||
|
response: () => json({ channel: directChannel('dm-1') }),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { router } = renderApp('/app/friends');
|
||||||
|
|
||||||
|
// Свёрнут: списка в DOM нет, кнопка сообщает состояние и число друзей.
|
||||||
|
const toggle = await screen.findByTestId('friends-list-toggle');
|
||||||
|
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
expect(toggle).toHaveTextContent('Друзья (1)');
|
||||||
|
expect(screen.queryByTestId('friends-list')).toBeNull();
|
||||||
|
expect(screen.getByTestId('friends-list-region')).toHaveAttribute('data-state', 'closed');
|
||||||
|
|
||||||
|
// Развёрнутый список въезжает снизу: класс анимации и состояние open.
|
||||||
|
await visitor.click(toggle);
|
||||||
|
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
const region = screen.getByTestId('friends-list-region');
|
||||||
|
expect(region).toHaveAttribute('data-state', 'open');
|
||||||
|
expect(region).toHaveClass('gl-reveal-up');
|
||||||
|
|
||||||
|
// Развёрнутый список работает как раньше: переход в беседу по клику.
|
||||||
|
const list = await screen.findByTestId('friends-list');
|
||||||
|
await visitor.click(within(list).getByTestId('friend-name-user-2'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(router.state.location.pathname).toBe('/app/friends/dm-1');
|
||||||
|
});
|
||||||
|
expect(findRequest(fetchMock, { url: '/users/@me/channels', method: 'POST' })?.body).toEqual({
|
||||||
|
recipient_id: 'user-2',
|
||||||
|
});
|
||||||
|
|
||||||
|
// На странице беседы списка нет: состояние раскрытия переживает возврат.
|
||||||
|
expect(window.localStorage.getItem('glchat.friends.expanded')).toBe('true');
|
||||||
|
await visitor.click(screen.getByTestId('friends-rail-link'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(router.state.location.pathname).toBe('/app/friends');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Свёрнутое состояние тоже запоминается.
|
||||||
|
await visitor.click(await screen.findByTestId('friends-list-toggle'));
|
||||||
|
expect(screen.queryByTestId('friends-list')).toBeNull();
|
||||||
|
expect(window.localStorage.getItem('glchat.friends.expanded')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('заявки в друзья остаются видимыми при свёрнутом списке', async () => {
|
||||||
|
installFetch(
|
||||||
|
baseRoutes({
|
||||||
|
...empty,
|
||||||
|
friends: [bob],
|
||||||
|
incoming: [
|
||||||
|
relationship({
|
||||||
|
user_id: 'user-3',
|
||||||
|
username: 'carol',
|
||||||
|
display_name: 'Кэрол',
|
||||||
|
relationship: 'incoming',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderApp('/app/friends');
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('friends-incoming')).toHaveTextContent('Кэрол');
|
||||||
|
expect(await screen.findByTestId('friends-list-toggle')).toHaveAttribute(
|
||||||
|
'aria-expanded',
|
||||||
|
'false',
|
||||||
|
);
|
||||||
|
// Бейджи непрочитанного в беседах живут в сайдбаре и от списка не зависят.
|
||||||
|
expect(screen.queryByTestId('friends-list')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('рисует цветные точки статусов в списке друзей', async () => {
|
it('рисует цветные точки статусов в списке друзей', async () => {
|
||||||
installFetch(
|
installFetch(
|
||||||
baseRoutes({
|
baseRoutes({
|
||||||
@@ -348,7 +439,7 @@ describe('раздел друзей', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
renderApp('/app/friends');
|
renderApp('/app/friends');
|
||||||
const list = await screen.findByTestId('friends-list');
|
const list = await expandFriends();
|
||||||
|
|
||||||
const dots: [string, string, string][] = [
|
const dots: [string, string, string][] = [
|
||||||
['user-2', 'online', 'bg-success'],
|
['user-2', 'online', 'bg-success'],
|
||||||
@@ -393,7 +484,7 @@ describe('раздел друзей', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
renderApp('/app/friends');
|
renderApp('/app/friends');
|
||||||
await screen.findByTestId('friends-list');
|
await expandFriends();
|
||||||
|
|
||||||
// Наведение на имя: 10:00 UTC — это 13:00 в Москве.
|
// Наведение на имя: 10:00 UTC — это 13:00 в Москве.
|
||||||
await visitor.hover(screen.getByTestId('friend-name-user-2'));
|
await visitor.hover(screen.getByTestId('friend-name-user-2'));
|
||||||
@@ -441,7 +532,7 @@ describe('раздел друзей', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
renderApp('/app/friends');
|
renderApp('/app/friends');
|
||||||
await screen.findByTestId('friends-list');
|
await expandFriends();
|
||||||
|
|
||||||
await visitor.hover(screen.getByTestId('friend-name-user-2'));
|
await visitor.hover(screen.getByTestId('friend-name-user-2'));
|
||||||
// 10:00 UTC — 15:00 в Екатеринбурге (UTC+5).
|
// 10:00 UTC — 15:00 в Екатеринбурге (UTC+5).
|
||||||
@@ -480,7 +571,7 @@ describe('раздел друзей', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { router } = renderApp('/app/friends');
|
const { router } = renderApp('/app/friends');
|
||||||
const list = await screen.findByTestId('friends-list');
|
const list = await expandFriends();
|
||||||
await visitor.click(within(list).getByRole('button', { name: 'Написать' }));
|
await visitor.click(within(list).getByRole('button', { name: 'Написать' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -664,7 +755,7 @@ describe('события шлюза о друзьях', () => {
|
|||||||
hasFriend = true;
|
hasFriend = true;
|
||||||
await dispatchEvent(gateway, 'RELATIONSHIP_UPDATE', { user_id: 'user-2' });
|
await dispatchEvent(gateway, 'RELATIONSHIP_UPDATE', { user_id: 'user-2' });
|
||||||
|
|
||||||
const list = await screen.findByTestId('friends-list');
|
const list = await expandFriends();
|
||||||
expect(list).toHaveTextContent('Боб');
|
expect(list).toHaveTextContent('Боб');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -685,7 +776,7 @@ describe('события шлюза о друзьях', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
renderApp('/app/friends');
|
renderApp('/app/friends');
|
||||||
const list = await screen.findByTestId('friends-list');
|
const list = await expandFriends();
|
||||||
await gateway.greet();
|
await gateway.greet();
|
||||||
expect(within(list).getByTestId('friend-status-user-2')).toHaveAttribute(
|
expect(within(list).getByTestId('friend-status-user-2')).toHaveAttribute(
|
||||||
'data-status',
|
'data-status',
|
||||||
|
|||||||
+15
-6
@@ -117,18 +117,27 @@ export function findRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Сброс всех сторов к исходному состоянию (изоляция между тестами). */
|
/** Сброс всех сторов к исходному состоянию (изоляция между тестами). */
|
||||||
export function resetStores(): void {
|
/**
|
||||||
useSessionStore.getState().reset();
|
* Сброс UI-стора к начальному состоянию: окно настроек закрыто, список друзей
|
||||||
useMessagesStore.getState().reset();
|
* свёрнут. Нужен и внутри `renderApp`, и в тестах, которые меняют настройки
|
||||||
useVoiceStore.getState().reset();
|
* вида без перерисовки приложения.
|
||||||
useFriendsStore.getState().reset();
|
*/
|
||||||
// Окно настроек — обычное состояние UI: между тестами оно должно быть закрыто.
|
export function resetUiStore(): void {
|
||||||
useUiStore.setState({
|
useUiStore.setState({
|
||||||
guildCreateOpen: false,
|
guildCreateOpen: false,
|
||||||
settingsOpen: false,
|
settingsOpen: false,
|
||||||
settingsSection: 'profile',
|
settingsSection: 'profile',
|
||||||
settingsProfileTab: 'main',
|
settingsProfileTab: 'main',
|
||||||
|
friendsListOpen: false,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetStores(): void {
|
||||||
|
useSessionStore.getState().reset();
|
||||||
|
useMessagesStore.getState().reset();
|
||||||
|
useVoiceStore.getState().reset();
|
||||||
|
useFriendsStore.getState().reset();
|
||||||
|
resetUiStore();
|
||||||
// `reset` закрывает и общее соединение шлюза: иначе колбэки прошлого теста
|
// `reset` закрывает и общее соединение шлюза: иначе колбэки прошлого теста
|
||||||
// (переподключение, статус) меняли бы стор уже следующего.
|
// (переподключение, статус) меняли бы стор уже следующего.
|
||||||
useGatewayStore.getState().reset();
|
useGatewayStore.getState().reset();
|
||||||
|
|||||||
+29
-13
@@ -121,16 +121,15 @@ function saveNear(label: string): HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Открывает настройки сервера: приложение → мини-профиль → «Настройки серверов».
|
* Открывает администрирование сервера: приложение → мини-профиль →
|
||||||
* Так же их открывает и обычный участник без прав администратора.
|
* «Настройки серверов» → пункт «Сервер». Так же его открывает шестерёнка
|
||||||
|
* в шапке сервера.
|
||||||
*/
|
*/
|
||||||
async function openServerSettings(path = '/app/empty', guildName?: string) {
|
async function openServerSettings(path = '/app/empty', guildName?: string) {
|
||||||
const view = renderApp(path);
|
const view = renderApp(path);
|
||||||
const trigger = await screen.findByLabelText('Открыть меню пользователя');
|
const dialog = await openSettingsWindow();
|
||||||
await userEvent.click(trigger);
|
const nav = within(dialog).getByTestId('settings-nav');
|
||||||
const menu = await screen.findByRole('menu', { name: 'Меню пользователя' });
|
await userEvent.click(await within(nav).findByRole('button', { name: 'Сервер' }));
|
||||||
await userEvent.click(within(menu).getByRole('menuitem', { name: 'Настройки серверов' }));
|
|
||||||
await screen.findByRole('dialog', { name: 'Настройки' });
|
|
||||||
|
|
||||||
if (guildName !== undefined) {
|
if (guildName !== undefined) {
|
||||||
const picker = await openGuildPicker();
|
const picker = await openGuildPicker();
|
||||||
@@ -139,7 +138,24 @@ async function openServerSettings(path = '/app/empty', guildName?: string) {
|
|||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Раскрывает выпадающий список серверов во вкладке «Профиль → Сервер». */
|
/**
|
||||||
|
* Открывает «Профиль → Сервер»: личный ник и «Применить ко всем серверам».
|
||||||
|
* Доступно и обычному участнику без прав администратора.
|
||||||
|
*/
|
||||||
|
async function openProfileServerTab(path = '/app/empty') {
|
||||||
|
const view = renderApp(path);
|
||||||
|
await openSettingsWindow();
|
||||||
|
await userEvent.click(await screen.findByRole('tab', { name: 'Сервер' }));
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Окно настроек открывается шестерёнкой в панели пользователя. */
|
||||||
|
async function openSettingsWindow(): Promise<HTMLElement> {
|
||||||
|
await userEvent.click(await screen.findByLabelText('Настройки пользователя'));
|
||||||
|
return screen.findByRole('dialog', { name: 'Настройки' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Раскрывает выпадающий список серверов в настройках. */
|
||||||
async function openGuildPicker(): Promise<HTMLElement> {
|
async function openGuildPicker(): Promise<HTMLElement> {
|
||||||
await userEvent.click(await screen.findByLabelText('Выбрать сервер'));
|
await userEvent.click(await screen.findByLabelText('Выбрать сервер'));
|
||||||
return screen.findByRole('menu', { name: 'Мои серверы' });
|
return screen.findByRole('menu', { name: 'Мои серверы' });
|
||||||
@@ -210,7 +226,7 @@ describe('настройки серверов', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('сохраняет свой никнейм через PATCH /guilds/{id}/members/{me}', async () => {
|
it('сохраняет свой никнейм в «Профиль → Сервер» через PATCH /guilds/{id}/members/{me}', async () => {
|
||||||
const fetchMock = installFetch(
|
const fetchMock = installFetch(
|
||||||
serversRoutes([
|
serversRoutes([
|
||||||
{
|
{
|
||||||
@@ -221,7 +237,7 @@ describe('настройки серверов', () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
await openServerSettings();
|
await openProfileServerTab();
|
||||||
|
|
||||||
const nickname = await screen.findByLabelText('Никнейм');
|
const nickname = await screen.findByLabelText('Никнейм');
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -239,7 +255,7 @@ describe('настройки серверов', () => {
|
|||||||
expect(patch?.body).toEqual({ nickname: 'Алиса' });
|
expect(patch?.body).toEqual({ nickname: 'Алиса' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('применяет введённый ник ко всем серверам по очереди', async () => {
|
it('применяет введённый ник ко всем серверам по очереди (вкладка профиля)', async () => {
|
||||||
const fetchMock = installFetch(
|
const fetchMock = installFetch(
|
||||||
serversRoutes([
|
serversRoutes([
|
||||||
{
|
{
|
||||||
@@ -255,7 +271,7 @@ describe('настройки серверов', () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
await openServerSettings();
|
await openProfileServerTab();
|
||||||
|
|
||||||
const nickname = await screen.findByLabelText('Никнейм');
|
const nickname = await screen.findByLabelText('Никнейм');
|
||||||
await userEvent.clear(nickname);
|
await userEvent.clear(nickname);
|
||||||
@@ -387,7 +403,7 @@ describe('настройки серверов', () => {
|
|||||||
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await openServerSettings();
|
await openProfileServerTab();
|
||||||
|
|
||||||
const content = screen.getByTestId('settings-content');
|
const content = screen.getByTestId('settings-content');
|
||||||
expect(await within(content).findByText('Пока нет ни одного сервера')).toBeVisible();
|
expect(await within(content).findByText('Пока нет ни одного сервера')).toBeVisible();
|
||||||
|
|||||||
+213
-16
@@ -35,13 +35,46 @@ const sessions = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Ответы оболочки приложения: профиль, серверы, инстанс. */
|
/**
|
||||||
|
* Ответы оболочки приложения: профиль, серверы, инстанс. Дополнительные
|
||||||
|
* маршруты идут первыми — они должны побеждать при одинаковом шаблоне.
|
||||||
|
*/
|
||||||
function appRoutes(current = user, extra: FetchRoute[] = []): FetchRoute[] {
|
function appRoutes(current = user, extra: FetchRoute[] = []): FetchRoute[] {
|
||||||
return [
|
return [
|
||||||
|
...extra,
|
||||||
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
||||||
{ match: '/api/v1/users/@me', response: () => json({ user: current }) },
|
{ match: '/api/v1/users/@me', response: () => json({ user: current }) },
|
||||||
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||||
...extra,
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Сводка сервера для ответов `GET /guilds/{id}`. */
|
||||||
|
function guildDetail(id: string, name: string, ownerId: string, permissions: string[]) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description: '',
|
||||||
|
owner_id: ownerId,
|
||||||
|
is_main: false,
|
||||||
|
member_count: 1,
|
||||||
|
roles: [],
|
||||||
|
my_role_ids: [],
|
||||||
|
my_permissions: permissions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Серверы пользователя: «Альфа» своя, «Бета» с правом MANAGE_GUILD. */
|
||||||
|
function guildRoute(): FetchRoute[] {
|
||||||
|
const alpha = guildDetail('g-1', 'Альфа', 'user-1', []);
|
||||||
|
const beta = guildDetail('g-2', 'Бета', 'user-9', ['MANAGE_GUILD']);
|
||||||
|
return [
|
||||||
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [alpha, beta] }) },
|
||||||
|
{ match: '/api/v1/guilds/g-1/members', response: () => json({ members: [] }) },
|
||||||
|
{ match: '/api/v1/guilds/g-2/members', response: () => json({ members: [] }) },
|
||||||
|
{ match: '/api/v1/guilds/g-1/roles', response: () => json({ roles: [] }) },
|
||||||
|
{ match: '/api/v1/guilds/g-2/roles', response: () => json({ roles: [] }) },
|
||||||
|
{ match: '/api/v1/guilds/g-1', response: () => json({ guild: alpha }) },
|
||||||
|
{ match: '/api/v1/guilds/g-2', response: () => json({ guild: beta }) },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +85,16 @@ async function openSettings(): Promise<HTMLElement> {
|
|||||||
return screen.findByRole('dialog', { name: 'Настройки' });
|
return screen.findByRole('dialog', { name: 'Настройки' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Список пунктов окна настроек. */
|
||||||
|
function settingsNav(dialog: HTMLElement): HTMLElement {
|
||||||
|
return within(dialog).getByTestId('settings-nav');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Открывает пункт «Сервер» (администрирование) в левом меню настроек. */
|
||||||
|
async function openServerSection(dialog: HTMLElement): Promise<void> {
|
||||||
|
await userEvent.click(await within(settingsNav(dialog)).findByRole('button', { name: 'Сервер' }));
|
||||||
|
}
|
||||||
|
|
||||||
/** Переходит к пункту настроек по его названию в левом меню. */
|
/** Переходит к пункту настроек по его названию в левом меню. */
|
||||||
async function goToSection(dialog: HTMLElement, name: string): Promise<void> {
|
async function goToSection(dialog: HTMLElement, name: string): Promise<void> {
|
||||||
await userEvent.click(within(dialog).getByRole('button', { name }));
|
await userEvent.click(within(dialog).getByRole('button', { name }));
|
||||||
@@ -136,6 +179,41 @@ describe('настройки: окно и навигация', () => {
|
|||||||
expect(within(dialog).getByLabelText('Отображаемое имя')).toBeVisible();
|
expect(within(dialog).getByLabelText('Отображаемое имя')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('пункт «Сервер» видят только те, кто может управлять сервером', async () => {
|
||||||
|
installFetch(appRoutes());
|
||||||
|
|
||||||
|
renderApp('/app/empty');
|
||||||
|
const dialog = await openSettings();
|
||||||
|
|
||||||
|
// Ни своего сервера, ни прав администратора инстанса — пункта нет.
|
||||||
|
expect(within(settingsNav(dialog)).queryByRole('button', { name: 'Сервер' })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('владелец сервера открывает администрирование этого сервера', async () => {
|
||||||
|
installFetch(appRoutes(user, guildRoute()));
|
||||||
|
|
||||||
|
renderApp('/app/empty');
|
||||||
|
const dialog = await openSettings();
|
||||||
|
// Пункт появляется, когда снапшот сессии узнал серверы пользователя.
|
||||||
|
await openServerSection(dialog);
|
||||||
|
|
||||||
|
// Выбран сервер пользователя: его общие настройки и «Опасная зона».
|
||||||
|
expect(await within(dialog).findByLabelText('Выбрать сервер')).toHaveTextContent('Альфа');
|
||||||
|
expect(await within(dialog).findByLabelText('Название сервера')).toHaveValue('Альфа');
|
||||||
|
expect(within(dialog).getByRole('button', { name: 'Удалить сервер' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('администратор инстанса видит пункт «Сервер» без своих серверов', async () => {
|
||||||
|
installFetch(appRoutes(makeUser({ is_instance_admin: true })));
|
||||||
|
|
||||||
|
renderApp('/app/empty');
|
||||||
|
const dialog = await openSettings();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await within(settingsNav(dialog)).findByRole('button', { name: 'Сервер' }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
it('пункт «Инстанс» видят только администраторы', async () => {
|
it('пункт «Инстанс» видят только администраторы', async () => {
|
||||||
installFetch(appRoutes());
|
installFetch(appRoutes());
|
||||||
|
|
||||||
@@ -230,16 +308,27 @@ describe('настройки: старые адреса', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('/settings/servers открывает вкладку «Сервер»', async () => {
|
it('/settings/servers открывает пункт «Сервер» администрирования', async () => {
|
||||||
installFetch(appRoutes());
|
installFetch(appRoutes(user, guildRoute()));
|
||||||
|
|
||||||
renderApp('/settings/servers');
|
renderApp('/settings/servers');
|
||||||
|
|
||||||
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
||||||
expect(within(dialog).getByRole('tab', { name: 'Сервер' })).toHaveAttribute(
|
expect(
|
||||||
'aria-selected',
|
await within(settingsNav(dialog)).findByRole('button', { name: 'Сервер' }),
|
||||||
'true',
|
).toHaveAttribute('aria-current', 'true');
|
||||||
);
|
// Открылся первый сервер списка.
|
||||||
|
expect(await within(dialog).findByLabelText('Выбрать сервер')).toHaveTextContent('Альфа');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/settings/servers/g-2 открывает администрирование выбранного сервера', async () => {
|
||||||
|
installFetch(appRoutes(user, guildRoute()));
|
||||||
|
|
||||||
|
renderApp('/settings/servers/g-2');
|
||||||
|
|
||||||
|
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
||||||
|
expect(await within(dialog).findByLabelText('Выбрать сервер')).toHaveTextContent('Бета');
|
||||||
|
expect(await within(dialog).findByLabelText('Название сервера')).toHaveValue('Бета');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -324,29 +413,137 @@ describe('настройки: аватар', () => {
|
|||||||
expect(await screen.findByRole('alert')).toHaveTextContent('Файл больше допустимого размера');
|
expect(await screen.findByRole('alert')).toHaveTextContent('Файл больше допустимого размера');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('часовой пояс уходит в PATCH вместе с профилем', async () => {
|
it('профиль сохраняется без часового пояса и статуса: у них свои места', async () => {
|
||||||
const fetchMock = installFetch(
|
const fetchMock = installFetch(
|
||||||
appRoutes(user, [
|
appRoutes(user, [
|
||||||
{
|
{
|
||||||
match: '/api/v1/users/@me',
|
match: '/api/v1/users/@me',
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
response: () => json({ user: { ...user, timezone: 'Asia/Tokyo' } }),
|
response: () => json({ user }),
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
renderApp('/app/empty');
|
renderApp('/app/empty');
|
||||||
await openSettings();
|
const dialog = await openSettings();
|
||||||
|
|
||||||
await userEvent.selectOptions(screen.getByLabelText('Часовой пояс'), 'Asia/Tokyo');
|
expect(within(dialog).queryByLabelText('Часовой пояс')).toBeNull();
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Сохранить' }));
|
expect(within(dialog).queryByLabelText('Статус присутствия')).toBeNull();
|
||||||
|
// ID не показываем и не копируем в пользовательских настройках.
|
||||||
|
expect(within(dialog).queryByText('ID пользователя')).toBeNull();
|
||||||
|
expect(within(dialog).queryByRole('button', { name: 'Скопировать ID' })).toBeNull();
|
||||||
|
expect(within(dialog).getByLabelText('Отображаемое имя')).toHaveValue('Alice');
|
||||||
|
|
||||||
|
await userEvent.click(within(dialog).getByRole('button', { name: 'Сохранить' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(findRequest(fetchMock, { url: '/users/@me', method: 'PATCH' })?.body).toMatchObject({
|
expect(findRequest(fetchMock, { url: '/users/@me', method: 'PATCH' })?.body).toEqual({
|
||||||
timezone: 'Asia/Tokyo',
|
display_name: 'Alice',
|
||||||
|
bio: '',
|
||||||
|
custom_status: '',
|
||||||
|
custom_status_emoji: '',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(await screen.findByText('Профиль сохранён.')).toBeVisible();
|
});
|
||||||
|
|
||||||
|
it('часовой пояс есть ровно в одном пункте — «Язык и регион»', async () => {
|
||||||
|
installFetch(appRoutes());
|
||||||
|
|
||||||
|
renderApp('/app/empty');
|
||||||
|
const dialog = await openSettings();
|
||||||
|
|
||||||
|
for (const item of ['Профиль', 'Внешний вид', 'Уведомления', 'Аудио-видео']) {
|
||||||
|
await goToSection(dialog, item);
|
||||||
|
expect(within(dialog).queryByLabelText('Часовой пояс')).toBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
await goToSection(dialog, 'Язык и регион');
|
||||||
|
expect(within(dialog).getAllByLabelText('Часовой пояс')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('аватар стоит слева от полей, клик по нему открывает выбор файла', async () => {
|
||||||
|
const fetchMock = installFetch(
|
||||||
|
appRoutes(user, [
|
||||||
|
{
|
||||||
|
match: '/api/v1/users/@me/avatar',
|
||||||
|
method: 'POST',
|
||||||
|
response: () => json({ user: { ...user, avatar_file_id: 'file-9' } }),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderApp('/app/empty');
|
||||||
|
const dialog = await openSettings();
|
||||||
|
|
||||||
|
// Аватар — слева: кнопка смены идёт в DOM раньше поля имени.
|
||||||
|
const avatar = within(dialog).getByTestId('profile-avatar');
|
||||||
|
const displayName = within(dialog).getByLabelText('Отображаемое имя');
|
||||||
|
expect(avatar.compareDocumentPosition(displayName)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||||
|
|
||||||
|
// Подсказка о форматах — в title и доступном имени кнопки, а не текстом.
|
||||||
|
const change = within(dialog).getByRole('button', { name: /Сменить аватар/ });
|
||||||
|
expect(change).toHaveAttribute('title', expect.stringContaining('JPEG'));
|
||||||
|
expect(within(dialog).queryByText(/PNG, JPEG, WebP/)).toBeNull();
|
||||||
|
|
||||||
|
// Клик по аватару открывает выбор файла: он же отправляет multipart.
|
||||||
|
const input = within(dialog).getByLabelText('Выбрать файл аватара');
|
||||||
|
await userEvent.upload(input, new File(['x'], 'avatar.png', { type: 'image/png' }));
|
||||||
|
expect(change).toBeVisible();
|
||||||
|
expect(document.querySelector('input[type="file"]')).not.toBeNull();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(avatarForm(fetchMock, 'POST')).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('размер окна настроек фиксирован и не зависит от содержимого', async () => {
|
||||||
|
installFetch(appRoutes());
|
||||||
|
|
||||||
|
renderApp('/app/empty');
|
||||||
|
const dialog = await openSettings();
|
||||||
|
|
||||||
|
const panel = dialog;
|
||||||
|
const style = panel.getAttribute('style') ?? '';
|
||||||
|
expect(style).toContain('clamp(20rem, 70vw, 72rem)');
|
||||||
|
expect(style).toContain('clamp(22rem, 70vh, 52rem)');
|
||||||
|
expect(panel).toHaveClass('overflow-hidden');
|
||||||
|
expect(screen.getByTestId('settings-content')).toHaveClass('overflow-y-auto');
|
||||||
|
|
||||||
|
// Переключение пункта не меняет размеры окна.
|
||||||
|
const before = panel.getAttribute('style');
|
||||||
|
await goToSection(dialog, 'Безопасность');
|
||||||
|
expect(panel.getAttribute('style')).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ID пользователя в «Инстансе» виден администратору', async () => {
|
||||||
|
installFetch(
|
||||||
|
appRoutes(makeUser({ is_instance_admin: true }), [
|
||||||
|
{ match: '/api/v1/instance/settings', response: () => json({ settings: {} }) },
|
||||||
|
{ match: '/api/v1/instance/guilds', response: () => json({ guilds: [] }) },
|
||||||
|
{
|
||||||
|
match: '/api/v1/instance/users',
|
||||||
|
response: () =>
|
||||||
|
json({
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
id: 'u-42',
|
||||||
|
username: 'alice',
|
||||||
|
display_name: 'Alice',
|
||||||
|
is_instance_admin: true,
|
||||||
|
created_at: '2026-09-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ match: '/api/v1/instance/audit', response: () => json({ entries: [] }) },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
renderApp('/app/empty');
|
||||||
|
const dialog = await openSettings();
|
||||||
|
await goToSection(dialog, 'Инстанс');
|
||||||
|
|
||||||
|
expect(await within(dialog).findByTestId('instance-user-id-u-42')).toHaveTextContent('u-42');
|
||||||
|
expect(within(dialog).getByText(/ID пользователей видны только администратору/)).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,14 @@ describe('основной экран', () => {
|
|||||||
// «Покинуть сервер» из сайдбара убрано: выход живёт в «Опасной зоне».
|
// «Покинуть сервер» из сайдбара убрано: выход живёт в «Опасной зоне».
|
||||||
expect(screen.queryByRole('button', { name: 'Покинуть сервер' })).toBeNull();
|
expect(screen.queryByRole('button', { name: 'Покинуть сервер' })).toBeNull();
|
||||||
|
|
||||||
|
// Панель пользователя стоит под сайдбаром в левой колонке, а подсказка о
|
||||||
|
// выходе с сервера — внизу области контента.
|
||||||
|
const column = screen.getByTestId('app-sidebar-column');
|
||||||
|
const sidebar = screen.getByRole('complementary', { name: 'Комнаты' });
|
||||||
|
expect(column).toContainElement(panel);
|
||||||
|
expect(sidebar.compareDocumentPosition(panel)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||||
|
expect(screen.getByTestId('guild-leave-hint')).toHaveTextContent('Опасная зона');
|
||||||
|
|
||||||
// Фаза 2: вместо заглушки в области комнаты открывается чат.
|
// Фаза 2: вместо заглушки в области комнаты открывается чат.
|
||||||
expect(await screen.findByTestId('composer')).toBeVisible();
|
expect(await screen.findByTestId('composer')).toBeVisible();
|
||||||
expect(await screen.findByTestId('chat-start')).toHaveTextContent('Это начало комнаты #общий');
|
expect(await screen.findByTestId('chat-start')).toHaveTextContent('Это начало комнаты #общий');
|
||||||
@@ -155,7 +163,7 @@ describe('основной экран', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('шестерёнка настроек сервера видна владельцу и открывает окно настроек', async () => {
|
it('шестерёнка настроек сервера видна владельцу и открывает администрирование этого сервера', async () => {
|
||||||
const { default: userEvent } = await import('@testing-library/user-event');
|
const { default: userEvent } = await import('@testing-library/user-event');
|
||||||
const visitor = userEvent.setup();
|
const visitor = userEvent.setup();
|
||||||
installFetch([
|
installFetch([
|
||||||
@@ -194,10 +202,15 @@ describe('основной экран', () => {
|
|||||||
// Настройки — модальное окно: из комнаты никуда не уходим.
|
// Настройки — модальное окно: из комнаты никуда не уходим.
|
||||||
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
const dialog = await screen.findByRole('dialog', { name: 'Настройки' });
|
||||||
expect(router.state.location.pathname).toBe('/app/g-1/c-general');
|
expect(router.state.location.pathname).toBe('/app/g-1/c-general');
|
||||||
expect(await within(dialog).findByRole('tab', { name: 'Сервер' })).toHaveAttribute(
|
// Открылся пункт «Сервер» (администрирование) с этим же сервером.
|
||||||
'aria-selected',
|
const nav = within(dialog).getByTestId('settings-nav');
|
||||||
|
expect(within(nav).getByRole('button', { name: 'Сервер' })).toHaveAttribute(
|
||||||
|
'aria-current',
|
||||||
'true',
|
'true',
|
||||||
);
|
);
|
||||||
|
expect(await within(dialog).findByLabelText('Выбрать сервер')).toHaveTextContent(
|
||||||
|
'Основной сервер',
|
||||||
|
);
|
||||||
expect(await within(dialog).findByLabelText('Название сервера')).toHaveValue('Основной сервер');
|
expect(await within(dialog).findByLabelText('Название сервера')).toHaveValue('Основной сервер');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ describe('меню пользователя', () => {
|
|||||||
expect(within(menu).getByRole('menuitem', { name: 'Настройки аккаунта' })).toBeVisible();
|
expect(within(menu).getByRole('menuitem', { name: 'Настройки аккаунта' })).toBeVisible();
|
||||||
expect(within(menu).getByRole('menuitem', { name: 'Настройки серверов' })).toBeVisible();
|
expect(within(menu).getByRole('menuitem', { name: 'Настройки серверов' })).toBeVisible();
|
||||||
expect(within(menu).getByRole('menuitem', { name: 'Выйти' })).toBeVisible();
|
expect(within(menu).getByRole('menuitem', { name: 'Выйти' })).toBeVisible();
|
||||||
|
|
||||||
|
// ID пользователя в интерфейсе не показываем и не копируем.
|
||||||
|
expect(within(menu).queryByRole('menuitem', { name: 'Скопировать ID' })).toBeNull();
|
||||||
|
expect(menu).not.toHaveTextContent(user.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('шестерёнка открывает окно настроек', async () => {
|
it('шестерёнка открывает окно настроек', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user