Implement PlaceholderHelp component across various forms for enhanced user guidance
- Integrated PlaceholderHelp component into multiple forms including WelcomeForm, TagsManager, and StatsForm to provide contextual hints for available placeholders. - Updated DiscordEmbedBuilder to support placeholder presets, improving user experience when creating embeds. - Enhanced localization files to include new placeholder descriptions, ensuring clarity in both English and German. - Refactored related components to maintain consistency in placeholder handling across the application.
This commit is contained in:
@@ -5,7 +5,7 @@ import {
|
|||||||
type MessageCreateOptions,
|
type MessageCreateOptions,
|
||||||
type TextChannel
|
type TextChannel
|
||||||
} from 'discord.js';
|
} from 'discord.js';
|
||||||
import { WelcomeEmbedSchema, t, tf } from '@nexumi/shared';
|
import { WelcomeEmbedSchema, expandBracketChannelMentions, t, tf } from '@nexumi/shared';
|
||||||
import type { ScheduledMessage } from '@prisma/client';
|
import type { ScheduledMessage } from '@prisma/client';
|
||||||
import { ensureGuild } from '../../guild.js';
|
import { ensureGuild } from '../../guild.js';
|
||||||
import { logger } from '../../logger.js';
|
import { logger } from '../../logger.js';
|
||||||
@@ -99,15 +99,15 @@ export function buildAnnouncementPayload(schedule: {
|
|||||||
if (embedData) {
|
if (embedData) {
|
||||||
const embed = new EmbedBuilder().setColor(embedData.color ?? 0x6366f1);
|
const embed = new EmbedBuilder().setColor(embedData.color ?? 0x6366f1);
|
||||||
if (embedData.title) {
|
if (embedData.title) {
|
||||||
embed.setTitle(embedData.title);
|
embed.setTitle(expandBracketChannelMentions(embedData.title));
|
||||||
}
|
}
|
||||||
if (embedData.description) {
|
if (embedData.description) {
|
||||||
embed.setDescription(embedData.description);
|
embed.setDescription(expandBracketChannelMentions(embedData.description));
|
||||||
}
|
}
|
||||||
embeds.push(embed);
|
embeds.push(embed);
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseContent = schedule.content?.trim() ?? '';
|
const baseContent = expandBracketChannelMentions(schedule.content?.trim() ?? '');
|
||||||
const roleMention = schedule.rolePingId ? `<@&${schedule.rolePingId}>` : '';
|
const roleMention = schedule.rolePingId ? `<@&${schedule.rolePingId}>` : '';
|
||||||
const content = [roleMention, baseContent].filter(Boolean).join(' ').trim();
|
const content = [roleMention, baseContent].filter(Boolean).join(' ').trim();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { PermissionFlagsBits, type GuildMember } from 'discord.js';
|
import { PermissionFlagsBits, type GuildMember } from 'discord.js';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
import { getWelcomeConfig, resolveWelcomePayload } from './service.js';
|
import { getWelcomeConfig, resolveWelcomePayload, renderWelcomeText } from './service.js';
|
||||||
import { sendLeaveMessage, sendWelcomeMessage } from './renderer.js';
|
import { sendLeaveMessage, sendWelcomeMessage } from './renderer.js';
|
||||||
import { logger } from '../../logger.js';
|
import { logger } from '../../logger.js';
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ export async function handleMemberWelcome(context: BotContext, member: GuildMemb
|
|||||||
|
|
||||||
if (config.welcomeDmEnabled && config.welcomeDmContent && !member.user.bot) {
|
if (config.welcomeDmEnabled && config.welcomeDmContent && !member.user.bot) {
|
||||||
await member
|
await member
|
||||||
.send({ content: config.welcomeDmContent.replace('{user}', `<@${member.id}>`) })
|
.send({ content: renderWelcomeText(config.welcomeDmContent, member, member.guild) })
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { DiscordChannelSelect } from '@/components/ui/discord-channel-select';
|
|||||||
import { DiscordRoleSelect } from '@/components/ui/discord-role-select';
|
import { DiscordRoleSelect } 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';
|
||||||
|
import { PlaceholderHelp } from '@/components/ui/placeholder-help';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
||||||
@@ -87,6 +88,7 @@ export function BirthdaysForm({ guildId, initialValue }: BirthdaysFormProps) {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.birthdays.message')}</Label>
|
<Label>{t('modulePages.birthdays.message')}</Label>
|
||||||
<Textarea value={value.message ?? ''} onChange={(event) => setValue((prev) => ({ ...prev, message: event.target.value || null }))} placeholder={t('modulePages.birthdays.messagePlaceholder')} />
|
<Textarea value={value.message ?? ''} onChange={(event) => setValue((prev) => ({ ...prev, message: event.target.value || null }))} placeholder={t('modulePages.birthdays.messagePlaceholder')} />
|
||||||
|
<PlaceholderHelp preset="birthdays" />
|
||||||
</div>
|
</div>
|
||||||
</FieldAnchor>
|
</FieldAnchor>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { DiscordChannelSelect } from '@/components/ui/discord-channel-select';
|
|||||||
import { DiscordRoleSelect } from '@/components/ui/discord-role-select';
|
import { DiscordRoleSelect } 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';
|
||||||
|
import { PlaceholderHelp } from '@/components/ui/placeholder-help';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
|
||||||
@@ -206,6 +207,7 @@ export function FeedsManager({ guildId, initialFeeds }: FeedsManagerProps) {
|
|||||||
onChange={(event) => setDraft((prev) => ({ ...prev, template: event.target.value }))}
|
onChange={(event) => setDraft((prev) => ({ ...prev, template: event.target.value }))}
|
||||||
placeholder={t('modulePages.feeds.templatePlaceholder')}
|
placeholder={t('modulePages.feeds.templatePlaceholder')}
|
||||||
/>
|
/>
|
||||||
|
<PlaceholderHelp preset="feeds" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" variant="outline" onClick={handleCreate} disabled={creating}>
|
<Button type="button" variant="outline" onClick={handleCreate} disabled={creating}>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
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';
|
||||||
|
import { PlaceholderHelp } from '@/components/ui/placeholder-help';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
@@ -250,6 +251,7 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
|
|||||||
onChange={(event) => setValue((prev) => ({ ...prev, levelUpMessage: event.target.value || null }))}
|
onChange={(event) => setValue((prev) => ({ ...prev, levelUpMessage: event.target.value || null }))}
|
||||||
placeholder="Congrats {user}, you reached level {level}!"
|
placeholder="Congrats {user}, you reached level {level}!"
|
||||||
/>
|
/>
|
||||||
|
<PlaceholderHelp preset="leveling" />
|
||||||
</div>
|
</div>
|
||||||
<FieldAnchor id="field-leveling-stack">
|
<FieldAnchor id="field-leveling-stack">
|
||||||
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
<div className="flex items-center justify-between rounded-md border border-border p-4">
|
||||||
|
|||||||
@@ -2,15 +2,16 @@
|
|||||||
|
|
||||||
import type { LogChannelMapping, LogEventTypeDashboard, LoggingConfigDashboard } from '@nexumi/shared';
|
import type { LogChannelMapping, LogEventTypeDashboard, LoggingConfigDashboard } from '@nexumi/shared';
|
||||||
import { Plus, Trash2 } from 'lucide-react';
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
import { FieldAnchor } from '@/components/layout/field-anchor';
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
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 { DiscordResourceMultiSelect } from '@/components/ui/discord-resource-multi-select';
|
||||||
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';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
||||||
import { SettingsForm } from '@/components/settings/settings-form';
|
import { SettingsForm } from '@/components/settings/settings-form';
|
||||||
@@ -44,8 +45,10 @@ const LOG_EVENT_TYPES: LogEventTypeDashboard[] = [
|
|||||||
'MOD_ACTION'
|
'MOD_ACTION'
|
||||||
];
|
];
|
||||||
|
|
||||||
interface LocalLogChannel extends LogChannelMapping {
|
interface LocalLogChannelGroup {
|
||||||
clientKey: string;
|
clientKey: string;
|
||||||
|
channelId: string;
|
||||||
|
eventTypes: LogEventTypeDashboard[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LocalLoggingValue {
|
interface LocalLoggingValue {
|
||||||
@@ -54,37 +57,78 @@ interface LocalLoggingValue {
|
|||||||
ignoredChannelIds: string[];
|
ignoredChannelIds: string[];
|
||||||
ignoredRoleIds: string[];
|
ignoredRoleIds: string[];
|
||||||
retentionDays: number;
|
retentionDays: number;
|
||||||
logChannels: LocalLogChannel[];
|
logChannels: LocalLogChannelGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function toLocal(config: LoggingConfigDashboard): LocalLoggingValue {
|
function toLocal(config: LoggingConfigDashboard): LocalLoggingValue {
|
||||||
|
const byChannel = new Map<string, LogEventTypeDashboard[]>();
|
||||||
|
for (const mapping of config.logChannels) {
|
||||||
|
const existing = byChannel.get(mapping.channelId) ?? [];
|
||||||
|
existing.push(mapping.eventType);
|
||||||
|
byChannel.set(mapping.channelId, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const logChannels: LocalLogChannelGroup[] = [...byChannel.entries()].map(([channelId, eventTypes], index) => ({
|
||||||
|
clientKey: `${channelId}-${index}`,
|
||||||
|
channelId,
|
||||||
|
eventTypes: [...new Set(eventTypes)]
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: config.enabled,
|
enabled: config.enabled,
|
||||||
ignoreBots: config.ignoreBots,
|
ignoreBots: config.ignoreBots,
|
||||||
ignoredChannelIds: config.ignoredChannelIds,
|
ignoredChannelIds: config.ignoredChannelIds,
|
||||||
ignoredRoleIds: config.ignoredRoleIds,
|
ignoredRoleIds: config.ignoredRoleIds,
|
||||||
retentionDays: config.retentionDays,
|
retentionDays: config.retentionDays,
|
||||||
logChannels: config.logChannels.map((mapping, index) => ({ ...mapping, clientKey: `${mapping.eventType}-${index}` }))
|
logChannels
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toRemote(value: LocalLoggingValue): LoggingConfigDashboard {
|
function toRemote(value: LocalLoggingValue): { ok: true; value: LoggingConfigDashboard } | { ok: false; error: string } {
|
||||||
|
const mappings: LogChannelMapping[] = [];
|
||||||
|
const seenEventTypes = new Set<LogEventTypeDashboard>();
|
||||||
|
|
||||||
|
for (const group of value.logChannels) {
|
||||||
|
if (!group.channelId.trim() || group.eventTypes.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const eventType of group.eventTypes) {
|
||||||
|
if (seenEventTypes.has(eventType)) {
|
||||||
|
return { ok: false, error: 'duplicateEventType' };
|
||||||
|
}
|
||||||
|
seenEventTypes.add(eventType);
|
||||||
|
mappings.push({ eventType, channelId: group.channelId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: value.enabled,
|
ok: true,
|
||||||
ignoreBots: value.ignoreBots,
|
value: {
|
||||||
ignoredChannelIds: value.ignoredChannelIds,
|
enabled: value.enabled,
|
||||||
ignoredRoleIds: value.ignoredRoleIds,
|
ignoreBots: value.ignoreBots,
|
||||||
retentionDays: value.retentionDays,
|
ignoredChannelIds: value.ignoredChannelIds,
|
||||||
logChannels: value.logChannels.map(({ clientKey: _clientKey, ...rest }) => rest)
|
ignoredRoleIds: value.ignoredRoleIds,
|
||||||
|
retentionDays: value.retentionDays,
|
||||||
|
logChannels: mappings
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveLogging(guildId: string, value: LocalLoggingValue): Promise<SettingsSaveResult> {
|
async function saveLogging(
|
||||||
|
guildId: string,
|
||||||
|
value: LocalLoggingValue,
|
||||||
|
t: (key: string) => string
|
||||||
|
): Promise<SettingsSaveResult> {
|
||||||
|
const remote = toRemote(value);
|
||||||
|
if (!remote.ok) {
|
||||||
|
return { ok: false, error: t(`modulePages.logging.errors.${remote.error}`) };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/guilds/${guildId}/logging`, {
|
const response = await fetch(`/api/guilds/${guildId}/logging`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(toRemote(value))
|
body: JSON.stringify(remote.value)
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
const body = (await response.json().catch(() => null)) as { error?: string } | null;
|
||||||
@@ -104,10 +148,19 @@ interface LoggingFormProps {
|
|||||||
export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
|
export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const eventOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
LOG_EVENT_TYPES.map((eventType) => ({
|
||||||
|
id: eventType,
|
||||||
|
name: t(`modulePages.logging.eventTypes.${eventType}`)
|
||||||
|
})),
|
||||||
|
[t]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsForm<LocalLoggingValue>
|
<SettingsForm<LocalLoggingValue>
|
||||||
initialValue={toLocal(initialValue)}
|
initialValue={toLocal(initialValue)}
|
||||||
onSave={(value) => saveLogging(guildId, value)}
|
onSave={(value) => saveLogging(guildId, value, t)}
|
||||||
>
|
>
|
||||||
{({ value, setValue }) => (
|
{({ value, setValue }) => (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -186,63 +239,63 @@ export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
|
|||||||
<p className="text-sm text-muted-foreground">{t('modulePages.logging.channelsEmpty')}</p>
|
<p className="text-sm text-muted-foreground">{t('modulePages.logging.channelsEmpty')}</p>
|
||||||
)}
|
)}
|
||||||
{value.logChannels.map((mapping) => (
|
{value.logChannels.map((mapping) => (
|
||||||
<div key={mapping.clientKey} className="flex flex-wrap items-end gap-3 rounded-md border border-border p-3">
|
<div key={mapping.clientKey} className="space-y-3 rounded-md border border-border p-3">
|
||||||
<div className="space-y-2">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<Label>{t('modulePages.logging.eventType')}</Label>
|
<div className="min-w-[14rem] flex-1 space-y-2">
|
||||||
<Select
|
<Label>{t('modulePages.logging.channelId')}</Label>
|
||||||
value={mapping.eventType}
|
<DiscordChannelSelect
|
||||||
onValueChange={(eventType) =>
|
guildId={guildId}
|
||||||
|
value={mapping.channelId}
|
||||||
|
onChange={(channelId) =>
|
||||||
|
setValue((prev) => ({
|
||||||
|
...prev,
|
||||||
|
logChannels: prev.logChannels.map((entry) =>
|
||||||
|
entry.clientKey === mapping.clientKey ? { ...entry, channelId } : entry
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={t('common.remove')}
|
||||||
|
onClick={() =>
|
||||||
setValue((prev) => ({
|
setValue((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
logChannels: prev.logChannels.map((entry) =>
|
logChannels: prev.logChannels.filter((entry) => entry.clientKey !== mapping.clientKey)
|
||||||
entry.clientKey === mapping.clientKey
|
|
||||||
? { ...entry, eventType: eventType as LogEventTypeDashboard }
|
|
||||||
: entry
|
|
||||||
)
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-56">
|
<Trash2 className="size-4" />
|
||||||
<SelectValue />
|
</Button>
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{LOG_EVENT_TYPES.map((eventType) => (
|
|
||||||
<SelectItem key={eventType} value={eventType}>
|
|
||||||
{eventType}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.logging.channelId')}</Label>
|
<Label>{t('modulePages.logging.eventTypesLabel')}</Label>
|
||||||
<DiscordChannelSelect
|
<DiscordResourceMultiSelect
|
||||||
guildId={guildId}
|
options={eventOptions}
|
||||||
value={mapping.channelId}
|
value={mapping.eventTypes}
|
||||||
onChange={(channelId) =>
|
showIdHint={false}
|
||||||
|
placeholder={t('modulePages.logging.eventTypesPlaceholder')}
|
||||||
|
onChange={(nextIds) => {
|
||||||
|
const eventTypes = nextIds as LogEventTypeDashboard[];
|
||||||
setValue((prev) => ({
|
setValue((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
logChannels: prev.logChannels.map((entry) =>
|
logChannels: prev.logChannels.map((entry) => {
|
||||||
entry.clientKey === mapping.clientKey ? { ...entry, channelId } : entry
|
if (entry.clientKey === mapping.clientKey) {
|
||||||
)
|
return { ...entry, eventTypes };
|
||||||
}))
|
}
|
||||||
}
|
// Each event type can only map to one channel (DB unique).
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
eventTypes: entry.eventTypes.filter((eventType) => !eventTypes.includes(eventType))
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
aria-label={t('common.remove')}
|
|
||||||
onClick={() =>
|
|
||||||
setValue((prev) => ({
|
|
||||||
...prev,
|
|
||||||
logChannels: prev.logChannels.filter((entry) => entry.clientKey !== mapping.clientKey)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Button
|
<Button
|
||||||
@@ -253,7 +306,7 @@ export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
|
|||||||
...prev,
|
...prev,
|
||||||
logChannels: [
|
logChannels: [
|
||||||
...prev.logChannels,
|
...prev.logChannels,
|
||||||
{ clientKey: `new-${Date.now()}`, eventType: 'MOD_ACTION', channelId: '' }
|
{ clientKey: `new-${Date.now()}`, channelId: '', eventTypes: [] }
|
||||||
]
|
]
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -261,6 +314,7 @@ export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
|
|||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
{t('modulePages.logging.addMapping')}
|
{t('modulePages.logging.addMapping')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('modulePages.logging.channelsHint')}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</FieldAnchor>
|
</FieldAnchor>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { DiscordEmbedBuilder, normalizeEmbed } from '@/components/ui/discord-emb
|
|||||||
import { DiscordRoleSelect } from '@/components/ui/discord-role-select';
|
import { DiscordRoleSelect } 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';
|
||||||
|
import { PlaceholderHelp } from '@/components/ui/placeholder-help';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
|
||||||
const EMPTY_DRAFT = {
|
const EMPTY_DRAFT = {
|
||||||
@@ -168,10 +169,15 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
|
|||||||
onChange={(event) => setDraft((prev) => ({ ...prev, content: event.target.value }))}
|
onChange={(event) => setDraft((prev) => ({ ...prev, content: event.target.value }))}
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
|
<PlaceholderHelp preset="scheduler" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.scheduler.embed')}</Label>
|
<Label>{t('modulePages.scheduler.embed')}</Label>
|
||||||
<DiscordEmbedBuilder value={draft.embed} onChange={(embed) => setDraft((prev) => ({ ...prev, embed }))} />
|
<DiscordEmbedBuilder
|
||||||
|
value={draft.embed}
|
||||||
|
onChange={(embed) => setDraft((prev) => ({ ...prev, embed }))}
|
||||||
|
placeholderPreset="scheduler"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">{t('modulePages.scheduler.cronHint')}</p>
|
<p className="text-xs text-muted-foreground">{t('modulePages.scheduler.cronHint')}</p>
|
||||||
<Button type="button" onClick={handleCreate} disabled={creating}>
|
<Button type="button" onClick={handleCreate} disabled={creating}>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { DiscordChannelSelect } from '@/components/ui/discord-channel-select';
|
import { DiscordChannelSelect } from '@/components/ui/discord-channel-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';
|
||||||
|
import { PlaceholderHelp } from '@/components/ui/placeholder-help';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
||||||
import { SettingsForm } from '@/components/settings/settings-form';
|
import { SettingsForm } from '@/components/settings/settings-form';
|
||||||
@@ -102,7 +103,7 @@ export function StatsForm({ guildId, initialValue }: StatsFormProps) {
|
|||||||
<Input id={boostsTemplateId} value={value.boostsTemplate} onChange={(event) => setValue((prev) => ({ ...prev, boostsTemplate: event.target.value }))} />
|
<Input id={boostsTemplateId} value={value.boostsTemplate} onChange={(event) => setValue((prev) => ({ ...prev, boostsTemplate: event.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">{t('modulePages.stats.templateHint')}</p>
|
<PlaceholderHelp preset="stats" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { TagDashboard, TagResponseType } from '@nexumi/shared';
|
import type { TagDashboard, TagResponseType, WelcomeEmbed } from '@nexumi/shared';
|
||||||
import { Plus, Trash2 } from 'lucide-react';
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -9,9 +9,11 @@ import { useTranslations } from '@/components/locale-provider';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { DiscordChannelMultiSelect } from '@/components/ui/discord-channel-select';
|
import { DiscordChannelMultiSelect } from '@/components/ui/discord-channel-select';
|
||||||
|
import { DiscordEmbedBuilder, normalizeEmbed } from '@/components/ui/discord-embed-builder';
|
||||||
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';
|
||||||
|
import { PlaceholderHelp } from '@/components/ui/placeholder-help';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
|
||||||
@@ -25,6 +27,7 @@ interface LocalTag extends TagDashboard {
|
|||||||
const EMPTY_DRAFT = {
|
const EMPTY_DRAFT = {
|
||||||
name: '',
|
name: '',
|
||||||
content: '',
|
content: '',
|
||||||
|
embed: null as WelcomeEmbed | null,
|
||||||
responseType: 'TEXT' as TagResponseType,
|
responseType: 'TEXT' as TagResponseType,
|
||||||
triggerWord: '',
|
triggerWord: '',
|
||||||
allowedRoleIds: [] as string[],
|
allowedRoleIds: [] as string[],
|
||||||
@@ -41,6 +44,7 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
|
|||||||
const [tags, setTags] = useState<LocalTag[]>(
|
const [tags, setTags] = useState<LocalTag[]>(
|
||||||
initialTags.map((tag, index) => ({
|
initialTags.map((tag, index) => ({
|
||||||
...tag,
|
...tag,
|
||||||
|
embed: tag.embed ?? null,
|
||||||
clientKey: tag.id ?? `existing-${index}`
|
clientKey: tag.id ?? `existing-${index}`
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
@@ -52,6 +56,11 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
|
|||||||
toast.error(t('modulePages.tags.nameRequired'));
|
toast.error(t('modulePages.tags.nameRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const embed = draft.responseType === 'EMBED' ? normalizeEmbed(draft.embed) : null;
|
||||||
|
if (draft.responseType === 'EMBED' && !embed?.title && !embed?.description) {
|
||||||
|
toast.error(t('modulePages.tags.embedRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/guilds/${guildId}/tags`, {
|
const response = await fetch(`/api/guilds/${guildId}/tags`, {
|
||||||
@@ -60,6 +69,7 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: draft.name.trim(),
|
name: draft.name.trim(),
|
||||||
content: draft.content.trim() || null,
|
content: draft.content.trim() || null,
|
||||||
|
embed,
|
||||||
responseType: draft.responseType,
|
responseType: draft.responseType,
|
||||||
triggerWord: draft.triggerWord.trim() || null,
|
triggerWord: draft.triggerWord.trim() || null,
|
||||||
allowedRoleIds: draft.allowedRoleIds,
|
allowedRoleIds: draft.allowedRoleIds,
|
||||||
@@ -88,6 +98,11 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave(tag: LocalTag) {
|
async function handleSave(tag: LocalTag) {
|
||||||
|
const embed = tag.responseType === 'EMBED' ? normalizeEmbed(tag.embed ?? null) : null;
|
||||||
|
if (tag.responseType === 'EMBED' && !embed?.title && !embed?.description) {
|
||||||
|
toast.error(t('modulePages.tags.embedRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, saving: true } : entry)));
|
setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, saving: true } : entry)));
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/guilds/${guildId}/tags/${tag.id}`, {
|
const response = await fetch(`/api/guilds/${guildId}/tags/${tag.id}`, {
|
||||||
@@ -96,6 +111,7 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: tag.name,
|
name: tag.name,
|
||||||
content: tag.content,
|
content: tag.content,
|
||||||
|
embed,
|
||||||
responseType: tag.responseType,
|
responseType: tag.responseType,
|
||||||
triggerWord: tag.triggerWord,
|
triggerWord: tag.triggerWord,
|
||||||
allowedRoleIds: tag.allowedRoleIds,
|
allowedRoleIds: tag.allowedRoleIds,
|
||||||
@@ -105,12 +121,19 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
|
|||||||
const body = (await response.json().catch(() => null)) as (TagDashboard & { error?: string }) | null;
|
const body = (await response.json().catch(() => null)) as (TagDashboard & { error?: string }) | null;
|
||||||
if (!response.ok || !body) {
|
if (!response.ok || !body) {
|
||||||
toast.error(body?.error ?? t('common.saveError'));
|
toast.error(body?.error ?? t('common.saveError'));
|
||||||
|
setTags((prev) =>
|
||||||
|
prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, saving: false } : entry))
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setTags((prev) =>
|
||||||
|
prev.map((entry) =>
|
||||||
|
entry.clientKey === tag.clientKey ? { ...entry, ...body, clientKey: entry.clientKey, saving: false } : entry
|
||||||
|
)
|
||||||
|
);
|
||||||
toast.success(t('common.saveSuccess'));
|
toast.success(t('common.saveSuccess'));
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('common.saveError'));
|
toast.error(t('common.saveError'));
|
||||||
} finally {
|
|
||||||
setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, saving: false } : entry)));
|
setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, saving: false } : entry)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,10 +189,22 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
|
|||||||
<Input value={draft.triggerWord} onChange={(event) => setDraft((prev) => ({ ...prev, triggerWord: event.target.value }))} placeholder={t('common.optional')} />
|
<Input value={draft.triggerWord} onChange={(event) => setDraft((prev) => ({ ...prev, triggerWord: event.target.value }))} placeholder={t('common.optional')} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
{draft.responseType === 'TEXT' ? (
|
||||||
<Label>{t('modulePages.tags.content')}</Label>
|
<div className="space-y-2">
|
||||||
<Textarea rows={3} value={draft.content} onChange={(event) => setDraft((prev) => ({ ...prev, content: event.target.value }))} />
|
<Label>{t('modulePages.tags.content')}</Label>
|
||||||
</div>
|
<Textarea rows={3} value={draft.content} onChange={(event) => setDraft((prev) => ({ ...prev, content: event.target.value }))} />
|
||||||
|
<PlaceholderHelp preset="tags" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('modulePages.tags.embed')}</Label>
|
||||||
|
<DiscordEmbedBuilder
|
||||||
|
value={draft.embed}
|
||||||
|
onChange={(embed) => setDraft((prev) => ({ ...prev, embed }))}
|
||||||
|
placeholderPreset="tags"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.tags.allowedRoleIds')}</Label>
|
<Label>{t('modulePages.tags.allowedRoleIds')}</Label>
|
||||||
@@ -197,7 +232,6 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
|
|||||||
</FieldAnchor>
|
</FieldAnchor>
|
||||||
|
|
||||||
<FieldAnchor id="field-tags-list">
|
<FieldAnchor id="field-tags-list">
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('modulePages.tags.listTitle')}</CardTitle>
|
<CardTitle>{t('modulePages.tags.listTitle')}</CardTitle>
|
||||||
@@ -232,10 +266,26 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
|
|||||||
<Input value={tag.triggerWord ?? ''} onChange={(event) => setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, triggerWord: event.target.value || null } : entry)))} />
|
<Input value={tag.triggerWord ?? ''} onChange={(event) => setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, triggerWord: event.target.value || null } : entry)))} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
{tag.responseType === 'TEXT' ? (
|
||||||
<Label>{t('modulePages.tags.content')}</Label>
|
<div className="space-y-2">
|
||||||
<Textarea rows={3} value={tag.content ?? ''} onChange={(event) => setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, content: event.target.value } : entry)))} />
|
<Label>{t('modulePages.tags.content')}</Label>
|
||||||
</div>
|
<Textarea rows={3} value={tag.content ?? ''} onChange={(event) => setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, content: event.target.value } : entry)))} />
|
||||||
|
<PlaceholderHelp preset="tags" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t('modulePages.tags.embed')}</Label>
|
||||||
|
<DiscordEmbedBuilder
|
||||||
|
value={tag.embed ?? null}
|
||||||
|
onChange={(embed) =>
|
||||||
|
setTags((prev) =>
|
||||||
|
prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, embed } : entry))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholderPreset="tags"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.tags.allowedRoleIds')}</Label>
|
<Label>{t('modulePages.tags.allowedRoleIds')}</Label>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { DiscordChannelSelect } from '@/components/ui/discord-channel-select';
|
import { DiscordChannelSelect } from '@/components/ui/discord-channel-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';
|
||||||
|
import { PlaceholderHelp } from '@/components/ui/placeholder-help';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
||||||
import { SettingsForm } from '@/components/settings/settings-form';
|
import { SettingsForm } from '@/components/settings/settings-form';
|
||||||
@@ -81,6 +82,7 @@ export function TempVoiceForm({ guildId, initialValue }: TempVoiceFormProps) {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={nameId}>{t('modulePages.tempvoice.defaultName')}</Label>
|
<Label htmlFor={nameId}>{t('modulePages.tempvoice.defaultName')}</Label>
|
||||||
<Input id={nameId} value={value.defaultName} onChange={(event) => setValue((prev) => ({ ...prev, defaultName: event.target.value }))} placeholder="{user}'s Channel" />
|
<Input id={nameId} value={value.defaultName} onChange={(event) => setValue((prev) => ({ ...prev, defaultName: event.target.value }))} placeholder="{user}'s Channel" />
|
||||||
|
<PlaceholderHelp preset="tempvoice" />
|
||||||
</div>
|
</div>
|
||||||
</FieldAnchor>
|
</FieldAnchor>
|
||||||
<FieldAnchor id="field-tempvoice-limit">
|
<FieldAnchor id="field-tempvoice-limit">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
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';
|
||||||
|
import { PlaceholderHelp } from '@/components/ui/placeholder-help';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
@@ -131,7 +132,7 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
onChange={(event) => setValue((prev) => ({ ...prev, welcomeContent: event.target.value || null }))}
|
onChange={(event) => setValue((prev) => ({ ...prev, welcomeContent: event.target.value || null }))}
|
||||||
placeholder={t('modulePages.welcome.contentPlaceholder')}
|
placeholder={t('modulePages.welcome.contentPlaceholder')}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">{t('modulePages.welcome.placeholdersHint')}</p>
|
<PlaceholderHelp preset="welcome" />
|
||||||
</div>
|
</div>
|
||||||
</FieldAnchor>
|
</FieldAnchor>
|
||||||
<FieldAnchor id="field-welcome-embed">
|
<FieldAnchor id="field-welcome-embed">
|
||||||
@@ -141,6 +142,7 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
value={value.welcomeEmbed}
|
value={value.welcomeEmbed}
|
||||||
onChange={(welcomeEmbed) => setValue((prev) => ({ ...prev, welcomeEmbed }))}
|
onChange={(welcomeEmbed) => setValue((prev) => ({ ...prev, welcomeEmbed }))}
|
||||||
previewVars={WELCOME_PREVIEW_VARS}
|
previewVars={WELCOME_PREVIEW_VARS}
|
||||||
|
placeholderPreset="welcome"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</FieldAnchor>
|
</FieldAnchor>
|
||||||
@@ -158,6 +160,7 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
onChange={(event) => setValue((prev) => ({ ...prev, welcomeDmContent: event.target.value || null }))}
|
onChange={(event) => setValue((prev) => ({ ...prev, welcomeDmContent: event.target.value || null }))}
|
||||||
placeholder={t('modulePages.welcome.dmContentPlaceholder')}
|
placeholder={t('modulePages.welcome.dmContentPlaceholder')}
|
||||||
/>
|
/>
|
||||||
|
<PlaceholderHelp preset="welcomeDm" />
|
||||||
</div>
|
</div>
|
||||||
</FieldAnchor>
|
</FieldAnchor>
|
||||||
<FieldAnchor id="field-welcome-user-autoroles">
|
<FieldAnchor id="field-welcome-user-autoroles">
|
||||||
@@ -200,6 +203,7 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
</FieldAnchor>
|
</FieldAnchor>
|
||||||
</div>
|
</div>
|
||||||
|
<PlaceholderHelp preset="welcome" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -236,6 +240,7 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
onChange={(event) => setValue((prev) => ({ ...prev, leaveContent: event.target.value || null }))}
|
onChange={(event) => setValue((prev) => ({ ...prev, leaveContent: event.target.value || null }))}
|
||||||
placeholder={t('modulePages.welcome.contentPlaceholder')}
|
placeholder={t('modulePages.welcome.contentPlaceholder')}
|
||||||
/>
|
/>
|
||||||
|
<PlaceholderHelp preset="welcome" />
|
||||||
</div>
|
</div>
|
||||||
</FieldAnchor>
|
</FieldAnchor>
|
||||||
<FieldAnchor id="field-leave-embed">
|
<FieldAnchor id="field-leave-embed">
|
||||||
@@ -245,6 +250,7 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
value={value.leaveEmbed}
|
value={value.leaveEmbed}
|
||||||
onChange={(leaveEmbed) => setValue((prev) => ({ ...prev, leaveEmbed }))}
|
onChange={(leaveEmbed) => setValue((prev) => ({ ...prev, leaveEmbed }))}
|
||||||
previewVars={WELCOME_PREVIEW_VARS}
|
previewVars={WELCOME_PREVIEW_VARS}
|
||||||
|
placeholderPreset="welcome"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</FieldAnchor>
|
</FieldAnchor>
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import type { WelcomeEmbed } from '@nexumi/shared';
|
|||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { PlaceholderHelp } from '@/components/ui/placeholder-help';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import type { PlaceholderPresetId } from '@/lib/placeholder-presets';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export const DEFAULT_EMBED_COLOR = 0x6366f1;
|
export const DEFAULT_EMBED_COLOR = 0x6366f1;
|
||||||
@@ -54,10 +56,12 @@ function hexToColor(hex: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyPreviewVars(template: string, vars: Record<string, string>): string {
|
function applyPreviewVars(template: string, vars: Record<string, string>): string {
|
||||||
return Object.entries(vars).reduce(
|
const withVars = Object.entries(vars).reduce(
|
||||||
(acc, [key, value]) => acc.replaceAll(`{${key}}`, value),
|
(acc, [key, value]) => acc.replaceAll(`{${key}}`, value),
|
||||||
template
|
template
|
||||||
);
|
);
|
||||||
|
// Show bracket channel mentions as #id in the dashboard preview.
|
||||||
|
return withVars.replace(/\["(\d{17,20})"\]/g, '#$1').replace(/<#(\d{17,20})>/g, '#$1');
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DiscordEmbedBuilderProps {
|
interface DiscordEmbedBuilderProps {
|
||||||
@@ -67,6 +71,8 @@ interface DiscordEmbedBuilderProps {
|
|||||||
/** Sample values for preview placeholder substitution (e.g. user → @Alex). */
|
/** Sample values for preview placeholder substitution (e.g. user → @Alex). */
|
||||||
previewVars?: Record<string, string>;
|
previewVars?: Record<string, string>;
|
||||||
showClearHint?: boolean;
|
showClearHint?: boolean;
|
||||||
|
/** Which placeholders apply to this embed context. */
|
||||||
|
placeholderPreset?: PlaceholderPresetId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DiscordEmbedBuilder({
|
export function DiscordEmbedBuilder({
|
||||||
@@ -74,7 +80,8 @@ export function DiscordEmbedBuilder({
|
|||||||
onChange,
|
onChange,
|
||||||
className,
|
className,
|
||||||
previewVars,
|
previewVars,
|
||||||
showClearHint = true
|
showClearHint = true,
|
||||||
|
placeholderPreset = 'welcome'
|
||||||
}: DiscordEmbedBuilderProps) {
|
}: DiscordEmbedBuilderProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const draft = value ?? emptyEmbed();
|
const draft = value ?? emptyEmbed();
|
||||||
@@ -146,6 +153,7 @@ export function DiscordEmbedBuilder({
|
|||||||
{showClearHint ? (
|
{showClearHint ? (
|
||||||
<p className="text-xs text-muted-foreground">{t('embedBuilder.clearHint')}</p>
|
<p className="text-xs text-muted-foreground">{t('embedBuilder.clearHint')}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
<PlaceholderHelp preset={placeholderPreset} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
|
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ interface DiscordResourceMultiSelectProps {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
emptyLabel?: string;
|
emptyLabel?: string;
|
||||||
|
/** Show a truncated id hint in the dropdown (useful for Discord snowflakes). */
|
||||||
|
showIdHint?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DiscordResourceMultiSelect({
|
export function DiscordResourceMultiSelect({
|
||||||
@@ -37,7 +39,8 @@ export function DiscordResourceMultiSelect({
|
|||||||
onChange,
|
onChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
emptyLabel
|
emptyLabel,
|
||||||
|
showIdHint = true
|
||||||
}: DiscordResourceMultiSelectProps) {
|
}: DiscordResourceMultiSelectProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -96,9 +99,11 @@ export function DiscordResourceMultiSelect({
|
|||||||
{option.prefix ?? ''}
|
{option.prefix ?? ''}
|
||||||
{option.name}
|
{option.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-auto font-mono text-[10px] text-muted-foreground">
|
{showIdHint ? (
|
||||||
{option.id.slice(-6)}
|
<span className="ml-auto font-mono text-[10px] text-muted-foreground">
|
||||||
</span>
|
{option.id.slice(-6)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
96
apps/webui/src/components/ui/placeholder-help.tsx
Normal file
96
apps/webui/src/components/ui/placeholder-help.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Check, ChevronDown, Copy } from 'lucide-react';
|
||||||
|
import { useId, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
PLACEHOLDER_PRESETS,
|
||||||
|
type PlaceholderDefinition,
|
||||||
|
type PlaceholderPresetId
|
||||||
|
} from '@/lib/placeholder-presets';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface PlaceholderHelpProps {
|
||||||
|
preset: PlaceholderPresetId;
|
||||||
|
/** Optional extra tokens for one-off fields. */
|
||||||
|
extra?: PlaceholderDefinition[];
|
||||||
|
className?: string;
|
||||||
|
/** Start expanded (default: collapsed). */
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaceholderHelp({
|
||||||
|
preset,
|
||||||
|
extra = [],
|
||||||
|
className,
|
||||||
|
defaultOpen = false
|
||||||
|
}: PlaceholderHelpProps) {
|
||||||
|
const t = useTranslations();
|
||||||
|
const panelId = useId();
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
const [copiedToken, setCopiedToken] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const items = [...PLACEHOLDER_PRESETS[preset], ...extra];
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToken(token: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(token);
|
||||||
|
setCopiedToken(token);
|
||||||
|
toast.success(t('placeholders.copied'));
|
||||||
|
window.setTimeout(() => setCopiedToken((current) => (current === token ? null : current)), 1500);
|
||||||
|
} catch {
|
||||||
|
toast.error(t('placeholders.copyFailed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('rounded-md border border-border bg-muted/30', className)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={panelId}
|
||||||
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<span>{t('placeholders.toggle')}</span>
|
||||||
|
<ChevronDown className={cn('size-4 shrink-0 transition-transform', open && 'rotate-180')} />
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div id={panelId} className="space-y-2 border-t border-border px-3 py-2">
|
||||||
|
<p className="text-xs text-muted-foreground">{t('placeholders.hint')}</p>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{items.map((item) => {
|
||||||
|
const isCopied = copiedToken === item.token;
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={item.token}
|
||||||
|
className="flex items-start gap-2 rounded-md border border-transparent px-1 py-0.5 hover:border-border hover:bg-background/60"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<code className="break-all font-mono text-xs text-foreground">{item.token}</code>
|
||||||
|
<p className="text-[11px] leading-snug text-muted-foreground">{t(item.descriptionKey)}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-7 shrink-0"
|
||||||
|
aria-label={t('placeholders.copy')}
|
||||||
|
onClick={() => void copyToken(item.token)}
|
||||||
|
>
|
||||||
|
{isCopied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
apps/webui/src/lib/placeholder-presets.ts
Normal file
80
apps/webui/src/lib/placeholder-presets.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
export type PlaceholderPresetId =
|
||||||
|
| 'welcome'
|
||||||
|
| 'welcomeDm'
|
||||||
|
| 'tags'
|
||||||
|
| 'birthdays'
|
||||||
|
| 'leveling'
|
||||||
|
| 'tempvoice'
|
||||||
|
| 'stats'
|
||||||
|
| 'feeds'
|
||||||
|
| 'scheduler'
|
||||||
|
| 'channelMention';
|
||||||
|
|
||||||
|
export interface PlaceholderDefinition {
|
||||||
|
/** Exact token to insert/copy, e.g. `{user}` or `["123…"]`. */
|
||||||
|
token: string;
|
||||||
|
/** i18n key under `placeholders.desc.*`. */
|
||||||
|
descriptionKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHANNEL_MENTIONS: PlaceholderDefinition[] = [
|
||||||
|
{
|
||||||
|
token: '["123456789012345678"]',
|
||||||
|
descriptionKey: 'placeholders.desc.channelBracket'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
token: '<#123456789012345678>',
|
||||||
|
descriptionKey: 'placeholders.desc.channelNative'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const USER_SERVER: PlaceholderDefinition[] = [
|
||||||
|
{ token: '{user}', descriptionKey: 'placeholders.desc.userMention' },
|
||||||
|
{ token: '{user.name}', descriptionKey: 'placeholders.desc.userName' },
|
||||||
|
{ token: '{user.tag}', descriptionKey: 'placeholders.desc.userTag' },
|
||||||
|
{ token: '{user.id}', descriptionKey: 'placeholders.desc.userId' },
|
||||||
|
{ token: '{server}', descriptionKey: 'placeholders.desc.server' },
|
||||||
|
{ token: '{memberCount}', descriptionKey: 'placeholders.desc.memberCount' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholders that actually work in each dashboard field context.
|
||||||
|
* Keep this list in sync with the bot renderers.
|
||||||
|
*/
|
||||||
|
export const PLACEHOLDER_PRESETS: Record<PlaceholderPresetId, PlaceholderDefinition[]> = {
|
||||||
|
welcome: [...USER_SERVER, ...CHANNEL_MENTIONS],
|
||||||
|
welcomeDm: [...USER_SERVER, ...CHANNEL_MENTIONS],
|
||||||
|
tags: [
|
||||||
|
{ token: '{user}', descriptionKey: 'placeholders.desc.userMention' },
|
||||||
|
{ token: '{user.name}', descriptionKey: 'placeholders.desc.userName' },
|
||||||
|
{ token: '{user.tag}', descriptionKey: 'placeholders.desc.userTag' },
|
||||||
|
{ token: '{user.id}', descriptionKey: 'placeholders.desc.userId' },
|
||||||
|
{ token: '{server}', descriptionKey: 'placeholders.desc.server' },
|
||||||
|
{ token: '{args}', descriptionKey: 'placeholders.desc.args' },
|
||||||
|
{ token: '{random:a|b|c}', descriptionKey: 'placeholders.desc.random' },
|
||||||
|
...CHANNEL_MENTIONS
|
||||||
|
],
|
||||||
|
birthdays: [
|
||||||
|
{ token: '{user}', descriptionKey: 'placeholders.desc.userMention' },
|
||||||
|
{ token: '{user.name}', descriptionKey: 'placeholders.desc.userName' },
|
||||||
|
{ token: '{user.tag}', descriptionKey: 'placeholders.desc.userTag' },
|
||||||
|
{ token: '{age}', descriptionKey: 'placeholders.desc.age' }
|
||||||
|
],
|
||||||
|
leveling: [
|
||||||
|
{ token: '{user}', descriptionKey: 'placeholders.desc.userMention' },
|
||||||
|
{ token: '{username}', descriptionKey: 'placeholders.desc.username' },
|
||||||
|
{ token: '{level}', descriptionKey: 'placeholders.desc.level' }
|
||||||
|
],
|
||||||
|
tempvoice: [
|
||||||
|
{ token: '{user}', descriptionKey: 'placeholders.desc.userDisplayName' },
|
||||||
|
{ token: '{user.name}', descriptionKey: 'placeholders.desc.userName' },
|
||||||
|
{ token: '{user.tag}', descriptionKey: 'placeholders.desc.userTagPlain' }
|
||||||
|
],
|
||||||
|
stats: [{ token: '{count}', descriptionKey: 'placeholders.desc.count' }],
|
||||||
|
feeds: [
|
||||||
|
{ token: '{title}', descriptionKey: 'placeholders.desc.feedTitle' },
|
||||||
|
{ token: '{url}', descriptionKey: 'placeholders.desc.feedUrl' }
|
||||||
|
],
|
||||||
|
scheduler: [...CHANNEL_MENTIONS],
|
||||||
|
channelMention: [...CHANNEL_MENTIONS]
|
||||||
|
};
|
||||||
@@ -35,6 +35,33 @@
|
|||||||
"clear": "Embed leeren",
|
"clear": "Embed leeren",
|
||||||
"clearHint": "Leere Felder speichern kein Embed."
|
"clearHint": "Leere Felder speichern kein Embed."
|
||||||
},
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"toggle": "Verfügbare Platzhalter anzeigen",
|
||||||
|
"hint": "Zum Einfügen auf Kopieren tippen. Nur die hier gelisteten Platzhalter funktionieren in diesem Feld.",
|
||||||
|
"copy": "Platzhalter kopieren",
|
||||||
|
"copied": "Platzhalter kopiert",
|
||||||
|
"copyFailed": "Kopieren fehlgeschlagen",
|
||||||
|
"desc": {
|
||||||
|
"userMention": "Erwähnung des Mitglieds",
|
||||||
|
"userName": "Anzeigename des Mitglieds",
|
||||||
|
"userTag": "Benutzername/Tag des Mitglieds",
|
||||||
|
"userId": "Discord-ID des Mitglieds",
|
||||||
|
"userDisplayName": "Anzeigename (ohne Erwähnung)",
|
||||||
|
"userTagPlain": "Benutzername ohne Erwähnung",
|
||||||
|
"username": "Anzeigename des Mitglieds",
|
||||||
|
"server": "Servername",
|
||||||
|
"memberCount": "Aktuelle Mitgliederzahl",
|
||||||
|
"args": "Argumente nach dem Tag-Befehl",
|
||||||
|
"random": "Zufällige Wahl aus Optionen (mit | getrennt)",
|
||||||
|
"age": "Alter in Jahren (nur wenn Geburtsjahr gesetzt)",
|
||||||
|
"level": "Erreichtes Level",
|
||||||
|
"count": "Aktueller Statistik-Wert",
|
||||||
|
"feedTitle": "Titel des Feed-Eintrags",
|
||||||
|
"feedUrl": "URL des Feed-Eintrags",
|
||||||
|
"channelBracket": "Kanal-Erwähnung (Dashboard-Schreibweise, ID ersetzen)",
|
||||||
|
"channelNative": "Kanal-Erwähnung (Discord-Syntax, ID ersetzen)"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"overview": "Übersicht",
|
"overview": "Übersicht",
|
||||||
@@ -268,11 +295,45 @@
|
|||||||
"retentionLabel": "Aufbewahrung (Tage)",
|
"retentionLabel": "Aufbewahrung (Tage)",
|
||||||
"retentionHint": "Cases und Dashboard-Audit älter als X Tage löschen. 0 = unbegrenzt.",
|
"retentionHint": "Cases und Dashboard-Audit älter als X Tage löschen. 0 = unbegrenzt.",
|
||||||
"channelsTitle": "Event-Kanal-Zuordnung",
|
"channelsTitle": "Event-Kanal-Zuordnung",
|
||||||
"channelsDescription": "Lege für jeden Event-Typ einen eigenen Log-Kanal fest.",
|
"channelsDescription": "Wähle einen Log-Kanal und ordne ihm einen oder mehrere Event-Typen zu.",
|
||||||
"channelsEmpty": "Keine Event-Kanal-Zuordnungen konfiguriert.",
|
"channelsEmpty": "Keine Event-Kanal-Zuordnungen konfiguriert.",
|
||||||
|
"channelsHint": "Jeder Event-Typ kann nur einem Kanal zugeordnet werden. Eine neue Auswahl entfernt ihn automatisch von anderen Zeilen.",
|
||||||
"eventType": "Event-Typ",
|
"eventType": "Event-Typ",
|
||||||
"channelId": "Kanal-ID",
|
"eventTypesLabel": "Event-Typen",
|
||||||
"addMapping": "Zuordnung hinzufügen"
|
"eventTypesPlaceholder": "Event-Typen suchen…",
|
||||||
|
"channelId": "Log-Kanal",
|
||||||
|
"addMapping": "Zuordnung hinzufügen",
|
||||||
|
"errors": {
|
||||||
|
"duplicateEventType": "Ein Event-Typ ist mehrfach zugeordnet. Bitte prüfe die Auswahl."
|
||||||
|
},
|
||||||
|
"eventTypes": {
|
||||||
|
"MESSAGE_EDIT": "Nachricht bearbeitet",
|
||||||
|
"MESSAGE_DELETE": "Nachricht gelöscht",
|
||||||
|
"MESSAGE_BULK_DELETE": "Nachrichten massenhaft gelöscht",
|
||||||
|
"MEMBER_JOIN": "Mitglied beigetreten",
|
||||||
|
"MEMBER_LEAVE": "Mitglied verlassen",
|
||||||
|
"MEMBER_BAN": "Mitglied gebannt",
|
||||||
|
"MEMBER_UNBAN": "Mitglied entbannt",
|
||||||
|
"MEMBER_UPDATE": "Mitglied aktualisiert",
|
||||||
|
"CHANNEL_CREATE": "Kanal erstellt",
|
||||||
|
"CHANNEL_DELETE": "Kanal gelöscht",
|
||||||
|
"CHANNEL_UPDATE": "Kanal aktualisiert",
|
||||||
|
"VOICE_JOIN": "Voice beigetreten",
|
||||||
|
"VOICE_LEAVE": "Voice verlassen",
|
||||||
|
"VOICE_MOVE": "Voice verschoben",
|
||||||
|
"INVITE_CREATE": "Invite erstellt",
|
||||||
|
"EMOJI_CREATE": "Emoji erstellt",
|
||||||
|
"EMOJI_UPDATE": "Emoji aktualisiert",
|
||||||
|
"EMOJI_DELETE": "Emoji gelöscht",
|
||||||
|
"STICKER_CREATE": "Sticker erstellt",
|
||||||
|
"STICKER_UPDATE": "Sticker aktualisiert",
|
||||||
|
"STICKER_DELETE": "Sticker gelöscht",
|
||||||
|
"THREAD_CREATE": "Thread erstellt",
|
||||||
|
"THREAD_UPDATE": "Thread aktualisiert",
|
||||||
|
"THREAD_DELETE": "Thread gelöscht",
|
||||||
|
"GUILD_UPDATE": "Server aktualisiert",
|
||||||
|
"MOD_ACTION": "Moderations-Aktion"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"welcome": {
|
"welcome": {
|
||||||
"welcomeTitle": "Willkommen",
|
"welcomeTitle": "Willkommen",
|
||||||
@@ -287,7 +348,7 @@
|
|||||||
},
|
},
|
||||||
"welcomeContent": "Nachrichtentext",
|
"welcomeContent": "Nachrichtentext",
|
||||||
"contentPlaceholder": "Willkommen {user} auf {server}!",
|
"contentPlaceholder": "Willkommen {user} auf {server}!",
|
||||||
"placeholdersHint": "Verfügbare Platzhalter: {user}, {user.name}, {user.tag}, {user.id}, {server}, {memberCount}",
|
"placeholdersHint": "Verfügbare Platzhalter: siehe Liste unten.",
|
||||||
"welcomeEmbed": "Embed",
|
"welcomeEmbed": "Embed",
|
||||||
"welcomeDmEnabled": "Willkommens-DM aktiviert",
|
"welcomeDmEnabled": "Willkommens-DM aktiviert",
|
||||||
"dmContentPlaceholder": "Optionale private Nachricht an neue Mitglieder",
|
"dmContentPlaceholder": "Optionale private Nachricht an neue Mitglieder",
|
||||||
|
|||||||
@@ -35,6 +35,33 @@
|
|||||||
"clear": "Clear embed",
|
"clear": "Clear embed",
|
||||||
"clearHint": "Empty fields do not save an embed."
|
"clearHint": "Empty fields do not save an embed."
|
||||||
},
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"toggle": "Show available placeholders",
|
||||||
|
"hint": "Tap copy to insert. Only the placeholders listed here work in this field.",
|
||||||
|
"copy": "Copy placeholder",
|
||||||
|
"copied": "Placeholder copied",
|
||||||
|
"copyFailed": "Could not copy",
|
||||||
|
"desc": {
|
||||||
|
"userMention": "Mention of the member",
|
||||||
|
"userName": "Member display name",
|
||||||
|
"userTag": "Member username/tag",
|
||||||
|
"userId": "Member Discord ID",
|
||||||
|
"userDisplayName": "Display name (no mention)",
|
||||||
|
"userTagPlain": "Username without mention",
|
||||||
|
"username": "Member display name",
|
||||||
|
"server": "Server name",
|
||||||
|
"memberCount": "Current member count",
|
||||||
|
"args": "Arguments after the tag command",
|
||||||
|
"random": "Random choice from options (separated by |)",
|
||||||
|
"age": "Age in years (only if birth year is set)",
|
||||||
|
"level": "Reached level",
|
||||||
|
"count": "Current stats value",
|
||||||
|
"feedTitle": "Feed item title",
|
||||||
|
"feedUrl": "Feed item URL",
|
||||||
|
"channelBracket": "Channel mention (dashboard syntax — replace the ID)",
|
||||||
|
"channelNative": "Channel mention (Discord syntax — replace the ID)"
|
||||||
|
}
|
||||||
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"overview": "Overview",
|
"overview": "Overview",
|
||||||
@@ -268,11 +295,45 @@
|
|||||||
"retentionLabel": "Retention (days)",
|
"retentionLabel": "Retention (days)",
|
||||||
"retentionHint": "Delete cases and dashboard audit older than X days. 0 = keep forever.",
|
"retentionHint": "Delete cases and dashboard audit older than X days. 0 = keep forever.",
|
||||||
"channelsTitle": "Event-channel mapping",
|
"channelsTitle": "Event-channel mapping",
|
||||||
"channelsDescription": "Assign a dedicated log channel for each event type.",
|
"channelsDescription": "Pick a log channel and assign one or more event types to it.",
|
||||||
"channelsEmpty": "No event-channel mappings configured.",
|
"channelsEmpty": "No event-channel mappings configured.",
|
||||||
|
"channelsHint": "Each event type can only map to one channel. Selecting it here removes it from other rows.",
|
||||||
"eventType": "Event type",
|
"eventType": "Event type",
|
||||||
"channelId": "Channel ID",
|
"eventTypesLabel": "Event types",
|
||||||
"addMapping": "Add mapping"
|
"eventTypesPlaceholder": "Search event types…",
|
||||||
|
"channelId": "Log channel",
|
||||||
|
"addMapping": "Add mapping",
|
||||||
|
"errors": {
|
||||||
|
"duplicateEventType": "An event type is assigned more than once. Please check your selection."
|
||||||
|
},
|
||||||
|
"eventTypes": {
|
||||||
|
"MESSAGE_EDIT": "Message edited",
|
||||||
|
"MESSAGE_DELETE": "Message deleted",
|
||||||
|
"MESSAGE_BULK_DELETE": "Messages bulk deleted",
|
||||||
|
"MEMBER_JOIN": "Member joined",
|
||||||
|
"MEMBER_LEAVE": "Member left",
|
||||||
|
"MEMBER_BAN": "Member banned",
|
||||||
|
"MEMBER_UNBAN": "Member unbanned",
|
||||||
|
"MEMBER_UPDATE": "Member updated",
|
||||||
|
"CHANNEL_CREATE": "Channel created",
|
||||||
|
"CHANNEL_DELETE": "Channel deleted",
|
||||||
|
"CHANNEL_UPDATE": "Channel updated",
|
||||||
|
"VOICE_JOIN": "Voice joined",
|
||||||
|
"VOICE_LEAVE": "Voice left",
|
||||||
|
"VOICE_MOVE": "Voice moved",
|
||||||
|
"INVITE_CREATE": "Invite created",
|
||||||
|
"EMOJI_CREATE": "Emoji created",
|
||||||
|
"EMOJI_UPDATE": "Emoji updated",
|
||||||
|
"EMOJI_DELETE": "Emoji deleted",
|
||||||
|
"STICKER_CREATE": "Sticker created",
|
||||||
|
"STICKER_UPDATE": "Sticker updated",
|
||||||
|
"STICKER_DELETE": "Sticker deleted",
|
||||||
|
"THREAD_CREATE": "Thread created",
|
||||||
|
"THREAD_UPDATE": "Thread updated",
|
||||||
|
"THREAD_DELETE": "Thread deleted",
|
||||||
|
"GUILD_UPDATE": "Server updated",
|
||||||
|
"MOD_ACTION": "Moderation action"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"welcome": {
|
"welcome": {
|
||||||
"welcomeTitle": "Welcome",
|
"welcomeTitle": "Welcome",
|
||||||
@@ -287,7 +348,7 @@
|
|||||||
},
|
},
|
||||||
"welcomeContent": "Message content",
|
"welcomeContent": "Message content",
|
||||||
"contentPlaceholder": "Welcome {user} to {server}!",
|
"contentPlaceholder": "Welcome {user} to {server}!",
|
||||||
"placeholdersHint": "Available placeholders: {user}, {user.name}, {user.tag}, {user.id}, {server}, {memberCount}",
|
"placeholdersHint": "Available placeholders: see the list below.",
|
||||||
"welcomeEmbed": "Embed",
|
"welcomeEmbed": "Embed",
|
||||||
"welcomeDmEnabled": "Welcome DM enabled",
|
"welcomeDmEnabled": "Welcome DM enabled",
|
||||||
"dmContentPlaceholder": "Optional private message to new members",
|
"dmContentPlaceholder": "Optional private message to new members",
|
||||||
|
|||||||
@@ -141,6 +141,33 @@
|
|||||||
- [ ] Tag mit Antworttyp Embed erstellen/bearbeiten
|
- [ ] Tag mit Antworttyp Embed erstellen/bearbeiten
|
||||||
- [ ] Geplante Nachricht mit Embed (optional mit Text) anlegen
|
- [ ] Geplante Nachricht mit Embed (optional mit Text) anlegen
|
||||||
|
|
||||||
|
## Post-Phase – Logging Multi-Event pro Kanal (Status: implementiert)
|
||||||
|
|
||||||
|
### Abgeschlossen (Code)
|
||||||
|
|
||||||
|
- Logging-UI: pro Log-Kanal mehrere Event-Typen per Multi-Select
|
||||||
|
- Bestehende 1:1-Mappings werden beim Laden nach Kanal gruppiert; Speichern expandiert wieder auf `LogChannel`-Zeilen
|
||||||
|
- Event-Typen sind exklusiv (DB `@@unique([guildId, eventType])`) – Auswahl in einer Zeile entfernt sie aus anderen
|
||||||
|
|
||||||
|
### Manuell testen
|
||||||
|
|
||||||
|
- [ ] Mehrere Events einem Kanal zuweisen und speichern
|
||||||
|
- [ ] Event von Kanal A nach Kanal B umhängen (verschwindet bei A)
|
||||||
|
- [ ] Bot schreibt Logs der gewählten Events in den richtigen Kanal
|
||||||
|
|
||||||
|
## Post-Phase – Platzhalter-Hilfe (Status: implementiert)
|
||||||
|
|
||||||
|
### Abgeschlossen (Code)
|
||||||
|
|
||||||
|
- Aufklappbare `PlaceholderHelp`-Komponente mit Kopieren pro Token
|
||||||
|
- Kontextspezifische Presets (Welcome, Tags, Birthdays, Leveling, TempVoice, Stats, Feeds, Scheduler)
|
||||||
|
- Welcome-DM nutzt jetzt dieselben Platzhalter wie Welcome-Text (nicht nur `{user}`)
|
||||||
|
|
||||||
|
### Manuell testen
|
||||||
|
|
||||||
|
- [ ] Welcome: Liste aufklappen, Platzhalter kopieren, in Embed/Text einfügen
|
||||||
|
- [ ] Tags/Birthdays/Leveling/Stats/Feeds/Scheduler: nur die jeweiligen Tokens sichtbar
|
||||||
|
|
||||||
## Nächster geplanter Schritt
|
## Nächster geplanter Schritt
|
||||||
|
|
||||||
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe).
|
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe).
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ describe('phase2 helpers', () => {
|
|||||||
expect(result).toBe('Hello <@1> on Nexumi');
|
expect(result).toBe('Hello <@1> on Nexumi');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('expands bracket channel mentions', () => {
|
||||||
|
const result = applyWelcomePlaceholders('Join ["123456789012345678"] please', {});
|
||||||
|
expect(result).toBe('Join <#123456789012345678> please');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps native discord channel mentions', () => {
|
||||||
|
const result = applyWelcomePlaceholders('Join <#123456789012345678> please', {});
|
||||||
|
expect(result).toBe('Join <#123456789012345678> please');
|
||||||
|
});
|
||||||
|
|
||||||
it('detects discord invites', () => {
|
it('detects discord invites', () => {
|
||||||
expect(isDiscordInvite('join https://discord.gg/test')).toBe(true);
|
expect(isDiscordInvite('join https://discord.gg/test')).toBe(true);
|
||||||
expect(isDiscordInvite('hello world')).toBe(false);
|
expect(isDiscordInvite('hello world')).toBe(false);
|
||||||
|
|||||||
@@ -162,14 +162,23 @@ export const WELCOME_PLACEHOLDERS = [
|
|||||||
'{memberCount}'
|
'{memberCount}'
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expands dashboard-friendly channel mentions `["123…"]` into Discord's
|
||||||
|
* native `<#123…>` mention format. Raw `<#id>` mentions are left untouched.
|
||||||
|
*/
|
||||||
|
export function expandBracketChannelMentions(template: string): string {
|
||||||
|
return template.replace(/\["(\d{17,20})"\]/g, '<#$1>');
|
||||||
|
}
|
||||||
|
|
||||||
export function applyWelcomePlaceholders(
|
export function applyWelcomePlaceholders(
|
||||||
template: string,
|
template: string,
|
||||||
vars: Record<string, string | number>
|
vars: Record<string, string | number>
|
||||||
): string {
|
): string {
|
||||||
return Object.entries(vars).reduce(
|
const withVars = Object.entries(vars).reduce(
|
||||||
(acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)),
|
(acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)),
|
||||||
template
|
template
|
||||||
);
|
);
|
||||||
|
return expandBracketChannelMentions(withVars);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function countEmojis(content: string): number {
|
export function countEmojis(content: string): number {
|
||||||
|
|||||||
@@ -38,14 +38,17 @@ export const SuggestionStatusSchema = z.enum([
|
|||||||
]);
|
]);
|
||||||
export type SuggestionStatus = z.infer<typeof SuggestionStatusSchema>;
|
export type SuggestionStatus = z.infer<typeof SuggestionStatusSchema>;
|
||||||
|
|
||||||
|
import { expandBracketChannelMentions } from './phase2.js';
|
||||||
|
|
||||||
export function applyTagPlaceholders(
|
export function applyTagPlaceholders(
|
||||||
template: string,
|
template: string,
|
||||||
vars: Record<string, string>
|
vars: Record<string, string>
|
||||||
): string {
|
): string {
|
||||||
return Object.entries(vars).reduce(
|
const withVars = Object.entries(vars).reduce(
|
||||||
(acc, [key, value]) => acc.replaceAll(`{${key}}`, value),
|
(acc, [key, value]) => acc.replaceAll(`{${key}}`, value),
|
||||||
template
|
template
|
||||||
);
|
);
|
||||||
|
return expandBracketChannelMentions(withVars);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pickRandomWinners<T>(items: T[], count: number): T[] {
|
export function pickRandomWinners<T>(items: T[], count: number): T[] {
|
||||||
|
|||||||
Reference in New Issue
Block a user