deploy #1

Merged
smueller merged 30 commits from deploy into main 2026-07-24 08:41:09 +00:00
9 changed files with 480 additions and 183 deletions
Showing only changes of commit fba597a6f2 - Show all commits

View File

@@ -273,7 +273,7 @@ export async function applyLevelRewards(
}
}
} else {
const reward = rewards.find((entry) => entry.level === newLevel);
const reward = [...rewards].reverse().find((entry) => entry.level <= newLevel);
const removable = rewards
.map((entry) => entry.roleId)
.filter((roleId) => member.roles.cache.has(roleId) && rewardRoleIds.has(roleId));

View File

@@ -12,6 +12,12 @@ import {
type MultiplierEntry
} from '@/components/ui/discord-multiplier-editor';
import { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select';
import {
entriesToRewards,
LevelRewardEditor,
rewardsToEntries,
type LevelRewardEntry
} from '@/components/ui/level-reward-editor';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { PlaceholderHelp } from '@/components/ui/placeholder-help';
@@ -22,21 +28,27 @@ import type { SettingsSaveResult } from '@/components/settings/settings-form';
import { SettingsForm } from '@/components/settings/settings-form';
interface LocalLevelingValue
extends Omit<LevelingConfigDashboard, 'roleMultipliers' | 'channelMultipliers'> {
extends Omit<LevelingConfigDashboard, 'roleMultipliers' | 'channelMultipliers' | 'rewards'> {
roleMultiplierEntries: MultiplierEntry[];
channelMultiplierEntries: MultiplierEntry[];
rewardEntries: LevelRewardEntry[];
}
function toLocal(config: LevelingConfigDashboard): LocalLevelingValue {
const { roleMultipliers, channelMultipliers, ...rest } = config;
const { roleMultipliers, channelMultipliers, rewards, ...rest } = config;
return {
...rest,
roleMultiplierEntries: mapToMultiplierEntries(roleMultipliers),
channelMultiplierEntries: mapToMultiplierEntries(channelMultipliers)
channelMultiplierEntries: mapToMultiplierEntries(channelMultipliers),
rewardEntries: rewardsToEntries(rewards)
};
}
async function saveLeveling(guildId: string, value: LocalLevelingValue, t: (key: string) => string): Promise<SettingsSaveResult> {
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}`) };
@@ -45,17 +57,23 @@ async function saveLeveling(guildId: string, value: LocalLevelingValue, t: (key:
if (channelMultipliers.error) {
return { ok: false, error: t(`modulePages.leveling.errors.${channelMultipliers.error}`) };
}
const rewards = entriesToRewards(value.rewardEntries);
if (rewards.error) {
return { ok: false, error: t(`modulePages.leveling.errors.${rewards.error}`) };
}
const {
roleMultiplierEntries: _roleMultiplierEntries,
channelMultiplierEntries: _channelMultiplierEntries,
rewardEntries: _rewardEntries,
...rest
} = value;
const payload: LevelingConfigDashboard = {
...rest,
roleMultipliers: roleMultipliers.value,
channelMultipliers: channelMultipliers.value
channelMultipliers: channelMultipliers.value,
rewards: rewards.value
};
try {
@@ -96,214 +114,276 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
</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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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.levelUpChannelId')}</Label>
<DiscordChannelSelect
<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}!"
/>
<PlaceholderHelp preset="leveling" />
</div>
</CardContent>
</Card>
</FieldAnchor>
<FieldAnchor id="field-leveling-rewards">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.leveling.rewardsTitle')}</CardTitle>
<CardDescription>{t('modulePages.leveling.rewardsDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<FieldAnchor id="field-leveling-stack">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-1 pr-4">
<Label>{t('modulePages.leveling.stackRewards')}</Label>
<p className="text-xs text-muted-foreground">
{t('modulePages.leveling.stackRewardsHint')}
</p>
</div>
<Switch
checked={value.stackRewards}
onCheckedChange={(checked) =>
setValue((prev) => ({ ...prev, stackRewards: checked }))
}
/>
</div>
</FieldAnchor>
<div className="space-y-2">
<div className="grid grid-cols-[7rem_minmax(0,1fr)_auto] gap-2 text-xs text-muted-foreground">
<span>{t('modulePages.leveling.rewardLevel')}</span>
<span>{t('modulePages.leveling.rewardRole')}</span>
<span className="w-9" />
</div>
<LevelRewardEditor
guildId={guildId}
value={value.levelUpChannelId}
onChange={(levelUpChannelId) => setValue((prev) => ({ ...prev, levelUpChannelId }))}
value={value.rewardEntries}
onChange={(rewardEntries) => setValue((prev) => ({ ...prev, rewardEntries }))}
/>
</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}!"
/>
<PlaceholderHelp preset="leveling" />
</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>
</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 }))}
/>
<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>
<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 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>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
</FieldAnchor>
</div>
)}

View File

@@ -0,0 +1,119 @@
'use client';
import { Plus, Trash2 } from 'lucide-react';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import { DiscordRoleSelect } from '@/components/ui/discord-role-select';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import type { LevelRewardDashboard } from '@nexumi/shared';
export interface LevelRewardEntry extends LevelRewardDashboard {
clientKey: string;
}
export function rewardsToEntries(rewards: LevelRewardDashboard[]): LevelRewardEntry[] {
return rewards.map((reward) => ({
clientKey: `lvl-${reward.level}-${reward.roleId}`,
level: reward.level,
roleId: reward.roleId
}));
}
export function entriesToRewards(
entries: LevelRewardEntry[]
): { value: LevelRewardDashboard[]; error?: string } {
const value: LevelRewardDashboard[] = [];
const levels = new Set<number>();
for (const entry of entries) {
if (!entry.roleId) {
return { value: [], error: 'rewardMissingRole' };
}
if (!Number.isInteger(entry.level) || entry.level < 1) {
return { value: [], error: 'rewardInvalidLevel' };
}
if (levels.has(entry.level)) {
return { value: [], error: 'rewardDuplicateLevel' };
}
levels.add(entry.level);
value.push({ level: entry.level, roleId: entry.roleId });
}
value.sort((a, b) => a.level - b.level);
return { value };
}
interface LevelRewardEditorProps {
guildId: string;
value: LevelRewardEntry[];
onChange: (next: LevelRewardEntry[]) => void;
}
export function LevelRewardEditor({ guildId, value, onChange }: LevelRewardEditorProps) {
const t = useTranslations();
function updateEntry(
clientKey: string,
patch: Partial<Pick<LevelRewardEntry, 'level' | 'roleId'>>
) {
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="w-28 space-y-2">
<Label className="sr-only">{t('modulePages.leveling.rewardLevel')}</Label>
<Input
type="number"
min={1}
max={10000}
value={entry.level}
onChange={(event) =>
updateEntry(entry.clientKey, { level: Number(event.target.value) || 1 })
}
aria-label={t('modulePages.leveling.rewardLevel')}
/>
</div>
<div className="min-w-0 flex-1 space-y-2">
<Label className="sr-only">{t('modulePages.leveling.rewardRole')}</Label>
<DiscordRoleSelect
guildId={guildId}
value={entry.roleId}
onChange={(roleId) => updateEntry(entry.clientKey, { roleId })}
/>
</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-reward-${Date.now()}`,
level: value.length > 0 ? Math.max(...value.map((entry) => entry.level)) + 1 : 5,
roleId: ''
}
])
}
>
<Plus className="size-4" />
{t('modulePages.leveling.addReward')}
</Button>
</div>
);
}

