diff --git a/web/src/components/friends/FriendsView.tsx b/web/src/components/friends/FriendsView.tsx index 762676a..9ab71d4 100644 --- a/web/src/components/friends/FriendsView.tsx +++ b/web/src/components/friends/FriendsView.tsx @@ -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() { ) : (
-

- {t('friends.friendsSection')} -

- {friends.length === 0 ? ( -

{t('friends.friendsEmpty')}

- ) : ( - - )} + {/* + Список свёрнут по умолчанию: раскрывается снизу вверх, состояние + переживает перезагрузку страницы (zustand + localStorage). + */} + +
+ {!listOpen ? null : friends.length === 0 ? ( +

{t('friends.friendsEmpty')}

+ ) : ( + + )} +
)} diff --git a/web/src/components/ui/Avatar.tsx b/web/src/components/ui/Avatar.tsx index e50a83d..0ede7ae 100644 --- a/web/src/components/ui/Avatar.tsx +++ b/web/src/components/ui/Avatar.tsx @@ -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 ( * { + 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` анимации становятся * мгновенными. Задержки каскада тоже обнуляем — иначе элементы списка diff --git a/web/src/lib/identity.ts b/web/src/lib/identity.ts index 42506d5..e5a3fa4 100644 --- a/web/src/lib/identity.ts +++ b/web/src/lib/identity.ts @@ -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); +} + /** * Может ли пользователь модерировать сообщения (удалять и закреплять чужие). * Права приходят на уровне сервера, поэтому это приближение: сервер всё равно diff --git a/web/src/pages/app/AppLayout.tsx b/web/src/pages/app/AppLayout.tsx index 1302fef..603ba7d 100644 --- a/web/src/pages/app/AppLayout.tsx +++ b/web/src/pages/app/AppLayout.tsx @@ -156,11 +156,21 @@ export default function AppLayout() { canCreate={canCreate} onCreate={openGuildCreate} /> - {isFriends ? ( - - ) : ( - - )} + {/* + Левая колонка: сайдбар раздела и панель пользователя под ним. + Ширину задаёт колонка, поэтому сайдбары её больше не дублируют. + */} +
+ {isFriends ? ( + + ) : ( + + )} + +
{/* Ключ по пути: смена комнаты/сервера появляется с анимацией. @@ -169,8 +179,16 @@ export default function AppLayout() { */}
+ {/* Подсказка про выход с сервера — внизу области контента. */} + {realGuildId === null ? null : ( +

+ {t('channels.leaveHint')} +

+ )}
-
diff --git a/web/src/pages/app/ChannelSidebar.tsx b/web/src/pages/app/ChannelSidebar.tsx index 49704d5..0ea6d23 100644 --- a/web/src/pages/app/ChannelSidebar.tsx +++ b/web/src/pages/app/ChannelSidebar.tsx @@ -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 ( -