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:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
122
apps/webui/src/components/ui/discord-multiplier-editor.tsx
Normal file
122
apps/webui/src/components/ui/discord-multiplier-editor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
Reference in New Issue
Block a user