Files
glchat/web/src/pages/settings/GuildSettingsPage.tsx
T

247 lines
8.6 KiB
TypeScript
Raw Normal View History

import { useEffect, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { fetchGuild, fetchMembers, guildMembersQueryKey, guildQueryKey } from '@/api/guilds';
import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
import { ErrorNotice } from '@/components/ui/ErrorNotice';
import { Card } from '@/components/ui/primitives';
import { SkeletonLines } from '@/components/ui/Skeleton';
import { GuildAuditSection, GuildBansSection } from '@/pages/settings/GuildModerationSections';
import {
canAdministerGuild,
canBanMembers,
canManageEmojis,
canManageRoles,
canManageSounds,
canViewAuditLog,
} from '@/lib/identity';
import { useCurrentUser } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
import { GuildAppearanceSection } from '@/pages/settings/GuildAppearanceSection';
import { GuildCosmeticsSection } from '@/pages/settings/GuildCosmeticsSection';
import { GuildDangerSection } from '@/pages/settings/GuildDangerSection';
import { GuildEmojisSection } from '@/pages/settings/GuildEmojisSection';
import { GuildGeneralSection } from '@/pages/settings/GuildGeneralSection';
import { GuildMembersSection } from '@/pages/settings/GuildMembersSection';
import { GuildPicker } from '@/pages/settings/GuildPicker';
import { GuildRolesSection } from '@/pages/settings/GuildRolesSection';
import { GuildSoundsSection } from '@/pages/settings/GuildSoundsSection';
/**
* Пункт «Сервер» окна настроек: администрирование одного сервера —
* общее (имя и описание), участники, роли, эмодзи, звуки и «Опасная зона».
*
* В отличие от вкладки «Профиль → Сервер» (там только свой никнейм), сюда
* попадают через шестерёнку в шапке сервера или через пункт меню настроек.
* Сервер можно переключить тем же выпадающим списком.
*/
export function GuildSettingsPage() {
const sessionGuilds = useSessionStore((state) => state.guilds);
const upsertGuild = useSessionStore((state) => state.upsertGuild);
const selectSettingsGuild = useSessionStore((state) => state.selectSettingsGuild);
// Подстраховка снапшота: без неё выбранный сервер не найти в сторе.
const myGuilds = useQuery({
queryKey: myGuildsQueryKey,
queryFn: ({ signal }) => fetchMyGuilds(signal),
retry: 0,
enabled: sessionGuilds.length === 0,
});
useEffect(() => {
if (sessionGuilds.length > 0 || myGuilds.data === undefined) {
return;
}
for (const guild of myGuilds.data) {
upsertGuild(guild);
}
}, [sessionGuilds.length, myGuilds.data, upsertGuild]);
const guilds = useMemo(
() => (sessionGuilds.length > 0 ? sessionGuilds : (myGuilds.data ?? [])),
[sessionGuilds, myGuilds.data],
);
return (
<div className="flex flex-col gap-5">
<GuildPicker
guilds={guilds}
isPending={myGuilds.isPending && guilds.length === 0}
onSelect={selectSettingsGuild}
/>
<GuildSettingsBody guilds={guilds} />
{myGuilds.isError ? (
<Card>
<ErrorNotice error={myGuilds.error} onRetry={() => void myGuilds.refetch()} />
</Card>
) : null}
</div>
);
}
/** Секции выбранного сервера: данные тянутся по REST только для него. */
function GuildSettingsBody({ guilds }: { guilds: { id: string }[] }) {
const { t } = useTranslation();
const currentUser = useCurrentUser();
const selectedGuildId = useSessionStore((state) => state.settingsGuildId);
const selectSettingsGuild = useSessionStore((state) => state.selectSettingsGuild);
const active = useMemo(
() => guilds.find((guild) => guild.id === selectedGuildId) ?? null,
[guilds, selectedGuildId],
);
// Первый доступный сервер выбираем сами: список только что загрузился.
useEffect(() => {
if (active !== null || guilds.length === 0) {
return;
}
const first = guilds[0];
if (first !== undefined) {
selectSettingsGuild(first.id);
}
}, [active, guilds, selectSettingsGuild]);
const activeId = active?.id ?? null;
const detail = useQuery({
queryKey: guildQueryKey(activeId ?? ''),
queryFn: ({ signal }) => fetchGuild(activeId ?? '', signal),
enabled: activeId !== null,
retry: 0,
});
const members = useQuery({
queryKey: guildMembersQueryKey(activeId ?? ''),
queryFn: ({ signal }) => fetchMembers(activeId ?? '', signal),
enabled: activeId !== null,
retry: 0,
});
if (activeId === null) {
return (
<Card>
<p className="text-sm text-fg-muted">{t('settings.servers.notFound')}</p>
</Card>
);
}
if (detail.isPending) {
return (
<Card>
<SkeletonLines rows={5} />
</Card>
);
}
const guild = detail.data;
if (guild === undefined) {
return (
<Card>
<ErrorNotice error={detail.error} onRetry={() => void detail.refetch()} />
</Card>
);
}
const canManage = canAdministerGuild(
guild.my_permissions,
guild.owner_id,
currentUser.data?.id,
currentUser.data?.is_instance_admin === true,
);
// «Эмодзи» сервер разрешает владельцу, админу инстанса, ADMINISTRATOR
// и MANAGE_EMOJIS: остальным секция не показывается.
const canManageEmojiSet = canManageEmojis(
guild.my_permissions,
guild.owner_id,
currentUser.data?.id,
currentUser.data?.is_instance_admin === true,
);
// Исключение и тайм-аут: владелец, ADMINISTRATOR, право или админ инстанса.
const canModerateMembers = (permission: string): boolean =>
currentUser.data?.is_instance_admin === true ||
guild.owner_id === currentUser.data?.id ||
guild.my_permissions.includes('ADMINISTRATOR') ||
guild.my_permissions.includes(permission);
// «Баны» и журнал аудита (AGENT.md 7.17, 7.18).
const canBan = canBanMembers(
guild.my_permissions,
guild.owner_id,
currentUser.data?.id,
currentUser.data?.is_instance_admin === true,
);
const canViewAudit = canViewAuditLog(
guild.my_permissions,
guild.owner_id,
currentUser.data?.id,
currentUser.data?.is_instance_admin === true,
);
// Галерея оформления и рамки/иконки ролей (AGENT.md 7.4) — MANAGE_ROLES.
const canManageRoleSet = canManageRoles(
guild.my_permissions,
guild.owner_id,
currentUser.data?.id,
currentUser.data?.is_instance_admin === true,
);
// «Звуки» (AGENT.md 7.13) — та же логика для права MANAGE_SOUNDS.
const canManageSoundSet = canManageSounds(
guild.my_permissions,
guild.owner_id,
currentUser.data?.id,
currentUser.data?.is_instance_admin === true,
);
return (
<>
<GuildGeneralSection key={guild.id} guild={guild} canEdit={canManage} />
<GuildAppearanceSection key={`${guild.id}-appearance`} guild={guild} canEdit={canManage} />
<GuildMembersSection
guildId={guild.id}
members={members.data}
isPending={members.isPending}
error={members.error}
onRetry={() => void members.refetch()}
canKick={canModerateMembers('KICK_MEMBERS')}
canBan={canBan}
canTimeout={canModerateMembers('TIMEOUT_MEMBERS')}
currentUserId={currentUser.data?.id}
ownerId={guild.owner_id}
/>
<GuildCosmeticsSection
key={`${guild.id}-cosmetics`}
guildId={guild.id}
canManage={canManageRoleSet}
/>
<GuildRolesSection
key={`${guild.id}-roles`}
guildId={guild.id}
canManage={canManageRoleSet}
/>
{canManageEmojiSet ? (
<GuildEmojisSection key={`${guild.id}-emojis`} guildId={guild.id} />
) : null}
{canManageSoundSet ? (
<GuildSoundsSection key={`${guild.id}-sounds`} guildId={guild.id} />
) : null}
<GuildBansSection key={`${guild.id}-bans`} guildId={guild.id} canBan={canBan} />
<GuildAuditSection key={`${guild.id}-audit`} guildId={guild.id} canView={canViewAudit} />
<GuildDangerSection
key={`${guild.id}-danger`}
guildId={guild.id}
guildName={guild.name}
isOwner={currentUser.data?.id === guild.owner_id}
isInstanceAdmin={currentUser.data?.is_instance_admin === true}
/>
</>
);
}
export default GuildSettingsPage;