Files
glchat/web/src/pages/app/ChannelSidebar.tsx
T

276 lines
9.9 KiB
TypeScript
Raw Normal View History

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, leaveGuild } from '@/api/guilds';
import { myGuildsQueryKey } from '@/api/users';
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 { canManageGuild } from '@/lib/identity';
import { useCurrentUser } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import type { Channel } from '@/api/types';
interface ChannelSidebarProps {
guildId: string | null;
channelId: string | null;
}
interface ChannelGroup {
category: Channel | null;
channels: Channel[];
}
/** Группирует комнаты по категориям, сохраняя порядок `position`. */
export function groupChannels(channels: Channel[]): ChannelGroup[] {
const visible = channels
.filter((channel) => channel.can_view !== false && channel.type !== 'category')
.slice()
.sort((left, right) => left.position - right.position);
const categories = channels
.filter((channel) => channel.type === 'category')
.slice()
.sort((left, right) => left.position - right.position);
const groups: ChannelGroup[] = [];
const uncategorized = visible.filter((channel) => channel.parent_id === undefined);
if (uncategorized.length > 0) {
groups.push({ category: null, channels: uncategorized });
}
for (const category of categories) {
const children = visible.filter((channel) => channel.parent_id === category.id);
if (children.length > 0) {
groups.push({ category, channels: children });
}
}
return groups;
}
/** Сайдбар комнат выбранного сервера: категории, текстовые и голосовые комнаты. */
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 removeGuild = useSessionStore((state) => state.removeGuild);
const [createOpen, setCreateOpen] = useState(false);
const [channelName, setChannelName] = useState('');
const [channelType, setChannelType] = useState<'text' | 'voice'>('text');
const [parentId, setParentId] = useState('');
const channels = guild?.channels ?? [];
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 leave = useMutation({
mutationFn: () => leaveGuild(guildId ?? ''),
onSuccess: () => {
if (guildId !== null) {
removeGuild(guildId);
}
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
void navigate('/app/empty', { replace: true });
},
});
const groups = useMemo(() => groupChannels(channels), [channels]);
const categories = channels.filter((channel) => channel.type === 'category');
const canManage =
guild !== null && canManageGuild(guild.my_permissions, guild.owner_id, currentUser.data?.id);
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
aria-label={t('channels.title')}
className="flex w-60 shrink-0 flex-col border-r border-border/40 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>
{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 hover:text-fg"
onClick={() => setCreateOpen(true)}
>
+
</button>
) : null}
</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>
) : 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="flex flex-col gap-0.5">
{group.channels.map((channel) => (
<li key={channel.id}>
<NavLink
to={`/app/${guildId}/${channel.id}`}
aria-current={channel.id === channelId ? 'page' : undefined}
title={channel.name}
className={`flex items-center gap-2 truncate rounded-[var(--radius-sm)] px-2 py-1 text-sm ${
channel.id === channelId
? 'bg-surface-3 text-fg'
: 'text-fg-muted hover:bg-surface-2 hover:text-fg'
}`}
>
<span aria-hidden="true">{channel.type === 'voice' ? '🔊' : '#'}</span>
<span className="truncate">{channel.name}</span>
</NavLink>
</li>
))}
</ul>
</section>
))}
</nav>
)}
{remoteChannels.isError ? (
<ErrorNotice
className="mt-2"
error={remoteChannels.error}
onRetry={() => void remoteChannels.refetch()}
/>
) : null}
</div>
<footer className="border-t border-border/40 px-2 py-2">
<Button
variant="ghost"
className="w-full"
disabled={leave.isPending}
onClick={() => {
if (window.confirm(t('guilds.leaveConfirm', { name: guild?.name ?? '' }))) {
leave.mutate();
}
}}
>
{t('guilds.leave')}
</Button>
{leave.isError ? <ErrorNotice className="mt-2" error={leave.error} /> : null}
</footer>
<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>
);
}