Enhance environment configuration and expand Prisma schema for bot management
- Added OWNER_USER_IDS to .env.example for specifying global bot owners. - Introduced new models in the Prisma schema: CommandOverride, OwnerTeamMember, GlobalUserBlacklist, GuildBlacklist, FeatureFlag, BotPresenceConfig, ChangelogEntry, and OwnerAuditLog to support advanced bot management features. - Updated env.ts to preprocess OWNER_USER_IDS for better handling of global bot owner IDs. - Modified ModulePlaceholderPage to ensure proper routing for remaining dashboard modules.
This commit is contained in:
165
apps/webui/src/components/modules/automod-form.tsx
Normal file
165
apps/webui/src/components/modules/automod-form.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
'use client';
|
||||
|
||||
import type { AutomodConfigDashboard } from '@nexumi/shared';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
||||
import { SettingsForm } from '@/components/settings/settings-form';
|
||||
|
||||
async function saveAutomod(guildId: string, value: AutomodConfigDashboard): Promise<SettingsSaveResult> {
|
||||
try {
|
||||
const response = await fetch(`/api/guilds/${guildId}/automod`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(value)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
return { ok: false, error: body?.error ?? `Request failed with status ${response.status}` };
|
||||
}
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return { ok: false, error: 'Network error' };
|
||||
}
|
||||
}
|
||||
|
||||
interface AutomodFormProps {
|
||||
guildId: string;
|
||||
initialValue: AutomodConfigDashboard;
|
||||
}
|
||||
|
||||
export function AutomodForm({ guildId, initialValue }: AutomodFormProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<SettingsForm<AutomodConfigDashboard> initialValue={initialValue} onSave={(value) => saveAutomod(guildId, value)}>
|
||||
{({ value, setValue }) => (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.automod.title')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.automod.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label>{t('modulePages.automod.enabledLabel')}</Label>
|
||||
<p className="text-sm text-muted-foreground">{t('modulePages.automod.enabledHint')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={value.enabled}
|
||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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="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>
|
||||
<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>
|
||||
<p className="text-sm text-muted-foreground">{t('modulePages.automod.antiRaidActionHint')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</SettingsForm>
|
||||
);
|
||||
}
|
||||
130
apps/webui/src/components/modules/cases-table.tsx
Normal file
130
apps/webui/src/components/modules/cases-table.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
|
||||
import type { CaseDashboard } from '@nexumi/shared';
|
||||
import { Search } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
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 CasesTableProps {
|
||||
guildId: string;
|
||||
initialCases: CaseDashboard[];
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
||||
}
|
||||
|
||||
export function CasesTable({ guildId, initialCases }: CasesTableProps) {
|
||||
const t = useTranslations();
|
||||
const [cases, setCases] = useState<CaseDashboard[]>(initialCases);
|
||||
const [search, setSearch] = useState('');
|
||||
const [action, setAction] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchCases = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (search.trim()) params.set('search', search.trim());
|
||||
if (action.trim()) params.set('action', action.trim());
|
||||
const response = await fetch(`/api/guilds/${guildId}/cases?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
setError(t('common.saveError'));
|
||||
return;
|
||||
}
|
||||
const body = (await response.json()) as { cases: CaseDashboard[] };
|
||||
setCases(body.cases);
|
||||
} catch {
|
||||
setError(t('common.saveError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [action, guildId, search, t]);
|
||||
|
||||
return (
|
||||
<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>}
|
||||
|
||||
{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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
142
apps/webui/src/components/modules/economy-form.tsx
Normal file
142
apps/webui/src/components/modules/economy-form.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
'use client';
|
||||
|
||||
import type { EconomyConfigDashboard } from '@nexumi/shared';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
||||
import { SettingsForm } from '@/components/settings/settings-form';
|
||||
|
||||
async function saveEconomy(guildId: string, value: EconomyConfigDashboard): Promise<SettingsSaveResult> {
|
||||
try {
|
||||
const response = await fetch(`/api/guilds/${guildId}/economy`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(value)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
return { ok: false, error: body?.error ?? `Request failed with status ${response.status}` };
|
||||
}
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return { ok: false, error: 'Network error' };
|
||||
}
|
||||
}
|
||||
|
||||
interface EconomyFormProps {
|
||||
guildId: string;
|
||||
initialValue: EconomyConfigDashboard;
|
||||
}
|
||||
|
||||
export function EconomyForm({ guildId, initialValue }: EconomyFormProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<SettingsForm<EconomyConfigDashboard> initialValue={initialValue} onSave={(value) => saveEconomy(guildId, value)}>
|
||||
{({ value, setValue }) => (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.economy.title')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.economy.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||
<Label>{t('modulePages.economy.enabledLabel')}</Label>
|
||||
<Switch
|
||||
checked={value.enabled}
|
||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.economy.currencyName')}</Label>
|
||||
<Input
|
||||
value={value.currencyName}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, currencyName: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.economy.currencySymbol')}</Label>
|
||||
<Input
|
||||
value={value.currencySymbol}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, currencySymbol: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.economy.amountsTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.economy.dailyAmount')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={value.dailyAmount}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, dailyAmount: Number(event.target.value) || 0 }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.economy.weeklyAmount')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={value.weeklyAmount}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, weeklyAmount: Number(event.target.value) || 0 }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.economy.workMin')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={value.workMin}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, workMin: Number(event.target.value) || 0 }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.economy.workMax')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={value.workMax}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, workMax: Number(event.target.value) || 0 }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.economy.workCooldownSeconds')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={value.workCooldownSeconds}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({ ...prev, workCooldownSeconds: Number(event.target.value) || 1 }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.economy.startingBalance')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={value.startingBalance}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({ ...prev, startingBalance: Number(event.target.value) || 0 }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</SettingsForm>
|
||||
);
|
||||
}
|
||||
70
apps/webui/src/components/modules/fun-form.tsx
Normal file
70
apps/webui/src/components/modules/fun-form.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import type { FunConfigDashboard } from '@nexumi/shared';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
||||
import { SettingsForm } from '@/components/settings/settings-form';
|
||||
|
||||
async function saveFun(guildId: string, value: FunConfigDashboard): Promise<SettingsSaveResult> {
|
||||
try {
|
||||
const response = await fetch(`/api/guilds/${guildId}/fun`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(value)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
return { ok: false, error: body?.error ?? `Request failed with status ${response.status}` };
|
||||
}
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return { ok: false, error: 'Network error' };
|
||||
}
|
||||
}
|
||||
|
||||
interface FunFormProps {
|
||||
guildId: string;
|
||||
initialValue: FunConfigDashboard;
|
||||
}
|
||||
|
||||
export function FunForm({ guildId, initialValue }: FunFormProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<SettingsForm<FunConfigDashboard> initialValue={initialValue} onSave={(value) => saveFun(guildId, value)}>
|
||||
{({ value, setValue }) => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.fun.title')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.fun.description')}</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.fun.enabledLabel')}</Label>
|
||||
<p className="text-sm text-muted-foreground">{t('modulePages.fun.enabledHint')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={value.enabled}
|
||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label>{t('modulePages.fun.mediaEnabledLabel')}</Label>
|
||||
<p className="text-sm text-muted-foreground">{t('modulePages.fun.mediaEnabledHint')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={value.mediaEnabled}
|
||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, mediaEnabled: checked }))}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</SettingsForm>
|
||||
);
|
||||
}
|
||||
320
apps/webui/src/components/modules/leveling-form.tsx
Normal file
320
apps/webui/src/components/modules/leveling-form.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
'use client';
|
||||
|
||||
import type { LevelingConfigDashboard } from '@nexumi/shared';
|
||||
import { useId } from 'react';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
||||
import { SettingsForm } from '@/components/settings/settings-form';
|
||||
|
||||
interface LocalLevelingValue
|
||||
extends Omit<LevelingConfigDashboard, 'noXpChannelIds' | 'noXpRoleIds' | 'roleMultipliers' | 'channelMultipliers'> {
|
||||
noXpChannelIdsText: string;
|
||||
noXpRoleIdsText: string;
|
||||
roleMultipliersText: string;
|
||||
channelMultipliersText: string;
|
||||
}
|
||||
|
||||
function toCsv(ids: string[]): string {
|
||||
return ids.join(', ');
|
||||
}
|
||||
|
||||
function fromCsv(text: string): string[] {
|
||||
return text
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
}
|
||||
|
||||
function toMultiplierText(map: Record<string, number>): string {
|
||||
return Object.entries(map)
|
||||
.map(([id, multiplier]) => `${id}:${multiplier}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function fromMultiplierText(text: string): { value: Record<string, number>; error?: string } {
|
||||
const value: Record<string, number> = {};
|
||||
const lines = text
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
for (const line of lines) {
|
||||
const [id, multiplierRaw] = line.split(':').map((part) => part.trim());
|
||||
const multiplier = Number(multiplierRaw);
|
||||
if (!id || Number.isNaN(multiplier) || multiplier <= 0) {
|
||||
return { value: {}, error: `Invalid multiplier line: "${line}" (expected id:number)` };
|
||||
}
|
||||
value[id] = multiplier;
|
||||
}
|
||||
|
||||
return { value };
|
||||
}
|
||||
|
||||
function toLocal(config: LevelingConfigDashboard): LocalLevelingValue {
|
||||
const { noXpChannelIds, noXpRoleIds, roleMultipliers, channelMultipliers, ...rest } = config;
|
||||
return {
|
||||
...rest,
|
||||
noXpChannelIdsText: toCsv(noXpChannelIds),
|
||||
noXpRoleIdsText: toCsv(noXpRoleIds),
|
||||
roleMultipliersText: toMultiplierText(roleMultipliers),
|
||||
channelMultipliersText: toMultiplierText(channelMultipliers)
|
||||
};
|
||||
}
|
||||
|
||||
async function saveLeveling(guildId: string, value: LocalLevelingValue): Promise<SettingsSaveResult> {
|
||||
const roleMultipliers = fromMultiplierText(value.roleMultipliersText);
|
||||
if (roleMultipliers.error) {
|
||||
return { ok: false, error: roleMultipliers.error };
|
||||
}
|
||||
const channelMultipliers = fromMultiplierText(value.channelMultipliersText);
|
||||
if (channelMultipliers.error) {
|
||||
return { ok: false, error: channelMultipliers.error };
|
||||
}
|
||||
|
||||
const { noXpChannelIdsText, noXpRoleIdsText, roleMultipliersText, channelMultipliersText, ...rest } = value;
|
||||
|
||||
const payload: LevelingConfigDashboard = {
|
||||
...rest,
|
||||
noXpChannelIds: fromCsv(noXpChannelIdsText),
|
||||
noXpRoleIds: fromCsv(noXpRoleIdsText),
|
||||
roleMultipliers: roleMultipliers.value,
|
||||
channelMultipliers: channelMultipliers.value
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/guilds/${guildId}/leveling`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
return { ok: false, error: body?.error ?? `Request failed with status ${response.status}` };
|
||||
}
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return { ok: false, error: 'Network error' };
|
||||
}
|
||||
}
|
||||
|
||||
interface LevelingFormProps {
|
||||
guildId: string;
|
||||
initialValue: LevelingConfigDashboard;
|
||||
}
|
||||
|
||||
export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
|
||||
const t = useTranslations();
|
||||
const noXpChannelsId = useId();
|
||||
const noXpRolesId = useId();
|
||||
|
||||
return (
|
||||
<SettingsForm<LocalLevelingValue>
|
||||
initialValue={toLocal(initialValue)}
|
||||
onSave={(value) => saveLeveling(guildId, value)}
|
||||
>
|
||||
{({ value, setValue }) => (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.leveling.title')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.leveling.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||
<Label>{t('modulePages.leveling.enabledLabel')}</Label>
|
||||
<Switch
|
||||
checked={value.enabled}
|
||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.leveling.textXpMin')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={value.textXpMin}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, textXpMin: Number(event.target.value) || 1 }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.leveling.textXpMax')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={value.textXpMax}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, textXpMax: Number(event.target.value) || 1 }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.leveling.textCooldownSeconds')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={value.textCooldownSeconds}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({ ...prev, textCooldownSeconds: Number(event.target.value) || 1 }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.leveling.voiceXpPerMinute')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-40"
|
||||
value={value.voiceXpPerMinute}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({ ...prev, voiceXpPerMinute: Number(event.target.value) || 0 }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={noXpChannelsId}>{t('modulePages.leveling.noXpChannels')}</Label>
|
||||
<Textarea
|
||||
id={noXpChannelsId}
|
||||
value={value.noXpChannelIdsText}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, noXpChannelIdsText: event.target.value }))}
|
||||
placeholder={t('modulePages.logging.idsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={noXpRolesId}>{t('modulePages.leveling.noXpRoles')}</Label>
|
||||
<Textarea
|
||||
id={noXpRolesId}
|
||||
value={value.noXpRoleIdsText}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, noXpRoleIdsText: event.target.value }))}
|
||||
placeholder={t('modulePages.logging.idsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.leveling.roleMultipliers')}</Label>
|
||||
<Textarea
|
||||
className="font-mono"
|
||||
rows={4}
|
||||
value={value.roleMultipliersText}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, roleMultipliersText: event.target.value }))}
|
||||
placeholder="123456789012345678:1.5"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.leveling.multiplierHint')}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.leveling.channelMultipliers')}</Label>
|
||||
<Textarea
|
||||
className="font-mono"
|
||||
rows={4}
|
||||
value={value.channelMultipliersText}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, channelMultipliersText: event.target.value }))}
|
||||
placeholder="123456789012345678:2"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.leveling.multiplierHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.leveling.levelUpTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.leveling.levelUpMode')}</Label>
|
||||
<Select
|
||||
value={value.levelUpMode}
|
||||
onValueChange={(mode) =>
|
||||
setValue((prev) => ({ ...prev, levelUpMode: mode as LevelingConfigDashboard['levelUpMode'] }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="CHANNEL">{t('modulePages.leveling.mode.CHANNEL')}</SelectItem>
|
||||
<SelectItem value="DM">{t('modulePages.leveling.mode.DM')}</SelectItem>
|
||||
<SelectItem value="OFF">{t('modulePages.leveling.mode.OFF')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.leveling.levelUpChannelId')}</Label>
|
||||
<Input
|
||||
value={value.levelUpChannelId}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, levelUpChannelId: event.target.value.trim() }))}
|
||||
placeholder="123456789012345678"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.leveling.levelUpMessage')}</Label>
|
||||
<Textarea
|
||||
value={value.levelUpMessage ?? ''}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, levelUpMessage: event.target.value || null }))}
|
||||
placeholder="Congrats {user}, you reached level {level}!"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||
<Label>{t('modulePages.leveling.stackRewards')}</Label>
|
||||
<Switch
|
||||
checked={value.stackRewards}
|
||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, stackRewards: checked }))}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.leveling.rankCardTitle')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.leveling.cardAccentColor')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
className="size-9 rounded-md border border-input"
|
||||
value={value.cardAccentColor}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, cardAccentColor: event.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
value={value.cardAccentColor}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, cardAccentColor: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.leveling.cardBackgroundColor')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
className="size-9 rounded-md border border-input"
|
||||
value={value.cardBackgroundColor}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, cardBackgroundColor: event.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
value={value.cardBackgroundColor}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, cardBackgroundColor: event.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</SettingsForm>
|
||||
);
|
||||
}
|
||||
257
apps/webui/src/components/modules/logging-form.tsx
Normal file
257
apps/webui/src/components/modules/logging-form.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
'use client';
|
||||
|
||||
import type { LogChannelMapping, LogEventTypeDashboard, LoggingConfigDashboard } from '@nexumi/shared';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useId } from 'react';
|
||||
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 { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
||||
import { SettingsForm } from '@/components/settings/settings-form';
|
||||
|
||||
const LOG_EVENT_TYPES: LogEventTypeDashboard[] = [
|
||||
'MESSAGE_EDIT',
|
||||
'MESSAGE_DELETE',
|
||||
'MESSAGE_BULK_DELETE',
|
||||
'MEMBER_JOIN',
|
||||
'MEMBER_LEAVE',
|
||||
'MEMBER_BAN',
|
||||
'MEMBER_UNBAN',
|
||||
'MEMBER_UPDATE',
|
||||
'CHANNEL_CREATE',
|
||||
'CHANNEL_DELETE',
|
||||
'CHANNEL_UPDATE',
|
||||
'VOICE_JOIN',
|
||||
'VOICE_LEAVE',
|
||||
'VOICE_MOVE',
|
||||
'INVITE_CREATE',
|
||||
'EMOJI_CREATE',
|
||||
'EMOJI_UPDATE',
|
||||
'EMOJI_DELETE',
|
||||
'STICKER_CREATE',
|
||||
'STICKER_UPDATE',
|
||||
'STICKER_DELETE',
|
||||
'THREAD_CREATE',
|
||||
'THREAD_UPDATE',
|
||||
'THREAD_DELETE',
|
||||
'GUILD_UPDATE',
|
||||
'MOD_ACTION'
|
||||
];
|
||||
|
||||
interface LocalLogChannel extends LogChannelMapping {
|
||||
clientKey: string;
|
||||
}
|
||||
|
||||
interface LocalLoggingValue {
|
||||
enabled: boolean;
|
||||
ignoreBots: boolean;
|
||||
ignoredChannelIdsText: string;
|
||||
ignoredRoleIdsText: string;
|
||||
logChannels: LocalLogChannel[];
|
||||
}
|
||||
|
||||
function toCsv(ids: string[]): string {
|
||||
return ids.join(', ');
|
||||
}
|
||||
|
||||
function fromCsv(text: string): string[] {
|
||||
return text
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
}
|
||||
|
||||
function toLocal(config: LoggingConfigDashboard): LocalLoggingValue {
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
ignoreBots: config.ignoreBots,
|
||||
ignoredChannelIdsText: toCsv(config.ignoredChannelIds),
|
||||
ignoredRoleIdsText: toCsv(config.ignoredRoleIds),
|
||||
logChannels: config.logChannels.map((mapping, index) => ({ ...mapping, clientKey: `${mapping.eventType}-${index}` }))
|
||||
};
|
||||
}
|
||||
|
||||
function toRemote(value: LocalLoggingValue): LoggingConfigDashboard {
|
||||
return {
|
||||
enabled: value.enabled,
|
||||
ignoreBots: value.ignoreBots,
|
||||
ignoredChannelIds: fromCsv(value.ignoredChannelIdsText),
|
||||
ignoredRoleIds: fromCsv(value.ignoredRoleIdsText),
|
||||
logChannels: value.logChannels.map(({ clientKey, ...rest }) => rest)
|
||||
};
|
||||
}
|
||||
|
||||
async function saveLogging(guildId: string, value: LocalLoggingValue): Promise<SettingsSaveResult> {
|
||||
try {
|
||||
const response = await fetch(`/api/guilds/${guildId}/logging`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(toRemote(value))
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
return { ok: false, error: body?.error ?? `Request failed with status ${response.status}` };
|
||||
}
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return { ok: false, error: 'Network error' };
|
||||
}
|
||||
}
|
||||
|
||||
interface LoggingFormProps {
|
||||
guildId: string;
|
||||
initialValue: LoggingConfigDashboard;
|
||||
}
|
||||
|
||||
export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
|
||||
const t = useTranslations();
|
||||
const ignoredChannelsId = useId();
|
||||
const ignoredRolesId = useId();
|
||||
|
||||
return (
|
||||
<SettingsForm<LocalLoggingValue>
|
||||
initialValue={toLocal(initialValue)}
|
||||
onSave={(value) => saveLogging(guildId, value)}
|
||||
>
|
||||
{({ value, setValue }) => (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.logging.title')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.logging.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||
<Label>{t('modulePages.logging.enabledLabel')}</Label>
|
||||
<Switch
|
||||
checked={value.enabled}
|
||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||
<Label>{t('modulePages.logging.ignoreBotsLabel')}</Label>
|
||||
<Switch
|
||||
checked={value.ignoreBots}
|
||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, ignoreBots: checked }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={ignoredChannelsId}>{t('modulePages.logging.ignoredChannelsLabel')}</Label>
|
||||
<Textarea
|
||||
id={ignoredChannelsId}
|
||||
value={value.ignoredChannelIdsText}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, ignoredChannelIdsText: event.target.value }))}
|
||||
placeholder={t('modulePages.logging.idsPlaceholder')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.logging.idsHint')}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={ignoredRolesId}>{t('modulePages.logging.ignoredRolesLabel')}</Label>
|
||||
<Textarea
|
||||
id={ignoredRolesId}
|
||||
value={value.ignoredRoleIdsText}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, ignoredRoleIdsText: event.target.value }))}
|
||||
placeholder={t('modulePages.logging.idsPlaceholder')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.logging.idsHint')}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.logging.channelsTitle')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.logging.channelsDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{value.logChannels.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t('modulePages.logging.channelsEmpty')}</p>
|
||||
)}
|
||||
{value.logChannels.map((mapping) => (
|
||||
<div key={mapping.clientKey} className="flex flex-wrap items-end gap-3 rounded-md border border-border p-3">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.logging.eventType')}</Label>
|
||||
<Select
|
||||
value={mapping.eventType}
|
||||
onValueChange={(eventType) =>
|
||||
setValue((prev) => ({
|
||||
...prev,
|
||||
logChannels: prev.logChannels.map((entry) =>
|
||||
entry.clientKey === mapping.clientKey
|
||||
? { ...entry, eventType: eventType as LogEventTypeDashboard }
|
||||
: entry
|
||||
)
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-56">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOG_EVENT_TYPES.map((eventType) => (
|
||||
<SelectItem key={eventType} value={eventType}>
|
||||
{eventType}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label>{t('modulePages.logging.channelId')}</Label>
|
||||
<Input
|
||||
value={mapping.channelId}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({
|
||||
...prev,
|
||||
logChannels: prev.logChannels.map((entry) =>
|
||||
entry.clientKey === mapping.clientKey ? { ...entry, channelId: event.target.value.trim() } : entry
|
||||
)
|
||||
}))
|
||||
}
|
||||
placeholder="123456789012345678"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t('common.remove')}
|
||||
onClick={() =>
|
||||
setValue((prev) => ({
|
||||
...prev,
|
||||
logChannels: prev.logChannels.filter((entry) => entry.clientKey !== mapping.clientKey)
|
||||
}))
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setValue((prev) => ({
|
||||
...prev,
|
||||
logChannels: [
|
||||
...prev.logChannels,
|
||||
{ clientKey: `new-${Date.now()}`, eventType: 'MOD_ACTION', channelId: '' }
|
||||
]
|
||||
}))
|
||||
}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{t('modulePages.logging.addMapping')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</SettingsForm>
|
||||
);
|
||||
}
|
||||
232
apps/webui/src/components/modules/moderation-form.tsx
Normal file
232
apps/webui/src/components/modules/moderation-form.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
'use client';
|
||||
|
||||
import type { EscalationAction, EscalationRuleDashboard, ModerationDashboard } from '@nexumi/shared';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useId } from 'react';
|
||||
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 { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
||||
import { SettingsForm } from '@/components/settings/settings-form';
|
||||
|
||||
const ESCALATION_ACTIONS: EscalationAction[] = ['TIMEOUT', 'KICK', 'BAN'];
|
||||
|
||||
interface LocalEscalation extends EscalationRuleDashboard {
|
||||
clientKey: string;
|
||||
}
|
||||
|
||||
function toLocal(rules: EscalationRuleDashboard[]): LocalEscalation[] {
|
||||
return rules.map((rule, index) => ({ ...rule, clientKey: rule.id ?? `existing-${index}` }));
|
||||
}
|
||||
|
||||
function toRemote(rules: LocalEscalation[]): EscalationRuleDashboard[] {
|
||||
return rules.map(({ clientKey, ...rest }) => rest);
|
||||
}
|
||||
|
||||
async function saveModeration(guildId: string, value: ModerationDashboard): Promise<SettingsSaveResult> {
|
||||
try {
|
||||
const response = await fetch(`/api/guilds/${guildId}/moderation`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(value)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
return { ok: false, error: body?.error ?? `Request failed with status ${response.status}` };
|
||||
}
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return { ok: false, error: 'Network error' };
|
||||
}
|
||||
}
|
||||
|
||||
function EscalationRow({
|
||||
rule,
|
||||
onChange,
|
||||
onRemove
|
||||
}: {
|
||||
rule: LocalEscalation;
|
||||
onChange: (rule: LocalEscalation) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const warnCountId = useId();
|
||||
const durationId = useId();
|
||||
const reasonId = useId();
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-md border border-border p-4">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={warnCountId}>{t('modulePages.moderation.warnCount')}</Label>
|
||||
<Input
|
||||
id={warnCountId}
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-28"
|
||||
value={rule.warnCount}
|
||||
onChange={(event) => onChange({ ...rule, warnCount: Number(event.target.value) || 1 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.moderation.action')}</Label>
|
||||
<Select
|
||||
value={rule.action}
|
||||
onValueChange={(action) => onChange({ ...rule, action: action as EscalationAction })}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ESCALATION_ACTIONS.map((action) => (
|
||||
<SelectItem key={action} value={action}>
|
||||
{t(`modulePages.moderation.actions.${action}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{rule.action === 'TIMEOUT' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={durationId}>{t('modulePages.moderation.durationMs')}</Label>
|
||||
<Input
|
||||
id={durationId}
|
||||
type="number"
|
||||
min={1000}
|
||||
step={1000}
|
||||
className="w-36"
|
||||
value={rule.durationMs ?? 0}
|
||||
onChange={(event) => onChange({ ...rule, durationMs: Number(event.target.value) || null })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pb-1">
|
||||
<Switch checked={rule.enabled} onCheckedChange={(enabled) => onChange({ ...rule, enabled })} />
|
||||
<span className="text-sm text-muted-foreground">{t('common.enabled')}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-auto"
|
||||
onClick={onRemove}
|
||||
aria-label={t('common.remove')}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={reasonId}>{t('modulePages.moderation.reason')}</Label>
|
||||
<Input
|
||||
id={reasonId}
|
||||
value={rule.reason ?? ''}
|
||||
onChange={(event) => onChange({ ...rule, reason: event.target.value || null })}
|
||||
placeholder={t('modulePages.moderation.reasonPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModerationFormProps {
|
||||
guildId: string;
|
||||
initialValue: ModerationDashboard;
|
||||
}
|
||||
|
||||
export function ModerationForm({ guildId, initialValue }: ModerationFormProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<SettingsForm<{ moderationEnabled: boolean; escalations: LocalEscalation[] }>
|
||||
initialValue={{ moderationEnabled: initialValue.moderationEnabled, escalations: toLocal(initialValue.escalations) }}
|
||||
onSave={(value) =>
|
||||
saveModeration(guildId, { moderationEnabled: value.moderationEnabled, escalations: toRemote(value.escalations) })
|
||||
}
|
||||
>
|
||||
{({ value, setValue }) => (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.moderation.title')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.moderation.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label>{t('modulePages.moderation.enabledLabel')}</Label>
|
||||
<p className="text-sm text-muted-foreground">{t('modulePages.moderation.enabledHint')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={value.moderationEnabled}
|
||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, moderationEnabled: checked }))}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.moderation.escalationsTitle')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.moderation.escalationsDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{value.escalations.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t('modulePages.moderation.noEscalations')}</p>
|
||||
)}
|
||||
{value.escalations.map((rule) => (
|
||||
<EscalationRow
|
||||
key={rule.clientKey}
|
||||
rule={rule}
|
||||
onChange={(updated) =>
|
||||
setValue((prev) => ({
|
||||
...prev,
|
||||
escalations: prev.escalations.map((entry) => (entry.clientKey === rule.clientKey ? updated : entry))
|
||||
}))
|
||||
}
|
||||
onRemove={() =>
|
||||
setValue((prev) => ({
|
||||
...prev,
|
||||
escalations: prev.escalations.filter((entry) => entry.clientKey !== rule.clientKey)
|
||||
}))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setValue((prev) => ({
|
||||
...prev,
|
||||
escalations: [
|
||||
...prev.escalations,
|
||||
{
|
||||
clientKey: `new-${Date.now()}-${prev.escalations.length}`,
|
||||
warnCount: prev.escalations.length + 1,
|
||||
action: 'TIMEOUT',
|
||||
durationMs: 3_600_000,
|
||||
reason: null,
|
||||
enabled: true
|
||||
}
|
||||
]
|
||||
}))
|
||||
}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
{t('modulePages.moderation.addEscalation')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</SettingsForm>
|
||||
);
|
||||
}
|
||||
140
apps/webui/src/components/modules/verification-form.tsx
Normal file
140
apps/webui/src/components/modules/verification-form.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import type { VerificationConfigDashboard } from '@nexumi/shared';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
||||
import { SettingsForm } from '@/components/settings/settings-form';
|
||||
|
||||
async function saveVerification(guildId: string, value: VerificationConfigDashboard): Promise<SettingsSaveResult> {
|
||||
try {
|
||||
const response = await fetch(`/api/guilds/${guildId}/verification`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(value)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
return { ok: false, error: body?.error ?? `Request failed with status ${response.status}` };
|
||||
}
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return { ok: false, error: 'Network error' };
|
||||
}
|
||||
}
|
||||
|
||||
interface VerificationFormProps {
|
||||
guildId: string;
|
||||
initialValue: VerificationConfigDashboard;
|
||||
}
|
||||
|
||||
export function VerificationForm({ guildId, initialValue }: VerificationFormProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<SettingsForm<VerificationConfigDashboard>
|
||||
initialValue={initialValue}
|
||||
onSave={(value) => saveVerification(guildId, value)}
|
||||
>
|
||||
{({ value, setValue }) => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.verification.title')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.verification.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||
<Label>{t('modulePages.verification.enabledLabel')}</Label>
|
||||
<Switch
|
||||
checked={value.enabled}
|
||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, enabled: checked }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.verification.mode')}</Label>
|
||||
<Select
|
||||
value={value.mode}
|
||||
onValueChange={(mode) =>
|
||||
setValue((prev) => ({ ...prev, mode: mode as VerificationConfigDashboard['mode'] }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="BUTTON">{t('modulePages.verification.mode.BUTTON')}</SelectItem>
|
||||
<SelectItem value="CAPTCHA">{t('modulePages.verification.mode.CAPTCHA')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.verification.failAction')}</Label>
|
||||
<Select
|
||||
value={value.failAction}
|
||||
onValueChange={(failAction) =>
|
||||
setValue((prev) => ({ ...prev, failAction: failAction as VerificationConfigDashboard['failAction'] }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="KICK">{t('modulePages.verification.failAction.KICK')}</SelectItem>
|
||||
<SelectItem value="BAN">{t('modulePages.verification.failAction.BAN')}</SelectItem>
|
||||
<SelectItem value="NONE">{t('modulePages.verification.failAction.NONE')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.verification.channelId')}</Label>
|
||||
<Input
|
||||
value={value.channelId}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, channelId: event.target.value.trim() }))}
|
||||
placeholder="123456789012345678"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.verification.verifiedRoleId')}</Label>
|
||||
<Input
|
||||
value={value.verifiedRoleId}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, verifiedRoleId: event.target.value.trim() }))}
|
||||
placeholder="123456789012345678"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.verification.unverifiedRoleId')}</Label>
|
||||
<Input
|
||||
value={value.unverifiedRoleId}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, unverifiedRoleId: event.target.value.trim() }))}
|
||||
placeholder="123456789012345678"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.verification.minAccountAgeDays')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-40"
|
||||
value={value.minAccountAgeDays}
|
||||
onChange={(event) =>
|
||||
setValue((prev) => ({ ...prev, minAccountAgeDays: Number(event.target.value) || 0 }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</SettingsForm>
|
||||
);
|
||||
}
|
||||
269
apps/webui/src/components/modules/welcome-form.tsx
Normal file
269
apps/webui/src/components/modules/welcome-form.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
'use client';
|
||||
|
||||
import type { WelcomeConfigDashboard } from '@nexumi/shared';
|
||||
import { useId } from 'react';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
||||
import { SettingsForm } from '@/components/settings/settings-form';
|
||||
|
||||
interface LocalWelcomeValue extends Omit<WelcomeConfigDashboard, 'userAutoroleIds' | 'botAutoroleIds' | 'welcomeEmbed' | 'leaveEmbed'> {
|
||||
userAutoroleIdsText: string;
|
||||
botAutoroleIdsText: string;
|
||||
welcomeEmbedText: string;
|
||||
leaveEmbedText: string;
|
||||
}
|
||||
|
||||
function toCsv(ids: string[]): string {
|
||||
return ids.join(', ');
|
||||
}
|
||||
|
||||
function fromCsv(text: string): string[] {
|
||||
return text
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
}
|
||||
|
||||
function toJsonText(value: Record<string, unknown> | null | undefined): string {
|
||||
return value ? JSON.stringify(value, null, 2) : '';
|
||||
}
|
||||
|
||||
function toLocal(config: WelcomeConfigDashboard): LocalWelcomeValue {
|
||||
const { userAutoroleIds, botAutoroleIds, welcomeEmbed, leaveEmbed, ...rest } = config;
|
||||
return {
|
||||
...rest,
|
||||
userAutoroleIdsText: toCsv(userAutoroleIds),
|
||||
botAutoroleIdsText: toCsv(botAutoroleIds),
|
||||
welcomeEmbedText: toJsonText(welcomeEmbed),
|
||||
leaveEmbedText: toJsonText(leaveEmbed)
|
||||
};
|
||||
}
|
||||
|
||||
function parseEmbedJson(text: string): { value: Record<string, unknown> | null; error?: string } {
|
||||
if (!text.trim()) {
|
||||
return { value: null };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return { value: parsed as Record<string, unknown> };
|
||||
}
|
||||
return { value: null, error: 'Invalid embed JSON (must be an object)' };
|
||||
} catch {
|
||||
return { value: null, error: 'Invalid embed JSON' };
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWelcome(guildId: string, value: LocalWelcomeValue): Promise<SettingsSaveResult> {
|
||||
const welcomeEmbed = parseEmbedJson(value.welcomeEmbedText);
|
||||
if (welcomeEmbed.error) {
|
||||
return { ok: false, error: welcomeEmbed.error };
|
||||
}
|
||||
const leaveEmbed = parseEmbedJson(value.leaveEmbedText);
|
||||
if (leaveEmbed.error) {
|
||||
return { ok: false, error: leaveEmbed.error };
|
||||
}
|
||||
|
||||
const { userAutoroleIdsText, botAutoroleIdsText, welcomeEmbedText, leaveEmbedText, ...rest } = value;
|
||||
|
||||
const payload: WelcomeConfigDashboard = {
|
||||
...rest,
|
||||
userAutoroleIds: fromCsv(userAutoroleIdsText),
|
||||
botAutoroleIds: fromCsv(botAutoroleIdsText),
|
||||
welcomeEmbed: welcomeEmbed.value,
|
||||
leaveEmbed: leaveEmbed.value
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/guilds/${guildId}/welcome`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||
return { ok: false, error: body?.error ?? `Request failed with status ${response.status}` };
|
||||
}
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return { ok: false, error: 'Network error' };
|
||||
}
|
||||
}
|
||||
|
||||
interface WelcomeFormProps {
|
||||
guildId: string;
|
||||
initialValue: WelcomeConfigDashboard;
|
||||
}
|
||||
|
||||
export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
||||
const t = useTranslations();
|
||||
const userAutorolesId = useId();
|
||||
const botAutorolesId = useId();
|
||||
|
||||
return (
|
||||
<SettingsForm<LocalWelcomeValue> initialValue={toLocal(initialValue)} onSave={(value) => saveWelcome(guildId, value)}>
|
||||
{({ value, setValue }) => (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.welcome.welcomeTitle')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.welcome.welcomeDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||
<Label>{t('modulePages.welcome.welcomeEnabledLabel')}</Label>
|
||||
<Switch
|
||||
checked={value.welcomeEnabled}
|
||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, welcomeEnabled: checked }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.welcomeChannelId')}</Label>
|
||||
<Input
|
||||
value={value.welcomeChannelId}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, welcomeChannelId: event.target.value.trim() }))}
|
||||
placeholder="123456789012345678"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.welcomeType')}</Label>
|
||||
<Select
|
||||
value={value.welcomeType}
|
||||
onValueChange={(welcomeType) =>
|
||||
setValue((prev) => ({ ...prev, welcomeType: welcomeType as WelcomeConfigDashboard['welcomeType'] }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="TEXT">{t('modulePages.welcome.type.TEXT')}</SelectItem>
|
||||
<SelectItem value="EMBED">{t('modulePages.welcome.type.EMBED')}</SelectItem>
|
||||
<SelectItem value="IMAGE">{t('modulePages.welcome.type.IMAGE')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.welcomeContent')}</Label>
|
||||
<Textarea
|
||||
value={value.welcomeContent ?? ''}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, welcomeContent: event.target.value || null }))}
|
||||
placeholder={t('modulePages.welcome.contentPlaceholder')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.welcome.placeholdersHint')}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.welcomeEmbed')}</Label>
|
||||
<Textarea
|
||||
className="font-mono"
|
||||
rows={6}
|
||||
value={value.welcomeEmbedText}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, welcomeEmbedText: event.target.value }))}
|
||||
placeholder='{ "title": "Welcome!", "color": 6591981 }'
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||
<Label>{t('modulePages.welcome.welcomeDmEnabled')}</Label>
|
||||
<Switch
|
||||
checked={value.welcomeDmEnabled}
|
||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, welcomeDmEnabled: checked }))}
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
value={value.welcomeDmContent ?? ''}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, welcomeDmContent: event.target.value || null }))}
|
||||
placeholder={t('modulePages.welcome.dmContentPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={userAutorolesId}>{t('modulePages.welcome.userAutoroles')}</Label>
|
||||
<Textarea
|
||||
id={userAutorolesId}
|
||||
value={value.userAutoroleIdsText}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, userAutoroleIdsText: event.target.value }))}
|
||||
placeholder={t('modulePages.logging.idsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={botAutorolesId}>{t('modulePages.welcome.botAutoroles')}</Label>
|
||||
<Textarea
|
||||
id={botAutorolesId}
|
||||
value={value.botAutoroleIdsText}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, botAutoroleIdsText: event.target.value }))}
|
||||
placeholder={t('modulePages.logging.idsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.imageTitle')}</Label>
|
||||
<Input
|
||||
value={value.imageTitle ?? ''}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, imageTitle: event.target.value || null }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.imageSubtitle')}</Label>
|
||||
<Input
|
||||
value={value.imageSubtitle ?? ''}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, imageSubtitle: event.target.value || null }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.welcome.leaveTitle')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.welcome.leaveDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||
<Label>{t('modulePages.welcome.leaveEnabledLabel')}</Label>
|
||||
<Switch
|
||||
checked={value.leaveEnabled}
|
||||
onCheckedChange={(checked) => setValue((prev) => ({ ...prev, leaveEnabled: checked }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.leaveChannelId')}</Label>
|
||||
<Input
|
||||
value={value.leaveChannelId}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, leaveChannelId: event.target.value.trim() }))}
|
||||
placeholder="123456789012345678"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.leaveContent')}</Label>
|
||||
<Textarea
|
||||
value={value.leaveContent ?? ''}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, leaveContent: event.target.value || null }))}
|
||||
placeholder={t('modulePages.welcome.contentPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('modulePages.welcome.leaveEmbed')}</Label>
|
||||
<Textarea
|
||||
className="font-mono"
|
||||
rows={6}
|
||||
value={value.leaveEmbedText}
|
||||
onChange={(event) => setValue((prev) => ({ ...prev, leaveEmbedText: event.target.value }))}
|
||||
placeholder='{ "title": "Goodbye", "color": 15158332 }'
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</SettingsForm>
|
||||
);
|
||||
}
|
||||
21
apps/webui/src/components/ui/textarea.tsx
Normal file
21
apps/webui/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
'flex min-h-20 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea };
|
||||
Reference in New Issue
Block a user