276 lines
9.9 KiB
TypeScript
276 lines
9.9 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, 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>
|
||
|
|
);
|
||
|
|
}
|