Files
glchat/web/src/pages/app/ChannelSidebar.tsx
T
grendervill 4c8f888b3e feat(web): профиль с аватаром, панель в углу, настройки сервера, скрытые ID
Правки пользователя:

- в «Профиль → Основной» сверху квадратный аватар слева, справа от него
  настройки имени, «О себе», состояния и эмодзи во всю высоту; смена аватара —
  клик по нему, форматы (JPEG, PNG, WebP, GIF до 8 МБ) — в подсказке;
- убраны дубли: часовой пояс только в «Язык и регион», статус — только в
  мини-профиле панели;
- панель пользователя переехала в нижний левый угол под сайдбар, подсказка о
  выходе из сервера — на её прежнее место; панель ужата под 240 px;
- шестерёнка сервера открывает новый пункт настроек «Сервер» (список серверов,
  общее, участники, роли, опасная зона), а «Профиль → Сервер» оставлен только
  для ника и «Применить ко всем серверам»;
- окно настроек получило статический размер 70% рабочей области со скроллом;
- ID пользователей скрыты везде, кроме раздела «Инстанс» у администратора;
- список друзей свёрнут под кнопкой «Друзья (N)» и раскрывается снизу вверх;
- 249 тестов Vitest (было 239), check/lint/test/build зелёные.
2026-09-20 11:24:09 +03:00

