- Replaced text area inputs for role and channel multipliers with DiscordMultiplierEditor components for improved user experience. - Updated state management to handle multiplier entries as objects instead of text, enhancing data integrity. - Enhanced localization messages for multipliers, including error handling and hints for user guidance.
311 lines
13 KiB
TypeScript
311 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import type { LevelingConfigDashboard } 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, DiscordChannelSelect } from '@/components/ui/discord-channel-select';
|
|
import {
|
|
DiscordMultiplierEditor,
|
|
mapToMultiplierEntries,
|
|
multiplierEntriesToMap,
|
|
type MultiplierEntry
|
|
} from '@/components/ui/discord-multiplier-editor';
|
|
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 { 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, 'roleMultipliers' | 'channelMultipliers'> {
|
|
roleMultiplierEntries: MultiplierEntry[];
|
|
channelMultiplierEntries: MultiplierEntry[];
|
|
}
|
|
|
|
function toLocal(config: LevelingConfigDashboard): LocalLevelingValue {
|
|
const { roleMultipliers, channelMultipliers, ...rest } = config;
|
|
return {
|
|
...rest,
|
|
roleMultiplierEntries: mapToMultiplierEntries(roleMultipliers),
|
|
channelMultiplierEntries: mapToMultiplierEntries(channelMultipliers)
|
|
};
|
|
}
|
|
|
|
async function saveLeveling(guildId: string, value: LocalLevelingValue, t: (key: string) => string): Promise<SettingsSaveResult> {
|
|
const roleMultipliers = multiplierEntriesToMap(value.roleMultiplierEntries);
|
|
if (roleMultipliers.error) {
|
|
return { ok: false, error: t(`modulePages.leveling.errors.${roleMultipliers.error}`) };
|
|
}
|
|
const channelMultipliers = multiplierEntriesToMap(value.channelMultiplierEntries);
|
|
if (channelMultipliers.error) {
|
|
return { ok: false, error: t(`modulePages.leveling.errors.${channelMultipliers.error}`) };
|
|
}
|
|
|
|
const {
|
|
roleMultiplierEntries: _roleMultiplierEntries,
|
|
channelMultiplierEntries: _channelMultiplierEntries,
|
|
...rest
|
|
} = value;
|
|
|
|
const payload: LevelingConfigDashboard = {
|
|
...rest,
|
|
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();
|
|
|
|
return (
|
|
<SettingsForm<LocalLevelingValue>
|
|
initialValue={toLocal(initialValue)}
|
|
onSave={(value) => saveLeveling(guildId, value, t)}
|
|
>
|
|
{({ 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">
|
|
<FieldAnchor id="field-leveling-enabled">
|
|
<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>
|
|
</FieldAnchor>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-3">
|
|
<FieldAnchor id="field-leveling-text-min">
|
|
<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>
|
|
</FieldAnchor>
|
|
<FieldAnchor id="field-leveling-text-max">
|
|
<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>
|
|
</FieldAnchor>
|
|
<FieldAnchor id="field-leveling-cooldown">
|
|
<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>
|
|
</FieldAnchor>
|
|
</div>
|
|
|
|
<FieldAnchor id="field-leveling-voice">
|
|
<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>
|
|
</FieldAnchor>
|
|
|
|
<FieldAnchor id="field-leveling-no-xp-channels">
|
|
<div className="space-y-2">
|
|
<Label>{t('modulePages.leveling.noXpChannels')}</Label>
|
|
<DiscordChannelMultiSelect
|
|
guildId={guildId}
|
|
value={value.noXpChannelIds}
|
|
onChange={(noXpChannelIds) => setValue((prev) => ({ ...prev, noXpChannelIds }))}
|
|
/>
|
|
</div>
|
|
</FieldAnchor>
|
|
<FieldAnchor id="field-leveling-no-xp-roles">
|
|
<div className="space-y-2">
|
|
<Label>{t('modulePages.leveling.noXpRoles')}</Label>
|
|
<DiscordRoleMultiSelect
|
|
guildId={guildId}
|
|
value={value.noXpRoleIds}
|
|
onChange={(noXpRoleIds) => setValue((prev) => ({ ...prev, noXpRoleIds }))}
|
|
/>
|
|
</div>
|
|
</FieldAnchor>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<FieldAnchor id="field-leveling-role-multipliers">
|
|
<div className="space-y-2">
|
|
<Label>{t('modulePages.leveling.roleMultipliers')}</Label>
|
|
<DiscordMultiplierEditor
|
|
guildId={guildId}
|
|
kind="role"
|
|
value={value.roleMultiplierEntries}
|
|
onChange={(roleMultiplierEntries) => setValue((prev) => ({ ...prev, roleMultiplierEntries }))}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">{t('modulePages.leveling.multiplierHint')}</p>
|
|
</div>
|
|
</FieldAnchor>
|
|
<FieldAnchor id="field-leveling-channel-multipliers">
|
|
<div className="space-y-2">
|
|
<Label>{t('modulePages.leveling.channelMultipliers')}</Label>
|
|
<DiscordMultiplierEditor
|
|
guildId={guildId}
|
|
kind="channel"
|
|
value={value.channelMultiplierEntries}
|
|
onChange={(channelMultiplierEntries) =>
|
|
setValue((prev) => ({ ...prev, channelMultiplierEntries }))
|
|
}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">{t('modulePages.leveling.multiplierHint')}</p>
|
|
</div>
|
|
</FieldAnchor>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<FieldAnchor id="field-leveling-levelup">
|
|
<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.modes.CHANNEL')}</SelectItem>
|
|
<SelectItem value="DM">{t('modulePages.leveling.modes.DM')}</SelectItem>
|
|
<SelectItem value="OFF">{t('modulePages.leveling.modes.OFF')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>{t('modulePages.leveling.levelUpChannelId')}</Label>
|
|
<DiscordChannelSelect
|
|
guildId={guildId}
|
|
value={value.levelUpChannelId}
|
|
onChange={(levelUpChannelId) => setValue((prev) => ({ ...prev, levelUpChannelId }))}
|
|
/>
|
|
</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>
|
|
<FieldAnchor id="field-leveling-stack">
|
|
<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>
|
|
</FieldAnchor>
|
|
</CardContent>
|
|
</Card>
|
|
</FieldAnchor>
|
|
|
|
<FieldAnchor id="field-leveling-rank-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>
|
|
</FieldAnchor>
|
|
</div>
|
|
)}
|
|
</SettingsForm>
|
|
);
|
|
}
|