View File

@@ -499,6 +499,13 @@ export const SETTINGS_SEARCH_FIELDS: SearchIndexEntry[] = [
href: 'leveling',
hash: 'field-leveling-stack'
},
{
id: 'setting-leveling-rewards',
kind: 'setting',
labelKey: 'modulePages.leveling.rewardsTitle',
href: 'leveling',
hash: 'field-leveling-rewards'
},
{
id: 'setting-leveling-rank-card',
kind: 'setting',

View File

@@ -1,4 +1,9 @@
import type { LevelingConfigDashboard, LevelingConfigDashboardPatch } from '@nexumi/shared';
import {
hasUniqueRewardLevels,
type LevelingConfigDashboard,
type LevelingConfigDashboardPatch,
type LevelRewardDashboard
} from '@nexumi/shared';
import type { Prisma } from '@prisma/client';
import { prisma } from '../prisma';
@@ -21,7 +26,10 @@ function toMultiplierMap(raw: unknown): Record<string, number> {
return Object.fromEntries(entries);
}
function toDashboard(config: Awaited<ReturnType<typeof ensureLevelingConfig>>): LevelingConfigDashboard {
function toDashboard(
config: Awaited<ReturnType<typeof ensureLevelingConfig>>,
rewards: LevelRewardDashboard[]
): LevelingConfigDashboard {
return {
enabled: config.enabled,
textXpMin: config.textXpMin,
@@ -36,14 +44,25 @@ function toDashboard(config: Awaited<ReturnType<typeof ensureLevelingConfig>>):
levelUpChannelId: config.levelUpChannelId ?? '',
levelUpMessage: config.levelUpMessage,
stackRewards: config.stackRewards,
rewards,
cardAccentColor: config.cardAccentColor,
cardBackgroundColor: config.cardBackgroundColor
};
}
async function loadRewards(guildId: string): Promise<LevelRewardDashboard[]> {
const rows = await prisma.levelReward.findMany({
where: { guildId },
orderBy: { level: 'asc' },
select: { level: true, roleId: true }
});
return rows.map((row) => ({ level: row.level, roleId: row.roleId }));
}
export async function getLevelingDashboard(guildId: string): Promise<LevelingConfigDashboard> {
const config = await ensureLevelingConfig(guildId);
return toDashboard(config);
const rewards = await loadRewards(guildId);
return toDashboard(config, rewards);
}
export async function updateLevelingDashboard(
@@ -52,7 +71,11 @@ export async function updateLevelingDashboard(
): Promise<LevelingConfigDashboard> {
await ensureLevelingConfig(guildId);
const { levelUpChannelId, roleMultipliers, channelMultipliers, ...rest } = patch;
if (patch.rewards !== undefined && !hasUniqueRewardLevels(patch.rewards)) {
throw new Error('Duplicate reward levels are not allowed');
}
const { levelUpChannelId, roleMultipliers, channelMultipliers, rewards, ...rest } = patch;
const data: Prisma.LevelingConfigUpdateInput = { ...rest };
if (levelUpChannelId !== undefined) {
data.levelUpChannelId = levelUpChannelId === '' ? null : levelUpChannelId;
@@ -64,6 +87,23 @@ export async function updateLevelingDashboard(
data.channelMultipliers = channelMultipliers as Prisma.InputJsonValue;
}
const updated = await prisma.levelingConfig.update({ where: { guildId }, data });
return toDashboard(updated);
await prisma.$transaction(async (tx) => {
if (Object.keys(data).length > 0) {
await tx.levelingConfig.update({ where: { guildId }, data });
}
if (rewards !== undefined) {
await tx.levelReward.deleteMany({ where: { guildId } });
if (rewards.length > 0) {
await tx.levelReward.createMany({
data: rewards.map((reward) => ({
guildId,
level: reward.level,
roleId: reward.roleId
}))
});
}
}
});
return getLevelingDashboard(guildId);
}

View File

@@ -522,7 +522,10 @@
"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."
"multiplierDuplicate": "Jede Rolle bzw. jeder Kanal darf nur einmal vorkommen.",
"rewardMissingRole": "Jede Belohnung braucht eine Rolle.",
"rewardInvalidLevel": "Das Level muss eine ganze Zahl ab 1 sein.",
"rewardDuplicateLevel": "Jedes Level darf nur einmal eine Belohnung haben."
},
"levelUpTitle": "Level-Up-Nachricht",
"levelUpMode": "Modus",
@@ -533,7 +536,13 @@
},
"levelUpChannelId": "Level-Up-Kanal",
"levelUpMessage": "Nachrichtentext",
"rewardsTitle": "Rollen-Belohnungen",
"rewardsDescription": "Lege fest, ab welchem Level welche Rolle vergeben wird.",
"rewardLevel": "Level",
"rewardRole": "Rolle",
"addReward": "Belohnung hinzufügen",
"stackRewards": "Rollen-Belohnungen stapeln",
"stackRewardsHint": "An = alle erreichten Belohnungsrollen behalten. Aus = nur die Rolle des aktuellen Levels.",
"rankCardTitle": "Rang-Karte",
"cardAccentColor": "Akzentfarbe",
"cardBackgroundColor": "Hintergrundfarbe"

View File

@@ -522,7 +522,10 @@
"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."
"multiplierDuplicate": "Each role or channel may only appear once.",
"rewardMissingRole": "Every reward needs a role.",
"rewardInvalidLevel": "Level must be a whole number of at least 1.",
"rewardDuplicateLevel": "Each level may only have one reward."
},
"levelUpTitle": "Level-up message",
"levelUpMode": "Mode",
@@ -533,7 +536,13 @@
},
"levelUpChannelId": "Level-up channel",
"levelUpMessage": "Message content",
"rewardsTitle": "Role rewards",
"rewardsDescription": "Choose which role is granted at which level.",
"rewardLevel": "Level",
"rewardRole": "Role",
"addReward": "Add reward",
"stackRewards": "Stack role rewards",
"stackRewardsHint": "On = keep all unlocked reward roles. Off = only the role for the current level.",
"rankCardTitle": "Rank card",
"cardAccentColor": "Accent color",
"cardBackgroundColor": "Background color"

