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:
2026-09-20 11:24:09 +03:00
parent bd5f09336b
commit 4c8f888b3e
27 changed files with 1071 additions and 419 deletions
+44 -17
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useId, useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -12,6 +12,7 @@ import { SkeletonLines } from '@/components/ui/Skeleton';
import { Button } from '@/components/ui/primitives';
import { useFriendsStore } from '@/stores/friends';
import { useSessionStore } from '@/stores/session';
import { useUiStore } from '@/stores/ui';
/** Имя для заявки: список связей не хранит отдельного «ника». */
function displayName(user: RelationshipUser): string {
@@ -33,6 +34,9 @@ export function FriendsView() {
const load = useFriendsStore((state) => state.load);
const reload = useFriendsStore((state) => state.reload);
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);
@@ -176,22 +180,45 @@ export function FriendsView() {
</div>
) : (
<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>
{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>
)}
{/*
Список свёрнут по умолчанию: раскрывается снизу вверх, состояние
переживает перезагрузку страницы (zustand + localStorage).
*/}
<button
type="button"
aria-expanded={listOpen}
aria-controls={listId}
data-testid="friends-list-toggle"
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"
onClick={toggleFriendsList}
>
<span aria-hidden="true" className={listOpen ? 'gl-caret-open' : 'gl-caret'}>
▾
</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>
)}
+19 -5
View File
@@ -5,7 +5,9 @@ interface AvatarProps {
/** Стабильный id для оттенка заглушки. */
seed: string;
fileId?: string | undefined;
size?: 'sm' | 'md' | 'lg';
/** Локальный предпросмотр (свежевыбранный файл) вместо `fileId`. */
preview?: string | undefined;
size?: 'sm' | 'md' | 'lg' | 'xl';
/** Квадратная заглушка для серверов, круглая — для пользователей. */
shape?: 'circle' | 'square';
}
@@ -14,13 +16,25 @@ const sizes = {
sm: 'h-7 w-7 text-[11px]',
md: 'h-9 w-9 text-xs',
lg: 'h-14 w-14 text-lg',
/** Крупный аватар в настройках профиля: занимает родителя целиком. */
xl: 'h-full w-full text-4xl',
} 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 radius = shape === 'circle' ? 'rounded-full' : 'rounded-[var(--radius-md)]';
const radius = shape === 'circle' ? 'rounded-full' : 'rounded-[var(--radius-lg)]';
return (
<span
+9 -1
View File
@@ -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 { useTranslation } from 'react-i18next';
@@ -14,6 +14,12 @@ interface ModalProps {
size?: 'sm' | 'md' | 'lg' | 'xl';
/** Дополнительные классы панели (например, ограничение высоты). */
panelClassName?: string;
/**
* Инлайновые размеры панели. Нужны там, где размер считается от окна
* браузера (окно настроек занимает фиксированные 70% площади) и не должен
* зависеть от содержимого.
*/
panelStyle?: CSSProperties;
/** Дополнительные классы области содержимого (прокрутка длинных форм). */
bodyClassName?: string;
/**
@@ -54,6 +60,7 @@ export function Modal({
footer,
size = 'md',
panelClassName = '',
panelStyle,
bodyClassName = '',
portal = false,
}: ModalProps) {
@@ -144,6 +151,7 @@ export function Modal({
role="dialog"
aria-modal="true"
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)] ${
closing ? 'gl-pop-out' : 'gl-pop-in'
} ${panelClassName === '' ? 'p-5' : panelClassName}`}
+14 -15
View File
@@ -266,7 +266,8 @@
"more": "Actions: {{name}}",
"menu": "Actions for {{name}}",
"remove": "Remove friend",
"block": "Block"
"block": "Block",
"toggle": "Friends ({{count}})"
},
"lastSeen": {
"label": "Last seen",
@@ -406,7 +407,6 @@
"logout": "Sign out",
"loggingOut": "Signing out…",
"statusLabel": "Status: {{status}}",
"copyId": "Copy ID",
"status": {
"online": "Online",
"idle": "Idle",
@@ -463,11 +463,14 @@
"security": "Security",
"appearance": "Appearance",
"notifications": "Notifications",
"server": "Server",
"voice": "Audio & video",
"language": "Language & region",
"instance": "Instance"
},
"profile": {
"avatar.change": "Change avatar",
"avatar.uploading": "Uploading…",
"title": "Profile",
"description": "How other members see you.",
"displayName": "Display name",
@@ -477,11 +480,8 @@
"customStatusPlaceholder": "For example, “Writing code”",
"customStatusEmoji": "Status emoji",
"customStatusEmojiPlaceholder": "🚀",
"status": "Presence status",
"locale": "Interface language",
"saved": "Profile saved.",
"username": "Username",
"userId": "User ID",
"badges": "Badges",
"noBadges": "none",
"tabsLabel": "Profile tabs",
@@ -489,18 +489,15 @@
"main": "General",
"server": "Server"
},
"timezone": "Time zone",
"timezoneHint": "Used to show message times.",
"timezoneDefault": "default (MSK)",
"avatar": {
"title": "Avatar",
"hint": "Everyone can see this picture.",
"preview": "Avatar preview",
"choose": "Choose an avatar file",
"upload": "Upload avatar",
"change": "Change avatar",
"choose": "Choose avatar file",
"remove": "Remove avatar",
"limit": "Up to {{limit}}; PNG, JPEG, WebP or GIF.",
"tooLarge": "The file is larger than {{limit}} — pick a smaller image."
"formats": "JPEG, PNG, WebP, GIF",
"limit": "{{formats}} up to {{limit}}",
"tooLarge": "The file is larger than {{limit}} — pick a smaller image.",
"uploading": "Uploading…"
},
"decorations": {
"title": "Decorations",
@@ -568,13 +565,15 @@
"settingsEmpty": "No settings configured.",
"guilds": "Servers ({{count}})",
"guildsEmpty": "No servers.",
"userId": "User ID",
"users": "Users ({{count}})",
"usersEmpty": "No users.",
"audit": "Audit log (last {{count}})",
"auditEmpty": "No entries.",
"memberCount": "{{count}} members",
"adminBadge": "admin",
"mainBadge": "main"
"mainBadge": "main",
"usersIdHint": "User IDs are visible to the instance administrator only."
},
"servers": {
"pickerLabel": "My servers",
+13 -14
View File
@@ -266,7 +266,8 @@
"more": "Действия: {{name}}",
"menu": "Действия с {{name}}",
"remove": "Удалить из друзей",
"block": "Заблокировать"
"block": "Заблокировать",
"toggle": "Друзья ({{count}})"
},
"lastSeen": {
"label": "Последний вход",
@@ -406,7 +407,6 @@
"logout": "Выйти",
"loggingOut": "Выходим…",
"statusLabel": "Статус: {{status}}",
"copyId": "Скопировать ID",
"status": {
"online": "В сети",
"idle": "Неактивен",
@@ -463,11 +463,14 @@
"security": "Безопасность",
"appearance": "Внешний вид",
"notifications": "Уведомления",
"server": "Сервер",
"voice": "Аудио-видео",
"language": "Язык и регион",
"instance": "Инстанс"
},
"profile": {
"avatar.change": "Сменить аватар",
"avatar.uploading": "Загружаем…",
"title": "Профиль",
"description": "Как вас видят другие участники.",
"displayName": "Отображаемое имя",
@@ -477,11 +480,8 @@
"customStatusPlaceholder": "Например, «Пишу код»",
"customStatusEmoji": "Эмодзи состояния",
"customStatusEmojiPlaceholder": "🚀",
"status": "Статус присутствия",
"locale": "Язык интерфейса",
"saved": "Профиль сохранён.",
"username": "Имя пользователя",
"userId": "ID пользователя",
"badges": "Значки",
"noBadges": "нет",
"tabsLabel": "Вкладки профиля",
@@ -489,18 +489,15 @@
"main": "Основной",
"server": "Сервер"
},
"timezone": "Часовой пояс",
"timezoneHint": "По нему показывается время сообщений.",
"timezoneDefault": "по умолчанию (МСК)",
"avatar": {
"title": "Аватар",
"hint": "Картинку видят все участники.",
"preview": "Предпросмотр аватара",
"change": "Сменить аватар",
"choose": "Выбрать файл аватара",
"upload": "Загрузить аватар",
"remove": "Удалить аватар",
"limit": "До {{limit}}; PNG, JPEG, WebP или GIF.",
"tooLarge": "Файл больше {{limit}} — выберите картинку поменьше."
"formats": "JPEG, PNG, WebP, GIF",
"limit": "{{formats}} до {{limit}}",
"tooLarge": "Файл больше {{limit}} — выберите картинку поменьше.",
"uploading": "Загружаем…"
},
"decorations": {
"title": "Украшения",
@@ -568,13 +565,15 @@
"settingsEmpty": "Настройки не заданы.",
"guilds": "Серверы ({{count}})",
"guildsEmpty": "Серверов нет.",
"userId": "ID пользователя",
"users": "Пользователи ({{count}})",
"usersEmpty": "Пользователей нет.",
"audit": "Журнал аудита (последние {{count}})",
"auditEmpty": "Записей нет.",
"memberCount": "{{count}} участн.",
"adminBadge": "админ",
"mainBadge": "главный"
"mainBadge": "главный",
"usersIdHint": "ID пользователей видны только администратору инстанса."
},
"servers": {
"pickerLabel": "Мои серверы",
+48
View File
@@ -322,6 +322,54 @@ body {
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` анимации становятся
* мгновенными. Задержки каскада тоже обнуляем — иначе элементы списка
+13
View File
@@ -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);
}
/**
* Может ли пользователь модерировать сообщения (удалять и закреплять чужие).
* Права приходят на уровне сервера, поэтому это приближение: сервер всё равно
+24 -6
View File
@@ -156,11 +156,21 @@ export default function AppLayout() {
canCreate={canCreate}
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">
{/*
Ключ по пути: смена комнаты/сервера появляется с анимацией.
@@ -169,8 +179,16 @@ export default function AppLayout() {
*/}
<div key={location.pathname} className="gl-page relative min-h-0 flex-1 overflow-auto">
<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>
<UserPanel />
</div>
</div>
+13 -16
View File
@@ -13,7 +13,7 @@ import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { groupChannels } from '@/lib/channels';
import { canManageGuild, canManageGuildSettings } from '@/lib/identity';
import { canAdministerGuild, canManageGuild } from '@/lib/identity';
import { useCurrentUser } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { useUiStore } from '@/stores/ui';
@@ -127,25 +127,26 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
[channels],
);
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 =
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 — показываем скелетон вместо «пусто».
const channelsLoading = needsChannels && remoteChannels.isPending;
if (guildId === null) {
return (
<aside className="hidden w-60 shrink-0 border-r border-border/40 bg-surface-1 p-3 md:block" />
);
return <aside className="hidden min-h-0 flex-1 bg-surface-1 p-3 md:block" />;
}
return (
<aside
aria-label={t('channels.title')}
className="flex w-60 shrink-0 flex-col border-r border-border/40 bg-surface-1"
>
<aside aria-label={t('channels.title')} className="flex min-h-0 flex-1 flex-col bg-surface-1">
<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}>
{guild?.name ?? t('channels.notInGuild.title')}
@@ -156,8 +157,8 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
type="button"
aria-label={t('channels.settings')}
title={t('channels.settings')}
className="rounded-[var(--radius-sm)] px-1 text-lg leading-none text-fg-muted transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
onClick={() => openSettings({ section: 'profile', tab: 'server', guildId })}
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: 'server', guildId })}
>
⚙
</button>
@@ -217,10 +218,6 @@ export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
) : null}
</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
open={createOpen}
title={t('channels.create.title')}
+1 -1
View File
@@ -125,7 +125,7 @@ export function FriendsSidebar({ channelId }: { channelId: string | null }) {
return (
<aside
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">
<h1 className="truncate text-sm font-semibold">{t('friends.title')}</h1>
+5 -16
View File
@@ -87,7 +87,7 @@ function PanelControl({
aria-label={menuLabel}
title={menuLabel}
onClick={onOpenMenu}
className="rounded-[var(--radius-sm)] px-0.5 text-[9px] leading-none text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
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>
@@ -98,7 +98,7 @@ function PanelControl({
aria-label={toggleLabel}
title={toggleLabel}
onClick={onToggle}
className={`rounded-[var(--radius-sm)] px-1 py-1 text-sm leading-none transition-[color,background-color,transform] duration-[var(--duration-fast)] hover:bg-surface-3 active:scale-95 ${
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'
}`}
>
@@ -110,7 +110,7 @@ function PanelControl({
label={menuLabel}
anchorRef={anchorRef}
align="right"
className="w-72"
className="w-64 max-w-[70vw]"
onClose={onCloseMenu}
>
{children}
@@ -191,14 +191,6 @@ export function UserPanel() {
? t(`user.status.${status}`)
: 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(() => {
setOpenControl(null);
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"
>
<span className="relative shrink-0">
<Avatar name={displayName} seed={seed} fileId={avatarFileId} size="md" />
<Avatar name={displayName} seed={seed} fileId={avatarFileId} size="sm" />
<span
aria-hidden="true"
data-testid="user-status-dot"
@@ -334,7 +326,7 @@ export function UserPanel() {
type="button"
aria-label={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={() => {
setOpenControl(null);
goToSettings({ section: 'profile', tab: 'main' });
@@ -397,9 +389,6 @@ export function UserPanel() {
<MenuItem icon="🗄" onClick={() => goToSettings({ section: 'profile', tab: 'server' })}>
{t('user.menu.guilds')}
</MenuItem>
<MenuItem icon="#" onClick={onCopyId}>
{t('user.copyId')}
</MenuItem>
<MenuSeparator />
<MenuItem
danger
+1 -1
View File
@@ -19,4 +19,4 @@ export const ProfileSettingsPage = lazy(() => import('@/pages/settings/ProfileSe
export const SecuritySettingsPage = lazy(() => import('@/pages/settings/SecuritySettingsPage'));
export const AppearanceSettingsPage = lazy(() => import('@/pages/settings/AppearanceSettingsPage'));
export const InstanceSettingsPage = lazy(() => import('@/pages/settings/InstanceSettingsPage'));
export const ServersSettingsPage = lazy(() => import('@/pages/settings/ServersSettingsPage'));
export const ProfileServerTab = lazy(() => import('@/pages/settings/ProfileServerTab'));
+100
View File
@@ -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">
{t('settings.instance.users', { count: users.data?.length ?? 0 })}
</h3>
{/* ID пользователей — единственное место, где он виден: раздел открыт
только администратору инстанса. */}
<p className="mt-1 text-xs text-fg-muted">{t('settings.instance.usersIdHint')}</p>
{users.isPending ? <LoadingNotice /> : null}
{users.isError ? (
<ErrorNotice className="mt-3" error={users.error} onRetry={() => void users.refetch()} />
@@ -150,9 +153,16 @@ export default function InstanceSettingsPage() {
key={user.id}
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>
</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 ? (
<span className="shrink-0 rounded-full border border-accent/50 px-2 text-xs text-accent">
{t('settings.instance.adminBadge')}
@@ -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 { useTranslation } from 'react-i18next';
import {
fetchGuild,
fetchMembers,
guildMembersQueryKey,
guildQueryKey,
updateGuildMember,
} from '@/api/guilds';
import { fetchMembers, guildMembersQueryKey, updateGuildMember } from '@/api/guilds';
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
import { Avatar } from '@/components/ui/Avatar';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Menu, MenuGroupLabel, MenuItem } from '@/components/ui/Menu';
import { Card } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { canManageGuildSettings } from '@/lib/identity';
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 { GuildNicknameSection } from '@/pages/settings/GuildNicknameSection';
import { GuildRolesSection } from '@/pages/settings/GuildRolesSection';
import { GuildPicker } from '@/pages/settings/GuildPicker';
import { SettingsSection } from '@/pages/settings/SettingsSection';
/**
* Вкладка «Профиль → Сервер»: мой профиль на конкретном сервере и (для
* владельца и прав управления) администрирование сервера.
* Вкладка «Профиль → Сервер»: только личный ник на конкретном сервере и
* кнопка «Применить ко всем серверам». Администрирование сервера живёт в
* отдельном пункте «Сервер» (см. `GuildSettingsPage`) — так эта вкладка
* доступна и обычному участнику.
*
* Список серверов берём из READY-снапшота, а если шлюз ещё не подключился —
* из `GET /users/@me/guilds`. Выбранный сервер лежит в сторе сессии: его же
* сбрасывает «Опасная зона» после выхода с сервера.
*/
export function ServersSettingsPage() {
export function ProfileServerTab() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const currentUser = useCurrentUser();
@@ -83,16 +72,6 @@ export function ServersSettingsPage() {
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({
queryKey: guildMembersQueryKey(activeId ?? ''),
queryFn: ({ signal }) => fetchMembers(activeId ?? '', signal),
@@ -129,11 +108,7 @@ export function ServersSettingsPage() {
onSettled: () => setProgress(null),
});
const guild = detail.data;
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 fromSnapshot = sessionGuild?.my_nickname;
if (fromSnapshot !== undefined && fromSnapshot !== '') {
@@ -168,112 +143,28 @@ export function ServersSettingsPage() {
return (
<div className="flex flex-col gap-5">
<div className="relative">
<button
ref={pickerRef}
type="button"
aria-haspopup="menu"
aria-expanded={pickerOpen}
aria-label={t('settings.servers.selectLabel')}
onClick={() => setPickerOpen((value) => !value)}
className="flex w-full items-center gap-2 rounded-[var(--radius-md)] border border-border/60 bg-surface-1 px-3 py-2 text-left text-sm transition-colors duration-[var(--duration-fast)] hover:bg-surface-2"
>
{active === null ? null : (
<Avatar
name={active.name}
seed={active.id}
fileId={active.icon_file_id}
size="sm"
shape="square"
/>
)}
<span className="min-w-0 flex-1 truncate font-medium">
{active?.name ?? t('settings.servers.selectLabel')}
</span>
<span aria-hidden="true" className="text-fg-muted">
▾
</span>
</button>
<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>
<GuildPicker guilds={guilds} onSelect={selectSettingsGuild} />
{activeId === null ? (
<Card>
<p className="text-sm text-fg-muted">{t('settings.servers.notFound')}</p>
</Card>
) : detail.isPending ? (
<Card>
<SkeletonLines rows={5} />
</Card>
) : guild === undefined ? (
<Card>
<ErrorNotice error={detail.error} onRetry={() => void detail.refetch()} />
</Card>
) : (
<>
<GuildNicknameSection
guildId={guild.id}
userId={userId}
currentNickname={currentNickname}
members={members.data}
applyAll={{
pending: applyAll.isPending,
progress,
result: applyResult,
run: onApplyAll,
}}
/>
<GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} />
<GuildMembersSection
members={members.data}
isPending={members.isPending}
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}
/>
</>
<GuildNicknameSection
guildId={activeId}
userId={userId}
currentNickname={currentNickname}
members={members.data}
applyAll={{
pending: applyAll.isPending,
progress,
result: applyResult,
run: onApplyAll,
}}
/>
)}
</div>
);
}
export default ServersSettingsPage;
export default ProfileServerTab;
+119 -140
View File
@@ -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 { useTranslation } from 'react-i18next';
@@ -10,16 +10,14 @@ import {
currentUserQueryKey,
type UpdateProfileInput,
} from '@/api/users';
import { userStatuses, type User } from '@/api/types';
import type { User } from '@/api/types';
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 { Badge, Button, Card } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { supportedLanguages, type SupportedLanguage } from '@/i18n';
import { formatBytes } from '@/lib/format';
import { useCurrentUser } from '@/lib/hooks';
import { DEFAULT_TIMEZONE, timezoneChoices, timezoneLabel } from '@/lib/timezones';
import { useSessionStore } from '@/stores/session';
import { SettingsSection } from '@/pages/settings/SettingsSection';
@@ -73,6 +71,11 @@ function useAvatarPreview() {
* Форма профиля. Состояние инициализируется из `user` при монтировании,
* а после сохранения кэш React Query обновляется ответом сервера — поэтому
* синхронизация через эффект не нужна.
*
* Сверху формы — крупный аватар слева и поля справа на его высоту: имя,
* «О себе» и своё состояние с эмодзи. Статус присутствия и часовой пояс здесь
* намеренно отсутствуют (статус меняется в мини-профиле панели, часовой пояс —
* в разделе «Язык и регион»), чтобы настройки не дублировались.
*/
export function ProfileForm({ user }: { user: User }) {
const { t, i18n } = useTranslation();
@@ -83,22 +86,15 @@ export function ProfileForm({ user }: { user: User }) {
const [bio, setBio] = useState(user.bio);
const [customStatus, setCustomStatus] = useState(user.custom_status);
const [customEmoji, setCustomEmoji] = useState(user.custom_status_emoji);
const [status, setStatus] = useState<string>(user.status);
const [tz, setTz] = useState(user.timezone ?? DEFAULT_TIMEZONE);
const [language, setLanguage] = useState<SupportedLanguage>(user.locale === 'en' ? 'en' : 'ru');
const [saved, setSaved] = useState(false);
const [avatarError, setAvatarError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const avatar = useAvatarPreview();
/** Профиль из ответа сервера: кэш, снапшот сессии и язык интерфейса. */
/** Профиль из ответа сервера: кэш и снапшот сессии. */
const applyUser = (updated: User): void => {
queryClient.setQueryData(currentUserQueryKey, updated);
useSessionStore.getState().patchUser(updated);
if (updated.locale === 'en' || updated.locale === 'ru') {
window.localStorage.setItem('glchat.language', updated.locale);
void i18n.changeLanguage(updated.locale);
}
};
const save = useMutation({
@@ -154,21 +150,29 @@ export function ProfileForm({ user }: { user: User }) {
upload.mutate(file);
};
const openFilePicker = useCallback(() => {
fileInputRef.current?.click();
}, []);
const onSubmit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
setSaved(false);
// Часовой пояс и статус присутствия форма не трогает: у них свои места.
save.mutate({
display_name: displayName.trim(),
bio: bio.trim(),
custom_status: customStatus.trim(),
custom_status_emoji: customEmoji.trim(),
status,
locale: language,
timezone: tz,
});
};
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 (
<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>
<form className="mt-4 flex flex-col gap-4" onSubmit={onSubmit} noValidate>
<Field
label={t('settings.profile.displayName')}
name="display_name"
value={displayName}
maxLength={64}
onChange={(event) => setDisplayName(event.target.value)}
/>
<TextAreaField
label={t('settings.profile.bio')}
placeholder={t('settings.profile.bioPlaceholder')}
name="bio"
rows={4}
maxLength={512}
value={bio}
onChange={(event) => setBio(event.target.value)}
/>
<div className="grid gap-4 sm:grid-cols-[1fr_6rem]">
<Field
label={t('settings.profile.customStatus')}
placeholder={t('settings.profile.customStatusPlaceholder')}
name="custom_status"
maxLength={128}
value={customStatus}
onChange={(event) => setCustomStatus(event.target.value)}
/>
<Field
label={t('settings.profile.customStatusEmoji')}
placeholder={t('settings.profile.customStatusEmojiPlaceholder')}
name="custom_status_emoji"
maxLength={8}
value={customEmoji}
onChange={(event) => setCustomEmoji(event.target.value)}
/>
{/* Аватар слева, поля справа — на его высоту. */}
<div className="grid gap-4 sm:grid-cols-[auto_minmax(0,1fr)]">
<div className="min-h-36 sm:min-h-40">
<div className="group relative h-full w-36 sm:w-40" data-testid="profile-avatar">
<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}
/>
<Avatar
name={avatarName}
seed={user.id}
fileId={user.avatar_file_id}
preview={avatar.preview ?? undefined}
size="xl"
shape="square"
/>
{/*
Клик по аватару открывает выбор файла, поэтому кнопка лежит
поверх картинки; «Удалить» — отдельная кнопка рядом.
*/}
<button
type="button"
disabled={avatarBusy}
onClick={openFilePicker}
title={avatarHint}
aria-label={`${t('settings.profile.avatar.change')}. ${avatarHint}`}
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"
>
{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>
<SelectField
label={t('settings.profile.status')}
value={status}
onChange={setStatus}
options={userStatuses.map((value) => ({
value,
label: t(`user.status.${value}`),
}))}
/>
<SelectField
label={t('settings.profile.timezone')}
hint={t('settings.profile.timezoneHint')}
value={tz}
onChange={setTz}
options={timezoneChoices(tz).map((zone) => ({
value: zone,
label:
zone === DEFAULT_TIMEZONE
? `${timezoneLabel(zone, locale)} · ${t('settings.profile.timezoneDefault')}`
: timezoneLabel(zone, locale),
}))}
/>
<SelectField
label={t('settings.profile.locale')}
value={language}
onChange={(value) => setLanguage(value === 'en' ? 'en' : 'ru')}
options={supportedLanguages.map((value) => ({
value,
label: t(`language.${value}`),
}))}
/>
<dl className="text-sm text-fg-muted">
<div className="flex justify-between gap-4 border-b border-border/40 py-2">
<dt>{t('settings.profile.username')}</dt>
<dd className="font-medium text-fg">{user.username}</dd>
</div>
<div className="flex justify-between gap-4 border-b border-border/40 py-2">
<dt>{t('settings.profile.userId')}</dt>
<dd className="font-mono text-xs text-fg">{user.id}</dd>
</div>
<div className="flex justify-between gap-4 py-2">
<dt>{t('settings.profile.badges')}</dt>
<dd className="text-fg">
@@ -260,6 +288,13 @@ export function ProfileForm({ user }: { user: User }) {
</div>
</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}
{saved && !save.isError ? (
<p className="text-sm text-success" role="status">
@@ -275,62 +310,6 @@ export function ProfileForm({ user }: { user: User }) {
</form>
</Card>
<SettingsSection
title={t('settings.profile.avatar.title')}
description={t('settings.profile.avatar.hint')}
>
<div className="mt-4 flex flex-wrap items-center gap-4">
{avatar.preview === null ? (
<Avatar name={avatarName} seed={user.id} fileId={user.avatar_file_id} size="lg" />
) : (
<img
src={avatar.preview}
alt={t('settings.profile.avatar.preview')}
className="h-14 w-14 rounded-full object-cover"
/>
)}
<div className="flex flex-col items-start gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
aria-label={t('settings.profile.avatar.choose')}
className="sr-only"
onChange={onPickFile}
/>
<Button
variant="ghost"
disabled={upload.isPending}
onClick={() => fileInputRef.current?.click()}
>
{t(upload.isPending ? 'common.saving' : 'settings.profile.avatar.upload')}
</Button>
{user.avatar_file_id === undefined ? null : (
<Button
variant="ghost"
disabled={removeAvatar.isPending}
onClick={() => removeAvatar.mutate()}
>
{t('settings.profile.avatar.remove')}
</Button>
)}
<p className="text-xs text-fg-muted">
{t('settings.profile.avatar.limit', {
limit: formatBytes(AVATAR_MAX_BYTES, locale),
})}
</p>
</div>
</div>
{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
title={t('settings.profile.decorations.title')}
description={t('settings.profile.decorations.hint')}
+36 -4
View File
@@ -1,15 +1,18 @@
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { canAdministerGuild } from '@/lib/identity';
import { useCurrentUser } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { useUiStore, type SettingsSection } from '@/stores/ui';
import AppearanceSettingsPage from '@/pages/settings/AppearanceSettingsPage';
import GuildSettingsPage from '@/pages/settings/GuildSettingsPage';
import InstanceSettingsPage from '@/pages/settings/InstanceSettingsPage';
import { LanguageRegionSection } from '@/pages/settings/LanguageRegionSection';
import { NotificationsSettingsSection } from '@/pages/settings/NotificationsSettingsSection';
import ProfileServerTab from '@/pages/settings/ProfileServerTab';
import ProfileSettingsPage from '@/pages/settings/ProfileSettingsPage';
import SecuritySettingsPage from '@/pages/settings/SecuritySettingsPage';
import { ServersSettingsPage } from '@/pages/settings/ServersSettingsPage';
import { VoiceSettingsSection } from '@/pages/settings/VoiceSettingsSection';
/** Вкладки пункта «Профиль»: глобальный профиль и профиль на сервере. */
@@ -48,16 +51,32 @@ function ProfileTabs() {
))}
</div>
{tab === 'main' ? <ProfileSettingsPage /> : <ServersSettingsPage />}
{tab === 'main' ? <ProfileSettingsPage /> : <ProfileServerTab />}
</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/*`.
*
* Размер окна фиксирован (70% ширины и высоты окна браузера): при переключении
* пунктов оно не подстраивается под содержимое, а прокручивается изнутри.
* Окно живёт в портале: панель пользователя — узкая колонка, и портал снимает
* вопросы к её `overflow` и анимационным трансформациям.
*/
@@ -69,6 +88,9 @@ export function SettingsModal() {
const closeSettings = useUiStore((state) => state.closeSettings);
const currentUser = useCurrentUser();
const isAdmin = currentUser.data?.is_instance_admin === true;
const canManageGuilds = useCanManageGuilds();
const selectedGuildId = useSessionStore((state) => state.settingsGuildId);
const items: { id: SettingsSection; label: string }[] = [
{ id: 'profile', label: t('settings.tabs.profile') },
@@ -77,6 +99,7 @@ export function SettingsModal() {
{ id: 'notifications', label: t('settings.tabs.notifications') },
{ id: 'voice', label: t('settings.tabs.voice') },
{ 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') }] : []),
];
@@ -84,15 +107,23 @@ export function SettingsModal() {
<Modal
open={open}
title={t('settings.title')}
size="xl"
portal
panelClassName="flex max-h-[85vh] flex-col"
panelClassName="flex flex-col overflow-hidden"
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}
>
<nav
aria-label={t('settings.navLabel')}
className="flex w-40 shrink-0 flex-col gap-0.5 overflow-y-auto border-r border-border/40 pr-2"
data-testid="settings-nav"
>
{items.map((item) => (
<button
@@ -123,6 +154,7 @@ export function SettingsModal() {
{section === 'notifications' ? <NotificationsSettingsSection /> : null}
{section === 'voice' ? <VoiceSettingsSection /> : null}
{section === 'language' ? <LanguageRegionSection /> : null}
{section === 'server' ? <GuildSettingsPage key={selectedGuildId ?? 'none'} /> : null}
{section === 'instance' ? <InstanceSettingsPage /> : null}
{section === 'profile' ? <ProfileTabs /> : null}
</div>
+2 -2
View File
@@ -13,9 +13,9 @@ export function parseSettingsPath(pathname: string): SettingsTarget {
const rest = segments.slice(1);
if (rest[0] === 'servers') {
// Старые адреса серверов теперь ведут в администрирование сервера.
return {
section: 'profile',
tab: 'server',
section: 'server',
guildId: rest[1] === undefined ? null : decodeURIComponent(rest[1]),
};
}
+3 -3
View File
@@ -13,9 +13,9 @@ import {
NoGuildView,
OnboardingPage,
ProfileSettingsPage,
ProfileServerTab,
RegisterPage,
SecuritySettingsPage,
ServersSettingsPage,
SettingsLayout,
StatusPage,
} from '@/pages/lazyPages';
@@ -73,8 +73,8 @@ export const routes = [
],
},
// Настройки пользовательских серверов: /settings/servers/:guildId
{ path: 'servers', element: <ServersSettingsPage /> },
{ path: 'servers/:guildId', element: <ServersSettingsPage /> },
{ path: 'servers', element: <ProfileServerTab /> },
{ path: 'servers/:guildId', element: <ProfileServerTab /> },
// Старые пути Фазы 1: закладки и внешние ссылки продолжают работать.
{ path: 'profile', element: <Navigate to="/settings/account/profile" replace /> },
{ path: 'security', element: <Navigate to="/settings/account/security" replace /> },
+35 -1
View File
@@ -12,6 +12,8 @@ export const settingsSections = [
'notifications',
'voice',
'language',
/** Администрирование выбранного сервера. */
'server',
'instance',
] as const;
export type SettingsSection = (typeof settingsSections)[number];
@@ -24,7 +26,7 @@ export type ProfileTab = (typeof profileTabs)[number];
export interface SettingsTarget {
section?: SettingsSection;
tab?: ProfileTab;
/** Сервер для вкладки «Профиль → Сервер». */
/** Сервер для пункта «Сервер» и вкладки «Профиль → Сервер». */
guildId?: string | null;
}
@@ -44,13 +46,20 @@ interface UiState {
settingsOpen: boolean;
settingsSection: SettingsSection;
settingsProfileTab: ProfileTab;
/**
* Развёрнут ли список друзей во вкладке «Друзья». Свёрнут по умолчанию, а
* выбор пользователя переживает перезагрузку страницы.
*/
friendsListOpen: boolean;
openSettings: (target?: SettingsTarget) => void;
closeSettings: () => void;
setSettingsSection: (section: SettingsSection) => void;
setSettingsProfileTab: (tab: ProfileTab) => void;
toggleFriendsList: () => void;
}
const STORAGE_KEY = 'glchat.theme';
const FRIENDS_KEY = 'glchat.friends.expanded';
function initialTheme(): Theme {
if (typeof window === 'undefined') {
@@ -63,6 +72,14 @@ function initialTheme(): Theme {
return 'dark';
}
/** Список друзей свёрнут, пока пользователь сам его не раскрыл. */
function initialFriendsListOpen(): boolean {
if (typeof window === 'undefined') {
return false;
}
return window.localStorage.getItem(FRIENDS_KEY) === 'true';
}
export function applyTheme(theme: Theme): void {
if (typeof document === 'undefined') {
return;
@@ -71,6 +88,8 @@ export function applyTheme(theme: Theme): void {
}
export const useUiStore = create<UiState>((set, get) => ({
friendsListOpen: initialFriendsListOpen(),
theme: initialTheme(),
setTheme: (theme) => {
if (typeof window !== 'undefined') {
@@ -101,6 +120,10 @@ export const useUiStore = create<UiState>((set, get) => ({
settingsOpen: true,
...(target?.section === undefined ? {} : { settingsSection: target.section }),
...(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 }),
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;