Implement lockdown management in automod system
- Added functionality to activate and deactivate lockdowns via the automod worker, responding to specific job names. - Enhanced the anti-raid join handler to apply a verification role when lockdown is active and the anti-raid action is set to VERIFY. - Updated the automod configuration to ensure default rules are created if missing, improving the setup process for new guilds. - Introduced new API endpoints for managing cases and warnings, allowing for soft deletion and reason updates. - Enhanced the moderation dashboard to include warnings management, improving user experience in moderation tasks. - Updated localization files to reflect new features and improve user guidance in both English and German.
This commit is contained in:
@@ -1,21 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import type { AutomodConfigDashboard } from '@nexumi/shared';
|
||||
import type {
|
||||
AutoModAction,
|
||||
AutoModRuleType,
|
||||
AutomodConfigDashboard,
|
||||
AutomodRuleDashboard
|
||||
} from '@nexumi/shared';
|
||||
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { DiscordChannelMultiSelect } from '@/components/ui/discord-channel-select';
|
||||
import { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { StringListEditor } from '@/components/ui/string-list-editor';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
||||
import { SettingsForm } from '@/components/settings/settings-form';
|
||||
|
||||
const ACTIONS: AutoModAction[] = ['DELETE', 'WARN', 'TIMEOUT', 'KICK', 'BAN'];
|
||||
|
||||
async function saveAutomod(guildId: string, value: AutomodConfigDashboard): Promise<SettingsSaveResult> {
|
||||
try {
|
||||
const cleaned: AutomodConfigDashboard = {
|
||||
...value,
|
||||
rules: value.rules.map((rule) => ({
|
||||
...rule,
|
||||
durationMs: rule.action === 'TIMEOUT' ? rule.durationMs ?? 60_000 : null,
|
||||
threshold: {
|
||||
...rule.threshold,
|
||||
linkWhitelist: (rule.threshold.linkWhitelist ?? []).map((s) => s.trim()).filter(Boolean),
|
||||
linkBlacklist: (rule.threshold.linkBlacklist ?? []).map((s) => s.trim()).filter(Boolean)
|
||||
},
|
||||
wordList: {
|
||||
words: (rule.wordList.words ?? []).map((s) => s.trim()).filter(Boolean),
|
||||
regexPatterns: (rule.wordList.regexPatterns ?? []).map((s) => s.trim()).filter(Boolean)
|
||||
}
|
||||
}))
|
||||
};
|
||||
const response = await fetch(`/api/guilds/${guildId}/automod`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(value)
|
||||
body: JSON.stringify(cleaned)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
@@ -27,6 +54,281 @@ async function saveAutomod(guildId: string, value: AutomodConfigDashboard): Prom
|
||||
}
|
||||
}
|
||||
|
||||
function updateRule(
|
||||
rules: AutomodRuleDashboard[],
|
||||
ruleType: AutoModRuleType,
|
||||
patch: Partial<AutomodRuleDashboard>
|
||||
): AutomodRuleDashboard[] {
|
||||
return rules.map((rule) => (rule.ruleType === ruleType ? { ...rule, ...patch } : rule));
|
||||
}
|
||||
|
||||
function RuleCard({
|
||||
guildId,
|
||||
rule,
|
||||
onChange
|
||||
}: {
|
||||
guildId: string;
|
||||
rule: AutomodRuleDashboard;
|
||||
onChange: (patch: Partial<AutomodRuleDashboard>) => void;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const type = rule.ruleType;
|
||||
|
||||
return (
|
||||
<FieldAnchor id={`field-automod-rule-${type}`}>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle className="text-base">
|
||||
{t(`modulePages.automod.rules.${type}.title`)}
|
||||
</CardTitle>
|
||||
<CardDescription>{t(`modulePages.automod.rules.${type}.description`)}</CardDescription>
|
||||
</div>
|
||||
<Switch
|
||||
checked={rule.enabled}
|
||||
onCheckedChange={(enabled) => onChange({ enabled })}
|
||||
aria-label={t(`modulePages.automod.rules.${type}.title`)}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.ruleAction')}</Label>
|
||||
<Select
|
||||
value={rule.action}
|
||||
onValueChange={(action) => onChange({ action: action as AutoModAction })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACTIONS.map((action) => (
|
||||
<SelectItem key={action} value={action}>
|
||||
{t(`modulePages.automod.actions.${action}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{rule.action === 'TIMEOUT' ? (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.timeoutMinutes')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={40320}
|
||||
value={Math.max(1, Math.round((rule.durationMs ?? 60_000) / 60_000))}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
durationMs: Math.max(1, Number(event.target.value) || 1) * 60_000
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{type === 'SPAM' || type === 'DUPLICATE' ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
{type === 'SPAM'
|
||||
? t('modulePages.automod.thresholds.maxMessages')
|
||||
: t('modulePages.automod.thresholds.duplicateCount')}
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={
|
||||
type === 'SPAM'
|
||||
? (rule.threshold.maxMessages ?? 5)
|
||||
: (rule.threshold.duplicateCount ?? 3)
|
||||
}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
threshold: {
|
||||
...rule.threshold,
|
||||
...(type === 'SPAM'
|
||||
? { maxMessages: Number(event.target.value) || 1 }
|
||||
: { duplicateCount: Number(event.target.value) || 1 })
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.thresholds.windowSeconds')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={rule.threshold.windowSeconds ?? (type === 'SPAM' ? 5 : 30)}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
threshold: {
|
||||
...rule.threshold,
|
||||
windowSeconds: Number(event.target.value) || 1
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{type === 'MASS_MENTION' ? (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.thresholds.maxMentions')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-40"
|
||||
value={rule.threshold.maxMentions ?? 5}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
threshold: { ...rule.threshold, maxMentions: Number(event.target.value) || 1 }
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{type === 'CAPS' ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.thresholds.capsPercent')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={rule.threshold.capsPercent ?? 70}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
threshold: {
|
||||
...rule.threshold,
|
||||
capsPercent: Number(event.target.value) || 0
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.thresholds.minLength')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={rule.threshold.minLength ?? 10}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
threshold: {
|
||||
...rule.threshold,
|
||||
minLength: Number(event.target.value) || 0
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{type === 'EMOJI_SPAM' ? (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.thresholds.maxEmojis')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-40"
|
||||
value={rule.threshold.maxEmojis ?? 10}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
threshold: { ...rule.threshold, maxEmojis: Number(event.target.value) || 1 }
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{type === 'EXTERNAL_LINK' ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.linkWhitelist')}</Label>
|
||||
<StringListEditor
|
||||
value={rule.threshold.linkWhitelist ?? []}
|
||||
onChange={(linkWhitelist) =>
|
||||
onChange({ threshold: { ...rule.threshold, linkWhitelist } })
|
||||
}
|
||||
placeholder="discord.com"
|
||||
addLabel={t('modulePages.automod.addHost')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.linkBlacklist')}</Label>
|
||||
<StringListEditor
|
||||
value={rule.threshold.linkBlacklist ?? []}
|
||||
onChange={(linkBlacklist) =>
|
||||
onChange({ threshold: { ...rule.threshold, linkBlacklist } })
|
||||
}
|
||||
placeholder="example.com"
|
||||
addLabel={t('modulePages.automod.addHost')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{type === 'WORD_FILTER' ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.wordList')}</Label>
|
||||
<StringListEditor
|
||||
value={rule.wordList.words}
|
||||
onChange={(words) => onChange({ wordList: { ...rule.wordList, words } })}
|
||||
placeholder={t('modulePages.automod.wordPlaceholder')}
|
||||
addLabel={t('modulePages.automod.addWord')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.regexList')}</Label>
|
||||
<StringListEditor
|
||||
value={rule.wordList.regexPatterns}
|
||||
onChange={(regexPatterns) =>
|
||||
onChange({ wordList: { ...rule.wordList, regexPatterns } })
|
||||
}
|
||||
placeholder="\\bspam\\b"
|
||||
addLabel={t('modulePages.automod.addRegex')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.exceptionRoles')}</Label>
|
||||
<DiscordRoleMultiSelect
|
||||
guildId={guildId}
|
||||
value={rule.exceptions.roleIds}
|
||||
onChange={(roleIds) =>
|
||||
onChange({ exceptions: { ...rule.exceptions, roleIds } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.exceptionChannels')}</Label>
|
||||
<DiscordChannelMultiSelect
|
||||
guildId={guildId}
|
||||
value={rule.exceptions.channelIds}
|
||||
onChange={(channelIds) =>
|
||||
onChange({ exceptions: { ...rule.exceptions, channelIds } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FieldAnchor>
|
||||
);
|
||||
}
|
||||
|
||||
interface AutomodFormProps {
|
||||
guildId: string;
|
||||
initialValue: AutomodConfigDashboard;
|
||||
@@ -36,7 +338,10 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<SettingsForm<AutomodConfigDashboard> initialValue={initialValue} onSave={(value) => saveAutomod(guildId, value)}>
|
||||
<SettingsForm<AutomodConfigDashboard>
|
||||
initialValue={initialValue}
|
||||
onSave={(value) => saveAutomod(guildId, value)}
|
||||
>
|
||||
{({ value, setValue }) => (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
@@ -60,112 +365,181 @@ export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<FieldAnchor id="field-anti-raid">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.automod.antiRaidTitle')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.automod.antiRaidDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||
<Label>{t('modulePages.automod.antiRaidEnabled')}</Label>
|
||||
<Switch
|
||||
checked={value.antiRaidEnabled}
|
||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, antiRaidEnabled: checked }))}
|
||||
<FieldAnchor id="field-automod-rules">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{t('modulePages.automod.rulesTitle')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('modulePages.automod.rulesDescription')}</p>
|
||||
</div>
|
||||
{value.rules.map((rule) => (
|
||||
<RuleCard
|
||||
key={rule.ruleType}
|
||||
guildId={guildId}
|
||||
rule={rule}
|
||||
onChange={(patch) =>
|
||||
setValue((prev) => ({
|
||||
...prev,
|
||||
rules: updateRule(prev.rules, rule.ruleType, patch)
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.antiRaidJoinThreshold')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={value.antiRaidJoinThreshold}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({ ...prev, antiRaidJoinThreshold: Number(event.target.value) || 1 }))
|
||||
))}
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
|
||||
<FieldAnchor id="field-anti-raid">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.automod.antiRaidTitle')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.automod.antiRaidDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||
<Label>{t('modulePages.automod.antiRaidEnabled')}</Label>
|
||||
<Switch
|
||||
checked={value.antiRaidEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setValue((prev) => ({ ...prev, antiRaidEnabled: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.antiRaidWindowSeconds')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={value.antiRaidWindowSeconds}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({ ...prev, antiRaidWindowSeconds: Number(event.target.value) || 1 }))
|
||||
}
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.antiRaidJoinThreshold')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={value.antiRaidJoinThreshold}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({
|
||||
...prev,
|
||||
antiRaidJoinThreshold: Number(event.target.value) || 1
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.antiRaidWindowSeconds')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={value.antiRaidWindowSeconds}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({
|
||||
...prev,
|
||||
antiRaidWindowSeconds: Number(event.target.value) || 1
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.antiRaidAction')}</Label>
|
||||
<Select
|
||||
value={value.antiRaidAction}
|
||||
onValueChange={(antiRaidAction) =>
|
||||
setValue((prev) => ({
|
||||
...prev,
|
||||
antiRaidAction: antiRaidAction as AutomodConfigDashboard['antiRaidAction']
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="LOCKDOWN">
|
||||
{t('modulePages.automod.antiRaidActions.LOCKDOWN')}
|
||||
</SelectItem>
|
||||
<SelectItem value="VERIFY">
|
||||
{t('modulePages.automod.antiRaidActions.VERIFY')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t('modulePages.automod.antiRaidActionHint')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p className="text-sm text-muted-foreground">{t('modulePages.automod.antiRaidActionHint')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FieldAnchor>
|
||||
|
||||
<FieldAnchor id="field-anti-nuke">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.automod.antiNukeTitle')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.automod.antiNukeDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||
<Label>{t('modulePages.automod.antiNukeEnabled')}</Label>
|
||||
<Switch
|
||||
checked={value.antiNukeEnabled}
|
||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, antiNukeEnabled: checked }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.antiNukeBanThreshold')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={value.antiNukeBanThreshold}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({ ...prev, antiNukeBanThreshold: Number(event.target.value) || 1 }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.antiNukeChannelThreshold')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={value.antiNukeChannelThreshold}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({ ...prev, antiNukeChannelThreshold: Number(event.target.value) || 1 }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.antiNukeWindowSeconds')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={value.antiNukeWindowSeconds}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({ ...prev, antiNukeWindowSeconds: Number(event.target.value) || 1 }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FieldAnchor id="field-lockdown">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.automod.antiNukeTitle')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.automod.antiNukeDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label>{t('modulePages.automod.lockdownActive')}</Label>
|
||||
<p className="text-sm text-muted-foreground">{t('modulePages.automod.lockdownActiveHint')}</p>
|
||||
</div>
|
||||
<Label>{t('modulePages.automod.antiNukeEnabled')}</Label>
|
||||
<Switch
|
||||
checked={value.lockdownActive}
|
||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, lockdownActive: checked }))}
|
||||
checked={value.antiNukeEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setValue((prev) => ({ ...prev, antiNukeEnabled: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.antiNukeBanThreshold')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={value.antiNukeBanThreshold}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({
|
||||
...prev,
|
||||
antiNukeBanThreshold: Number(event.target.value) || 1
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.antiNukeChannelThreshold')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={value.antiNukeChannelThreshold}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({
|
||||
...prev,
|
||||
antiNukeChannelThreshold: Number(event.target.value) || 1
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.automod.antiNukeWindowSeconds')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={value.antiNukeWindowSeconds}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({
|
||||
...prev,
|
||||
antiNukeWindowSeconds: Number(event.target.value) || 1
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FieldAnchor id="field-lockdown">
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label>{t('modulePages.automod.lockdownActive')}</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('modulePages.automod.lockdownActiveHint')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={value.lockdownActive}
|
||||
onCheckedChange={(checked) =>
|
||||
setValue((prev) => ({ ...prev, lockdownActive: checked }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</FieldAnchor>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FieldAnchor>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { CaseDashboard } from '@nexumi/shared';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Pencil, Search, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
@@ -26,6 +26,8 @@ export function CasesTable({ guildId, initialCases }: CasesTableProps) {
|
||||
const [action, setAction] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editReason, setEditReason] = useState('');
|
||||
|
||||
const fetchCases = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -48,86 +50,158 @@ export function CasesTable({ guildId, initialCases }: CasesTableProps) {
|
||||
}
|
||||
}, [action, guildId, search, t]);
|
||||
|
||||
async function saveReason(caseId: string) {
|
||||
setError(null);
|
||||
const response = await fetch(`/api/guilds/${guildId}/cases`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: caseId, reason: editReason })
|
||||
});
|
||||
if (!response.ok) {
|
||||
setError(t('common.saveError'));
|
||||
return;
|
||||
}
|
||||
const body = (await response.json()) as { case: CaseDashboard };
|
||||
setCases((prev) => prev.map((entry) => (entry.id === caseId ? body.case : entry)));
|
||||
setEditingId(null);
|
||||
}
|
||||
|
||||
async function removeCase(caseId: string) {
|
||||
if (!window.confirm(t('modulePages.moderation.confirmDeleteCase'))) {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
const response = await fetch(`/api/guilds/${guildId}/cases?id=${encodeURIComponent(caseId)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!response.ok) {
|
||||
setError(t('common.saveError'));
|
||||
return;
|
||||
}
|
||||
setCases((prev) => prev.filter((entry) => entry.id !== caseId));
|
||||
}
|
||||
|
||||
return (
|
||||
<FieldAnchor id="field-mod-cases">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.moderation.casesTitle')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.moderation.casesDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<form
|
||||
className="flex flex-wrap items-end gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void fetchCases();
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t('modulePages.moderation.searchPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={action}
|
||||
onChange={(event) => setAction(event.target.value)}
|
||||
placeholder={t('modulePages.moderation.actionPlaceholder')}
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="outline" disabled={loading}>
|
||||
<Search className="size-4" />
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</form>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.moderation.casesTitle')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.moderation.casesDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<form
|
||||
className="flex flex-wrap items-end gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void fetchCases();
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t('modulePages.moderation.searchPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={action}
|
||||
onChange={(event) => setAction(event.target.value)}
|
||||
placeholder={t('modulePages.moderation.actionPlaceholder')}
|
||||
className="w-40"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="outline" disabled={loading}>
|
||||
<Search className="size-4" />
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-12 rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
) : cases.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('modulePages.moderation.casesEmpty')}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-xs uppercase text-muted-foreground">
|
||||
<th className="py-2 pr-4">#</th>
|
||||
<th className="py-2 pr-4">{t('modulePages.moderation.action')}</th>
|
||||
<th className="py-2 pr-4">{t('modulePages.moderation.target')}</th>
|
||||
<th className="py-2 pr-4">{t('modulePages.moderation.moderator')}</th>
|
||||
<th className="py-2 pr-4">{t('modulePages.moderation.reason')}</th>
|
||||
<th className="py-2 pr-4">{t('modulePages.moderation.createdAt')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{cases.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td className="py-2 pr-4 font-mono text-xs">#{entry.caseNumber}</td>
|
||||
<td className="py-2 pr-4">{entry.action}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">{entry.targetUserId}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">{entry.moderatorId}</td>
|
||||
<td className="max-w-64 truncate py-2 pr-4">
|
||||
{entry.reason ?? t('modulePages.moderation.noReason')}
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2 pr-4 text-xs text-muted-foreground">
|
||||
{formatDate(entry.createdAt)}
|
||||
</td>
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-12 rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
) : cases.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('modulePages.moderation.casesEmpty')}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-xs uppercase text-muted-foreground">
|
||||
<th className="py-2 pr-4">#</th>
|
||||
<th className="py-2 pr-4">{t('modulePages.moderation.action')}</th>
|
||||
<th className="py-2 pr-4">{t('modulePages.moderation.target')}</th>
|
||||
<th className="py-2 pr-4">{t('modulePages.moderation.moderator')}</th>
|
||||
<th className="py-2 pr-4">{t('modulePages.moderation.reason')}</th>
|
||||
<th className="py-2 pr-4">{t('modulePages.moderation.createdAt')}</th>
|
||||
<th className="py-2 pr-4" />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{cases.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td className="py-2 pr-4 font-mono text-xs">#{entry.caseNumber}</td>
|
||||
<td className="py-2 pr-4">{entry.action}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">{entry.targetUserId}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">{entry.moderatorId}</td>
|
||||
<td className="max-w-64 py-2 pr-4">
|
||||
{editingId === entry.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={editReason}
|
||||
onChange={(event) => setEditReason(event.target.value)}
|
||||
aria-label={t('modulePages.moderation.editReason')}
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={() => void saveReason(entry.id)}>
|
||||
{t('modulePages.moderation.saveReason')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="truncate block">
|
||||
{entry.reason ?? t('modulePages.moderation.noReason')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2 pr-4 text-xs text-muted-foreground">
|
||||
{formatDate(entry.createdAt)}
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t('modulePages.moderation.editReason')}
|
||||
onClick={() => {
|
||||
setEditingId(entry.id);
|
||||
setEditReason(entry.reason ?? '');
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t('modulePages.moderation.deleteCase')}
|
||||
onClick={() => void removeCase(entry.id)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FieldAnchor>
|
||||
);
|
||||
}
|
||||
|
||||
147
apps/webui/src/components/modules/warnings-table.tsx
Normal file
147
apps/webui/src/components/modules/warnings-table.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import type { WarningDashboard } from '@nexumi/shared';
|
||||
import { Search, Trash2 } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
interface WarningsTableProps {
|
||||
guildId: string;
|
||||
initialWarnings: WarningDashboard[];
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
export function WarningsTable({ guildId, initialWarnings }: WarningsTableProps) {
|
||||
const t = useTranslations();
|
||||
const [warnings, setWarnings] = useState<WarningDashboard[]>(initialWarnings);
|
||||
const [userId, setUserId] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchWarnings = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (userId.trim()) params.set('userId', userId.trim());
|
||||
const response = await fetch(`/api/guilds/${guildId}/warnings?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
setError(t('common.saveError'));
|
||||
return;
|
||||
}
|
||||
const body = (await response.json()) as { warnings: WarningDashboard[] };
|
||||
setWarnings(body.warnings);
|
||||
} catch {
|
||||
setError(t('common.saveError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [guildId, t, userId]);
|
||||
|
||||
async function removeWarning(warningId: string) {
|
||||
if (!window.confirm(t('modulePages.moderation.confirmRemoveWarning'))) {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
const response = await fetch(
|
||||
`/api/guilds/${guildId}/warnings?id=${encodeURIComponent(warningId)}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
if (!response.ok) {
|
||||
setError(t('common.saveError'));
|
||||
return;
|
||||
}
|
||||
setWarnings((prev) => prev.filter((entry) => entry.id !== warningId));
|
||||
}
|
||||
|
||||
return (
|
||||
<FieldAnchor id="field-mod-warnings">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.moderation.warningsTitle')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.moderation.warningsDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<form
|
||||
className="flex flex-wrap items-end gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void fetchWarnings();
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Input
|
||||
value={userId}
|
||||
onChange={(event) => setUserId(event.target.value)}
|
||||
placeholder={t('modulePages.moderation.warningsSearchUser')}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="outline" disabled={loading}>
|
||||
<Search className="size-4" />
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-12 rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
) : warnings.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('modulePages.moderation.warningsEmpty')}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-xs uppercase text-muted-foreground">
|
||||
<th className="py-2 pr-4">{t('modulePages.moderation.target')}</th>
|
||||
<th className="py-2 pr-4">{t('modulePages.moderation.moderator')}</th>
|
||||
<th className="py-2 pr-4">{t('modulePages.moderation.reason')}</th>
|
||||
<th className="py-2 pr-4">{t('modulePages.moderation.createdAt')}</th>
|
||||
<th className="py-2 pr-4" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{warnings.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td className="py-2 pr-4 font-mono text-xs">{entry.userId}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">{entry.moderatorId}</td>
|
||||
<td className="max-w-64 truncate py-2 pr-4">
|
||||
{entry.reason ?? t('modulePages.moderation.noReason')}
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-2 pr-4 text-xs text-muted-foreground">
|
||||
{formatDate(entry.createdAt)}
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t('modulePages.moderation.removeWarning')}
|
||||
onClick={() => void removeWarning(entry.id)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FieldAnchor>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user