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
+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