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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user