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:
@@ -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,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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user