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:
smueller
2026-07-24 10:21:49 +02:00
parent fba597a6f2
commit bd1b55fff7
22 changed files with 1517 additions and 242 deletions

View File

@@ -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>
)}

View File

@@ -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>
);
}

View 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>
);
}