Refactor leveling form to use Discord multiplier editor and enhance localization

- 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.
This commit is contained in:
TheOnlyMace
2026-07-22 21:34:29 +02:00
parent 042f68e777
commit 7888ccb7e6
5 changed files with 198 additions and 69 deletions

View File

@@ -5,6 +5,12 @@ import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider'; import { useTranslations } from '@/components/locale-provider';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DiscordChannelMultiSelect, DiscordChannelSelect } from '@/components/ui/discord-channel-select'; 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 { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -16,55 +22,34 @@ import { SettingsForm } from '@/components/settings/settings-form';
interface LocalLevelingValue interface LocalLevelingValue
extends Omit<LevelingConfigDashboard, 'roleMultipliers' | 'channelMultipliers'> { extends Omit<LevelingConfigDashboard, 'roleMultipliers' | 'channelMultipliers'> {
roleMultipliersText: string; roleMultiplierEntries: MultiplierEntry[];
channelMultipliersText: string; channelMultiplierEntries: MultiplierEntry[];
}
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 { function toLocal(config: LevelingConfigDashboard): LocalLevelingValue {
const { roleMultipliers, channelMultipliers, ...rest } = config; const { roleMultipliers, channelMultipliers, ...rest } = config;
return { return {
...rest, ...rest,
roleMultipliersText: toMultiplierText(roleMultipliers), roleMultiplierEntries: mapToMultiplierEntries(roleMultipliers),
channelMultipliersText: toMultiplierText(channelMultipliers) channelMultiplierEntries: mapToMultiplierEntries(channelMultipliers)
}; };
} }
async function saveLeveling(guildId: string, value: LocalLevelingValue): Promise<SettingsSaveResult> { async function saveLeveling(guildId: string, value: LocalLevelingValue, t: (key: string) => string): Promise<SettingsSaveResult> {
const roleMultipliers = fromMultiplierText(value.roleMultipliersText); const roleMultipliers = multiplierEntriesToMap(value.roleMultiplierEntries);
if (roleMultipliers.error) { if (roleMultipliers.error) {
return { ok: false, error: roleMultipliers.error }; return { ok: false, error: t(`modulePages.leveling.errors.${roleMultipliers.error}`) };
} }
const channelMultipliers = fromMultiplierText(value.channelMultipliersText); const channelMultipliers = multiplierEntriesToMap(value.channelMultiplierEntries);
if (channelMultipliers.error) { if (channelMultipliers.error) {
return { ok: false, error: channelMultipliers.error }; return { ok: false, error: t(`modulePages.leveling.errors.${channelMultipliers.error}`) };
} }
const { roleMultipliersText: _roleMultipliersText, channelMultipliersText: _channelMultipliersText, ...rest } = value; const {
roleMultiplierEntries: _roleMultiplierEntries,
channelMultiplierEntries: _channelMultiplierEntries,
...rest
} = value;
const payload: LevelingConfigDashboard = { const payload: LevelingConfigDashboard = {
...rest, ...rest,
@@ -99,7 +84,7 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
return ( return (
<SettingsForm<LocalLevelingValue> <SettingsForm<LocalLevelingValue>
initialValue={toLocal(initialValue)} initialValue={toLocal(initialValue)}
onSave={(value) => saveLeveling(guildId, value)} onSave={(value) => saveLeveling(guildId, value, t)}
> >
{({ value, setValue }) => ( {({ value, setValue }) => (
<div className="space-y-6"> <div className="space-y-6">
@@ -197,12 +182,11 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
<FieldAnchor id="field-leveling-role-multipliers"> <FieldAnchor id="field-leveling-role-multipliers">
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('modulePages.leveling.roleMultipliers')}</Label> <Label>{t('modulePages.leveling.roleMultipliers')}</Label>
<Textarea <DiscordMultiplierEditor
className="font-mono" guildId={guildId}
rows={4} kind="role"
value={value.roleMultipliersText} value={value.roleMultiplierEntries}
onChange={(event) => setValue((prev) => ({ ...prev, roleMultipliersText: event.target.value }))} onChange={(roleMultiplierEntries) => setValue((prev) => ({ ...prev, roleMultiplierEntries }))}
placeholder="123456789012345678:1.5"
/> />
<p className="text-xs text-muted-foreground">{t('modulePages.leveling.multiplierHint')}</p> <p className="text-xs text-muted-foreground">{t('modulePages.leveling.multiplierHint')}</p>
</div> </div>
@@ -210,12 +194,13 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
<FieldAnchor id="field-leveling-channel-multipliers"> <FieldAnchor id="field-leveling-channel-multipliers">
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('modulePages.leveling.channelMultipliers')}</Label> <Label>{t('modulePages.leveling.channelMultipliers')}</Label>
<Textarea <DiscordMultiplierEditor
className="font-mono" guildId={guildId}
rows={4} kind="channel"
value={value.channelMultipliersText} value={value.channelMultiplierEntries}
onChange={(event) => setValue((prev) => ({ ...prev, channelMultipliersText: event.target.value }))} onChange={(channelMultiplierEntries) =>
placeholder="123456789012345678:2" setValue((prev) => ({ ...prev, channelMultiplierEntries }))
}
/> />
<p className="text-xs text-muted-foreground">{t('modulePages.leveling.multiplierHint')}</p> <p className="text-xs text-muted-foreground">{t('modulePages.leveling.multiplierHint')}</p>
</div> </div>
@@ -243,9 +228,9 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="CHANNEL">{t('modulePages.leveling.mode.CHANNEL')}</SelectItem> <SelectItem value="CHANNEL">{t('modulePages.leveling.modes.CHANNEL')}</SelectItem>
<SelectItem value="DM">{t('modulePages.leveling.mode.DM')}</SelectItem> <SelectItem value="DM">{t('modulePages.leveling.modes.DM')}</SelectItem>
<SelectItem value="OFF">{t('modulePages.leveling.mode.OFF')}</SelectItem> <SelectItem value="OFF">{t('modulePages.leveling.modes.OFF')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>

View File

@@ -70,8 +70,8 @@ export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProp
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="CHANNEL">{t('modulePages.tickets.mode.CHANNEL')}</SelectItem> <SelectItem value="CHANNEL">{t('modulePages.tickets.modes.CHANNEL')}</SelectItem>
<SelectItem value="THREAD">{t('modulePages.tickets.mode.THREAD')}</SelectItem> <SelectItem value="THREAD">{t('modulePages.tickets.modes.THREAD')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>

View File

@@ -0,0 +1,122 @@
'use client';
import { Plus, Trash2 } from 'lucide-react';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import { DiscordChannelSelect } from '@/components/ui/discord-channel-select';
import { DiscordRoleSelect } from '@/components/ui/discord-role-select';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export interface MultiplierEntry {
clientKey: string;
id: string;
multiplier: number;
}
export function mapToMultiplierEntries(map: Record<string, number>): MultiplierEntry[] {
return Object.entries(map).map(([id, multiplier]) => ({
clientKey: id,
id,
multiplier
}));
}
export function multiplierEntriesToMap(
entries: MultiplierEntry[]
): { value: Record<string, number>; error?: string } {
const value: Record<string, number> = {};
for (const entry of entries) {
if (!entry.id) {
return { value: {}, error: 'multiplierMissingId' };
}
if (!Number.isFinite(entry.multiplier) || entry.multiplier <= 0) {
return { value: {}, error: 'multiplierInvalid' };
}
if (value[entry.id] !== undefined) {
return { value: {}, error: 'multiplierDuplicate' };
}
value[entry.id] = entry.multiplier;
}
return { value };
}
interface DiscordMultiplierEditorProps {
guildId: string;
kind: 'role' | 'channel';
value: MultiplierEntry[];
onChange: (next: MultiplierEntry[]) => void;
}
export function DiscordMultiplierEditor({ guildId, kind, value, onChange }: DiscordMultiplierEditorProps) {
const t = useTranslations();
function updateEntry(clientKey: string, patch: Partial<Pick<MultiplierEntry, 'id' | 'multiplier'>>) {
onChange(value.map((entry) => (entry.clientKey === clientKey ? { ...entry, ...patch } : entry)));
}
return (
<div className="space-y-3">
{value.map((entry) => (
<div key={entry.clientKey} className="flex items-end gap-2">
<div className="min-w-0 flex-1 space-y-2">
<Label className="sr-only">
{kind === 'role' ? t('modulePages.leveling.roleMultipliers') : t('modulePages.leveling.channelMultipliers')}
</Label>
{kind === 'role' ? (
<DiscordRoleSelect
guildId={guildId}
value={entry.id}
onChange={(id) => updateEntry(entry.clientKey, { id })}
/>
) : (
<DiscordChannelSelect
guildId={guildId}
kinds={['text', 'voice']}
value={entry.id}
onChange={(id) => updateEntry(entry.clientKey, { id })}
/>
)}
</div>
<div className="w-24 space-y-2">
<Label className="sr-only">{t('modulePages.leveling.multiplierValue')}</Label>
<Input
type="number"
min={0.1}
step={0.1}
value={entry.multiplier}
onChange={(event) =>
updateEntry(entry.clientKey, { multiplier: Number(event.target.value) || 0 })
}
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t('common.remove')}
onClick={() => onChange(value.filter((item) => item.clientKey !== entry.clientKey))}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
onChange([
...value,
{ clientKey: `new-${kind}-${Date.now()}`, id: '', multiplier: 1.5 }
])
}
>
<Plus className="size-4" />
{t('modulePages.leveling.addMultiplier')}
</Button>
</div>
);
}

View File

@@ -318,13 +318,22 @@
"noXpRoles": "No-XP-Rollen", "noXpRoles": "No-XP-Rollen",
"roleMultipliers": "Rollen-Multiplikatoren", "roleMultipliers": "Rollen-Multiplikatoren",
"channelMultipliers": "Kanal-Multiplikatoren", "channelMultipliers": "Kanal-Multiplikatoren",
"multiplierHint": "Ein Eintrag pro Zeile im Format ID:Multiplikator, z. B. 123456789012345678:1.5", "multiplierHint": "Rolle bzw. Kanal wählen und Multiplikator setzen (z. B. 1.5).",
"multiplierValue": "Multiplikator",
"addMultiplier": "Multiplikator hinzufügen",
"errors": {
"multiplierMissingId": "Jeder Multiplikator braucht eine Rolle bzw. einen Kanal.",
"multiplierInvalid": "Multiplikatoren müssen eine positive Zahl sein.",
"multiplierDuplicate": "Jede Rolle bzw. jeder Kanal darf nur einmal vorkommen."
},
"levelUpTitle": "Level-Up-Nachricht", "levelUpTitle": "Level-Up-Nachricht",
"levelUpMode": "Modus", "levelUpMode": "Modus",
"mode.CHANNEL": "Kanal", "modes": {
"mode.DM": "Direktnachricht", "CHANNEL": "Kanal",
"mode.OFF": "Aus", "DM": "Direktnachricht",
"levelUpChannelId": "Level-Up-Kanal-ID", "OFF": "Aus"
},
"levelUpChannelId": "Level-Up-Kanal",
"levelUpMessage": "Nachrichtentext", "levelUpMessage": "Nachrichtentext",
"stackRewards": "Rollen-Belohnungen stapeln", "stackRewards": "Rollen-Belohnungen stapeln",
"rankCardTitle": "Rang-Karte", "rankCardTitle": "Rang-Karte",
@@ -358,10 +367,12 @@
"description": "Lege fest, wie Support-Tickets erstellt und verwaltet werden.", "description": "Lege fest, wie Support-Tickets erstellt und verwaltet werden.",
"enabledLabel": "Ticket-Modul aktiviert", "enabledLabel": "Ticket-Modul aktiviert",
"mode": "Ticket-Modus", "mode": "Ticket-Modus",
"mode.CHANNEL": "Privater Kanal", "modes": {
"mode.THREAD": "Privater Thread", "CHANNEL": "Privater Kanal",
"THREAD": "Privater Thread"
},
"categoryId": "Ticket-Kategorie-ID", "categoryId": "Ticket-Kategorie-ID",
"logChannelId": "Log-Kanal-ID", "logChannelId": "Log-Kanal",
"inactivityHours": "Automatisch schließen nach Inaktivität (Stunden)", "inactivityHours": "Automatisch schließen nach Inaktivität (Stunden)",
"transcriptDm": "Transkript per DM an Ticket-Ersteller", "transcriptDm": "Transkript per DM an Ticket-Ersteller",
"transcriptDmHint": "Sendet dem Nutzer nach Schließung ein Transkript per DM.", "transcriptDmHint": "Sendet dem Nutzer nach Schließung ein Transkript per DM.",

View File

@@ -318,13 +318,22 @@
"noXpRoles": "No-XP roles", "noXpRoles": "No-XP roles",
"roleMultipliers": "Role multipliers", "roleMultipliers": "Role multipliers",
"channelMultipliers": "Channel multipliers", "channelMultipliers": "Channel multipliers",
"multiplierHint": "One entry per line in the format id:multiplier, e.g. 123456789012345678:1.5", "multiplierHint": "Pick a role or channel and set a multiplier (e.g. 1.5).",
"multiplierValue": "Multiplier",
"addMultiplier": "Add multiplier",
"errors": {
"multiplierMissingId": "Every multiplier needs a role or channel.",
"multiplierInvalid": "Multipliers must be a positive number.",
"multiplierDuplicate": "Each role or channel may only appear once."
},
"levelUpTitle": "Level-up message", "levelUpTitle": "Level-up message",
"levelUpMode": "Mode", "levelUpMode": "Mode",
"mode.CHANNEL": "Channel", "modes": {
"mode.DM": "Direct message", "CHANNEL": "Channel",
"mode.OFF": "Off", "DM": "Direct message",
"levelUpChannelId": "Level-up channel ID", "OFF": "Off"
},
"levelUpChannelId": "Level-up channel",
"levelUpMessage": "Message content", "levelUpMessage": "Message content",
"stackRewards": "Stack role rewards", "stackRewards": "Stack role rewards",
"rankCardTitle": "Rank card", "rankCardTitle": "Rank card",
@@ -358,10 +367,12 @@
"description": "Configure how support tickets are created and managed.", "description": "Configure how support tickets are created and managed.",
"enabledLabel": "Tickets module enabled", "enabledLabel": "Tickets module enabled",
"mode": "Ticket mode", "mode": "Ticket mode",
"mode.CHANNEL": "Private channel", "modes": {
"mode.THREAD": "Private thread", "CHANNEL": "Private channel",
"THREAD": "Private thread"
},
"categoryId": "Ticket category ID", "categoryId": "Ticket category ID",
"logChannelId": "Log channel ID", "logChannelId": "Log channel",
"inactivityHours": "Auto-close after inactivity (hours)", "inactivityHours": "Auto-close after inactivity (hours)",
"transcriptDm": "DM transcript to ticket creator", "transcriptDm": "DM transcript to ticket creator",
"transcriptDmHint": "Sends a transcript to the user via DM once their ticket is closed.", "transcriptDmHint": "Sends a transcript to the user via DM once their ticket is closed.",