View File

@@ -244,6 +244,20 @@
- Kein Browser-Fingerprinting (DSGVO); IP nur gehasht mit Salt
- Keine manuelle „Alt freigeben“-UI (nur Flag in `VerificationRecord`)
## Post-Phase Leveling Rollen-Belohnungen UI (Status: implementiert)
### Abgeschlossen (Code)
- Dashboard Leveling: Level→Rolle-Belohnungen bearbeiten (Liste, speichern ersetzt `LevelReward`)
- Shared Schema `rewards` + `hasUniqueRewardLevels`; i18n DE/EN
- Stapel-Toggle mit Hinweis neben dem Belohnungs-Editor
### Manuell testen
- [ ] Belohnung hinzufügen (z. B. Level 5 → Rolle), speichern, Mitglied levelt → Rolle vergeben
- [ ] Stack an/aus: stapeln vs. nur aktuelle Belohnungsrolle
- [ ] Doppeltes Level → Fehlermeldung, speichern blockiert
## Nächster geplanter Schritt
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy``main` mergen (nur auf Freigabe).

View File

@@ -269,6 +269,12 @@ export type VerificationConfigDashboardPatch = z.infer<typeof VerificationConfig
export const LevelUpModeDashboardSchema = z.enum(['CHANNEL', 'DM', 'OFF']);
export const LevelRewardDashboardSchema = z.object({
level: z.number().int().min(1).max(10_000),
roleId: SnowflakeSchema
});
export type LevelRewardDashboard = z.infer<typeof LevelRewardDashboardSchema>;
export const LevelingConfigDashboardSchema = z.object({
enabled: z.boolean(),
textXpMin: z.number().int().positive(),
@@ -283,6 +289,7 @@ export const LevelingConfigDashboardSchema = z.object({
levelUpChannelId: OptionalSnowflakeSchema,
levelUpMessage: z.string().max(500).nullable().optional(),
stackRewards: z.boolean(),
rewards: z.array(LevelRewardDashboardSchema).max(100),
cardAccentColor: z.string().regex(/^#[0-9a-fA-F]{6}$/),
cardBackgroundColor: z.string().regex(/^#[0-9a-fA-F]{6}$/)
});
@@ -291,6 +298,18 @@ export type LevelingConfigDashboard = z.infer<typeof LevelingConfigDashboardSche
export const LevelingConfigDashboardPatchSchema = nonEmptyPatch(LevelingConfigDashboardSchema);
export type LevelingConfigDashboardPatch = z.infer<typeof LevelingConfigDashboardPatchSchema>;
/** Returns true when every reward level is unique. */
export function hasUniqueRewardLevels(rewards: LevelRewardDashboard[]): boolean {
const levels = new Set<number>();
for (const reward of rewards) {
if (levels.has(reward.level)) {
return false;
}
levels.add(reward.level);
}
return true;
}
// ---------------------------------------------------------------------------
// Economy
// ---------------------------------------------------------------------------