feat(web): меню модерации участника в списке сервера
Банить и исключать нужно того, кто прямо сейчас в сервере, а не только разбирать готовый список банов (AGENT.md 7.17). - `MemberModerationMenu`: тайм-аут (1/10/60/1440 минут и снятие), бан с причиной и исключение; пункты скрыты без прав KICK_MEMBERS/BAN_MEMBERS/TIMEOUT_MEMBERS, себя модерировать нельзя; - после действия обновляются списки участников и банов (без F5); - тесты: бан с причиной, тайм-аут, исключение и скрытие меню без прав.
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { guildMembersQueryKey } from '@/api/guilds';
|
||||
import {
|
||||
banGuildMember,
|
||||
guildBansQueryKey,
|
||||
kickGuildMember,
|
||||
timeoutGuildMember,
|
||||
} from '@/api/moderation';
|
||||
import { Menu, MenuGroupLabel, MenuItem, MenuSeparator } from '@/components/ui/Menu';
|
||||
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||||
|
||||
/** Пресеты тайм-аута в минутах: 0 — снять ограничение. */
|
||||
const TIMEOUT_CHOICES = [1, 10, 60, 1440, 0] as const;
|
||||
|
||||
interface MemberModerationMenuProps {
|
||||
guildId: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
canKick: boolean;
|
||||
canBan: boolean;
|
||||
canTimeout: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Меню модерации участника в списке сервера (AGENT.md 7.17): тайм-аут,
|
||||
* исключение и бан с причиной. Пункты скрыты без соответствующих прав, а
|
||||
* сервер всё равно проверяет их повторно.
|
||||
*/
|
||||
export function MemberModerationMenu({
|
||||
guildId,
|
||||
userId,
|
||||
userName,
|
||||
canKick,
|
||||
canBan,
|
||||
canTimeout,
|
||||
}: MemberModerationMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const describeError = useApiErrorMessage();
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState('');
|
||||
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
const refresh = (): void => {
|
||||
void queryClient.invalidateQueries({ queryKey: guildMembersQueryKey(guildId) });
|
||||
void queryClient.invalidateQueries({ queryKey: guildBansQueryKey(guildId) });
|
||||
};
|
||||
|
||||
const timeout = useMutation({
|
||||
mutationFn: (minutes: number) =>
|
||||
timeoutGuildMember(
|
||||
guildId,
|
||||
userId,
|
||||
minutes === 0
|
||||
? '2000-01-01T00:00:00Z'
|
||||
: new Date(Date.now() + minutes * 60_000).toISOString(),
|
||||
),
|
||||
onSuccess: () => {
|
||||
refresh();
|
||||
close();
|
||||
},
|
||||
});
|
||||
const kick = useMutation({
|
||||
mutationFn: () => kickGuildMember(guildId, userId),
|
||||
onSuccess: () => {
|
||||
refresh();
|
||||
close();
|
||||
},
|
||||
});
|
||||
const ban = useMutation({
|
||||
mutationFn: () => banGuildMember(guildId, userId, reason.trim()),
|
||||
onSuccess: () => {
|
||||
refresh();
|
||||
close();
|
||||
},
|
||||
});
|
||||
|
||||
if (!canKick && !canBan && !canTimeout) {
|
||||
return null;
|
||||
}
|
||||
const error = timeout.error ?? kick.error ?? ban.error;
|
||||
const pending = timeout.isPending || kick.isPending || ban.isPending;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={anchorRef}
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={t('settings.servers.members.moderate', { name: userName })}
|
||||
title={t('settings.servers.members.moderate', { name: userName })}
|
||||
data-testid={`member-moderate-${userId}`}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="rounded-[var(--radius-sm)] px-1 text-lg leading-none text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg active:scale-95"
|
||||
>
|
||||
<span aria-hidden="true">⋯</span>
|
||||
</button>
|
||||
|
||||
<Menu
|
||||
open={open}
|
||||
onClose={close}
|
||||
anchorRef={anchorRef}
|
||||
label={t('settings.servers.members.moderate', { name: userName })}
|
||||
placement="bottom"
|
||||
>
|
||||
{canTimeout ? (
|
||||
<>
|
||||
<MenuGroupLabel>{t('settings.servers.members.timeout')}</MenuGroupLabel>
|
||||
{TIMEOUT_CHOICES.map((minutes) => (
|
||||
<MenuItem
|
||||
key={minutes}
|
||||
disabled={pending}
|
||||
data-testid={`member-timeout-${minutes}`}
|
||||
onClick={() => timeout.mutate(minutes)}
|
||||
>
|
||||
{minutes === 0
|
||||
? t('settings.servers.members.timeoutClear')
|
||||
: t('settings.servers.bans.timeoutMinutes', { count: minutes })}
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuSeparator />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{canBan ? (
|
||||
<>
|
||||
<MenuGroupLabel>{t('settings.servers.members.banReason')}</MenuGroupLabel>
|
||||
<label className="flex flex-col gap-1 px-2 pb-1">
|
||||
<input
|
||||
className="rounded-[var(--radius-sm)] border border-border/60 bg-surface-2 px-2 py-1 text-sm text-fg"
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
maxLength={400}
|
||||
placeholder={t('settings.servers.members.banReasonPlaceholder')}
|
||||
data-testid={`member-ban-reason-${userId}`}
|
||||
/>
|
||||
</label>
|
||||
<MenuItem
|
||||
danger
|
||||
disabled={pending}
|
||||
data-testid={`member-ban-${userId}`}
|
||||
onClick={() => ban.mutate()}
|
||||
>
|
||||
{t('settings.servers.members.ban')}
|
||||
</MenuItem>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{canKick ? (
|
||||
<MenuItem
|
||||
danger
|
||||
disabled={pending}
|
||||
data-testid={`member-kick-${userId}`}
|
||||
onClick={() => kick.mutate()}
|
||||
>
|
||||
{t('settings.servers.members.kick')}
|
||||
</MenuItem>
|
||||
) : null}
|
||||
|
||||
{error === null || error === undefined ? null : (
|
||||
<p className="px-2 py-1 text-xs text-danger">{describeError(error)}</p>
|
||||
)}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user