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>
)}