b43452aa04
- участники сервера отдают список бейджей из badges_json (AGENT.md 7.2): список расширяемый, назначается только системой - клиент: каталог бейджей (щит администратора инстанса, корона владельца, ранний сторонник, проверенный), неизвестные показываются нейтральной меткой - бейджи видны в ленте, списке участников и друзьях вместо текстовой плашки - тесты: Go (бейджи в списке участников) и Vitest (каталог и отрисовка)
247 lines
8.6 KiB
TypeScript
247 lines
8.6 KiB
TypeScript
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;
|