284 lines
11 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { NavLink, useNavigate } from 'react-router';
import { createChannel, fetchChannels, guildChannelsQueryKey } from '@/api/guilds';
import type { Channel } from '@/api/types';
import { ChannelBadge } from '@/components/chat/ChannelBadge';
import { useChannelUnread } from '@/components/chat/useChannelUnread';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Field, SelectField } from '@/components/ui/Field';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { groupChannels } from '@/lib/channels';
import { canAdministerGuild, canManageGuild } from '@/lib/identity';
import { useCurrentUser } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { useUiStore } from '@/stores/ui';
interface ChannelSidebarProps {
guildId: string | null;
channelId: string | null;
}
/**
* Ссылка на комнату с бейджем непрочитанного. Отдельный компонент нужен,
* чтобы подписка на стор сообщений жила на каждую комнату, а не на весь
* сайдбар (иначе любое новое сообщение перерисовывало бы список целиком).
*/
function ChannelLink({
guildId,
channel,
selected,
}: {
guildId: string | null;
channel: Channel;
selected: boolean;
}) {
const { t } = useTranslation();
const { unread, mentions } = useChannelUnread(channel.id);
const highlighted = !selected && (unread > 0 || mentions > 0);
// aria-label ссылки перекрывает её содержимое, поэтому счётчики озвучиваем здесь.
const label =
mentions > 0
? t('channels.selectMentions', { name: channel.name, count: mentions })
: unread > 0
? t('channels.selectUnread', { name: channel.name, count: unread })
: t('channels.select', { name: channel.name });
return (
<NavLink
to={`/app/${guildId ?? ''}/${channel.id}`}
aria-current={selected ? 'page' : undefined}
aria-label={label}
title={channel.name}
className={`relative flex items-center gap-2 truncate rounded-[var(--radius-sm)] px-2 py-1 text-sm transition-colors duration-[var(--duration-fast)] ${
selected
? 'gl-indicator bg-surface-3 text-fg'
: 'text-fg-muted hover:bg-surface-2 hover:text-fg'
} ${highlighted ? 'font-semibold text-fg' : ''}`}
>
<span aria-hidden="true">{channel.type === 'voice' ? '🔊' : '#'}</span>
<span className="truncate">{channel.name}</span>
<ChannelBadge channelId={channel.id} selected={selected} />
</NavLink>
);
}
/** Сайдбар комнат выбранного сервера: категории, текстовые и голосовые комнаты. */
export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const currentUser = useCurrentUser();
const guild = useSessionStore(
useCallback((state) => state.guilds.find((item) => item.id === guildId) ?? null, [guildId]),
);
const setGuildChannels = useSessionStore((state) => state.setGuildChannels);
const upsertChannel = useSessionStore((state) => state.upsertChannel);
const openSettings = useUiStore((state) => state.openSettings);
const [createOpen, setCreateOpen] = useState(false);
const [channelName, setChannelName] = useState('');
const [channelType, setChannelType] = useState<'text' | 'voice'>('text');
const [parentId, setParentId] = useState('');
const channels = useMemo(() => guild?.channels ?? [], [guild]);
const needsChannels = guildId !== null && guild !== null && channels.length === 0;
const remoteChannels = useQuery({
queryKey: guildChannelsQueryKey(guildId ?? ''),
queryFn: ({ signal }) => fetchChannels(guildId ?? '', signal),
enabled: needsChannels,
retry: 0,
});
useEffect(() => {
if (guildId === null || remoteChannels.data === undefined) {
return;
}
setGuildChannels(guildId, remoteChannels.data);
}, [guildId, remoteChannels.data, setGuildChannels]);
const create = useMutation({
mutationFn: () =>
createChannel(guildId ?? '', {
name: channelName.trim(),
type: channelType,
...(parentId === '' ? {} : { parent_id: parentId }),
}),
onSuccess: (channel) => {
if (guildId !== null) {
upsertChannel(guildId, channel);
}
setCreateOpen(false);
setChannelName('');
void queryClient.invalidateQueries({ queryKey: guildChannelsQueryKey(guildId ?? '') });
void navigate(`/app/${guildId ?? ''}/${channel.id}`);
},
});
const groups = useMemo(() => groupChannels(channels), [channels]);
const categories = useMemo(
() => channels.filter((channel) => channel.type === 'category'),
[channels],
);
const canManage =
guild !== null &&
canManageGuild(guild.my_permissions ?? [], guild.owner_id, currentUser.data?.id);
// Шестерёнка настроек сервера — только владельцу и правам управления.
const canManageSettings =
guild !== null &&
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 min-h-0 flex-1 bg-surface-1 p-3 md:block" />;
}
return (
<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')}
</h1>
<div className="flex items-center gap-1">
{canManageSettings ? (
<button
type="button"
aria-label={t('channels.settings')}
title={t('channels.settings')}
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>
) : null}
{canManage ? (
<button
type="button"
aria-label={t('channels.add')}
title={t('channels.add')}
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={() => setCreateOpen(true)}
>
+
</button>
) : null}
</div>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
{guild === null ? (
<p className="px-2 text-sm text-fg-muted">{t('channels.notInGuild.body')}</p>
) : channelsLoading ? (
<SkeletonLines className="px-2" rows={5} />
) : groups.length === 0 ? (
<p className="px-2 text-sm text-fg-muted">{t('channels.empty')}</p>
) : (
<nav aria-label={t('channels.title')}>
{groups.map((group) => (
<section key={group.category?.id ?? 'uncategorized'} className="mb-3">
{group.category === null ? null : (
<h2 className="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-fg-muted">
{group.category.name}
</h2>
)}
<ul className="gl-stagger flex flex-col gap-0.5">
{group.channels.map((channel) => (
<li key={channel.id}>
<ChannelLink
guildId={guildId}
channel={channel}
selected={channel.id === channelId}
/>
</li>
))}
</ul>
</section>
))}
</nav>
)}
{remoteChannels.isError ? (
<ErrorNotice
className="mt-2"
error={remoteChannels.error}
onRetry={() => void remoteChannels.refetch()}
/>
) : null}
</div>
<Modal
open={createOpen}
title={t('channels.create.title')}
onClose={() => setCreateOpen(false)}
footer={
<>
<Button variant="ghost" onClick={() => setCreateOpen(false)}>
{t('common.cancel')}
</Button>
<Button
onClick={() => create.mutate()}
disabled={create.isPending || channelName.trim().length === 0}
>
{t(create.isPending ? 'common.creating' : 'channels.create.submit')}
</Button>
</>
}
>
<form
className="flex flex-col gap-3"
onSubmit={(event) => {
event.preventDefault();
if (channelName.trim() !== '') {
create.mutate();
}
}}
>
<Field
label={t('channels.create.name')}
placeholder={t('channels.create.namePlaceholder')}
value={channelName}
onChange={(event) => setChannelName(event.target.value)}
maxLength={64}
/>
<SelectField
label={t('channels.create.type')}
value={channelType}
onChange={(value) => setChannelType(value === 'voice' ? 'voice' : 'text')}
options={[
{ value: 'text', label: t('channels.create.typeText') },
{ value: 'voice', label: t('channels.create.typeVoice') },
]}
/>
{categories.length === 0 ? null : (
<SelectField
label={t('channels.create.parent')}
value={parentId}
onChange={setParentId}
options={[
{ value: '', label: t('channels.create.noParent') },
...categories.map((category) => ({
value: category.id,
label: category.name,
})),
]}
/>
)}
{create.isError ? <ErrorNotice error={create.error} /> : null}
</form>
</Modal>
</aside>
);
}