Refactor channel and role selection in various forms to use Discord components

- Replaced standard input fields with DiscordChannelSelect and DiscordRoleSelect components in the BirthdaysForm, FeedsManager, GiveawaysManager, LevelingForm, LoggingForm, SchedulerManager, SelfRolesManager, StatsForm, SuggestionsConfigForm, TagsManager, TempVoiceForm, TicketConfigForm, and StarboardForm for improved user experience and consistency.
- Updated state management to accommodate new component structures, enhancing data handling for channel and role selections across the application.
This commit is contained in:
TheOnlyMace
2026-07-22 21:23:57 +02:00
parent cf6d071047
commit ed2ea23e87
27 changed files with 749 additions and 322 deletions

View File

@@ -1,6 +1,6 @@
import { Suspense } from 'react'; import { Suspense } from 'react';
import { CommandsManager } from '@/components/modules/commands-manager'; import { CommandsManager } from '@/components/modules/commands-manager';
import { listGuildChannelsForDashboard, listGuildRolesForDashboard } from '@/lib/discord-guild-resources'; import { listGuildChannelsForDashboard } from '@/lib/discord-guild-resources';
import { getLocale, t } from '@/lib/i18n'; import { getLocale, t } from '@/lib/i18n';
import { getCommandGlobalRules } from '@/lib/module-configs/command-global-rules'; import { getCommandGlobalRules } from '@/lib/module-configs/command-global-rules';
import { listCommandOverridesDashboard } from '@/lib/module-configs/commands'; import { listCommandOverridesDashboard } from '@/lib/module-configs/commands';
@@ -11,12 +11,11 @@ interface CommandsPageProps {
export default async function CommandsPage({ params }: CommandsPageProps) { export default async function CommandsPage({ params }: CommandsPageProps) {
const { guildId } = await params; const { guildId } = await params;
const [locale, commands, globalRules, channels, roles] = await Promise.all([ const [locale, commands, globalRules, channels] = await Promise.all([
getLocale(), getLocale(),
listCommandOverridesDashboard(guildId), listCommandOverridesDashboard(guildId),
getCommandGlobalRules(guildId), getCommandGlobalRules(guildId),
listGuildChannelsForDashboard(guildId).catch(() => []), listGuildChannelsForDashboard(guildId).catch(() => [])
listGuildRolesForDashboard(guildId).catch(() => [])
]); ]);
return ( return (
@@ -31,7 +30,6 @@ export default async function CommandsPage({ params }: CommandsPageProps) {
initialCommands={commands} initialCommands={commands}
initialGlobalRules={globalRules} initialGlobalRules={globalRules}
channels={channels} channels={channels}
roles={roles}
/> />
</Suspense> </Suspense>
</div> </div>

View File

@@ -5,6 +5,8 @@ import { useId } 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DiscordChannelSelect } from '@/components/ui/discord-channel-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 { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
@@ -36,8 +38,6 @@ interface BirthdaysFormProps {
export function BirthdaysForm({ guildId, initialValue }: BirthdaysFormProps) { export function BirthdaysForm({ guildId, initialValue }: BirthdaysFormProps) {
const t = useTranslations(); const t = useTranslations();
const channelId = useId();
const roleId = useId();
const timezoneId = useId(); const timezoneId = useId();
return ( return (
@@ -58,14 +58,22 @@ export function BirthdaysForm({ guildId, initialValue }: BirthdaysFormProps) {
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<FieldAnchor id="field-birthdays-channel"> <FieldAnchor id="field-birthdays-channel">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={channelId}>{t('modulePages.birthdays.channelId')}</Label> <Label>{t('modulePages.birthdays.channelId')}</Label>
<Input id={channelId} value={value.channelId} onChange={(event) => setValue((prev) => ({ ...prev, channelId: event.target.value.trim() }))} placeholder="123456789012345678" /> <DiscordChannelSelect
guildId={guildId}
value={value.channelId}
onChange={(channelId) => setValue((prev) => ({ ...prev, channelId }))}
/>
</div> </div>
</FieldAnchor> </FieldAnchor>
<FieldAnchor id="field-birthdays-role"> <FieldAnchor id="field-birthdays-role">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={roleId}>{t('modulePages.birthdays.roleId')}</Label> <Label>{t('modulePages.birthdays.roleId')}</Label>
<Input id={roleId} value={value.roleId} onChange={(event) => setValue((prev) => ({ ...prev, roleId: event.target.value.trim() }))} placeholder="123456789012345678" /> <DiscordRoleSelect
guildId={guildId}
value={value.roleId}
onChange={(roleId) => setValue((prev) => ({ ...prev, roleId }))}
/>
</div> </div>
</FieldAnchor> </FieldAnchor>
<FieldAnchor id="field-birthdays-timezone"> <FieldAnchor id="field-birthdays-timezone">

View File

@@ -3,8 +3,7 @@
import type { import type {
CommandGlobalRules, CommandGlobalRules,
CommandOverrideDashboard, CommandOverrideDashboard,
DiscordChannelOption, DiscordChannelOption
DiscordRoleOption
} from '@nexumi/shared'; } from '@nexumi/shared';
import { RotateCcw } from 'lucide-react'; import { RotateCcw } from 'lucide-react';
import { useSearchParams } from 'next/navigation'; import { useSearchParams } from 'next/navigation';
@@ -14,6 +13,7 @@ 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 { DiscordResourceMultiSelect } from '@/components/ui/discord-resource-multi-select'; import { DiscordResourceMultiSelect } from '@/components/ui/discord-resource-multi-select';
import { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
@@ -26,15 +26,13 @@ interface CommandsManagerProps {
initialCommands: CommandOverrideDashboard[]; initialCommands: CommandOverrideDashboard[];
initialGlobalRules: CommandGlobalRules; initialGlobalRules: CommandGlobalRules;
channels: DiscordChannelOption[]; channels: DiscordChannelOption[];
roles: DiscordRoleOption[];
} }
export function CommandsManager({ export function CommandsManager({
guildId, guildId,
initialCommands, initialCommands,
initialGlobalRules, initialGlobalRules,
channels, channels
roles
}: CommandsManagerProps) { }: CommandsManagerProps) {
const t = useTranslations(); const t = useTranslations();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -48,10 +46,6 @@ export function CommandsManager({
() => channels.map((channel) => ({ id: channel.id, name: channel.name, prefix: '#' })), () => channels.map((channel) => ({ id: channel.id, name: channel.name, prefix: '#' })),
[channels] [channels]
); );
const roleOptions = useMemo(
() => roles.map((role) => ({ id: role.id, name: role.name, prefix: '@' })),
[roles]
);
useEffect(() => { useEffect(() => {
const q = searchParams.get('q'); const q = searchParams.get('q');
@@ -250,8 +244,8 @@ export function CommandsManager({
</div> </div>
<div className="space-y-1 sm:col-span-2"> <div className="space-y-1 sm:col-span-2">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.allowedRoleIds')}</p> <p className="text-xs text-muted-foreground">{t('modulePages.commands.allowedRoleIds')}</p>
<DiscordResourceMultiSelect <DiscordRoleMultiSelect
options={roleOptions} guildId={guildId}
value={entry.allowedRoleIds} value={entry.allowedRoleIds}
onChange={(allowedRoleIds) => patchLocal(entry.commandName, { allowedRoleIds })} onChange={(allowedRoleIds) => patchLocal(entry.commandName, { allowedRoleIds })}
placeholder={t('modulePages.commands.roleSearchPlaceholder')} placeholder={t('modulePages.commands.roleSearchPlaceholder')}
@@ -260,8 +254,8 @@ export function CommandsManager({
</div> </div>
<div className="space-y-1 sm:col-span-2"> <div className="space-y-1 sm:col-span-2">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.deniedRoleIds')}</p> <p className="text-xs text-muted-foreground">{t('modulePages.commands.deniedRoleIds')}</p>
<DiscordResourceMultiSelect <DiscordRoleMultiSelect
options={roleOptions} guildId={guildId}
value={entry.deniedRoleIds} value={entry.deniedRoleIds}
onChange={(deniedRoleIds) => patchLocal(entry.commandName, { deniedRoleIds })} onChange={(deniedRoleIds) => patchLocal(entry.commandName, { deniedRoleIds })}
placeholder={t('modulePages.commands.roleSearchPlaceholder')} placeholder={t('modulePages.commands.roleSearchPlaceholder')}

View File

@@ -8,6 +8,8 @@ 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 { DiscordChannelSelect } from '@/components/ui/discord-channel-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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -182,17 +184,18 @@ export function FeedsManager({ guildId, initialFeeds }: FeedsManagerProps) {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('modulePages.feeds.channelId')}</Label> <Label>{t('modulePages.feeds.channelId')}</Label>
<Input <DiscordChannelSelect
guildId={guildId}
value={draft.channelId} value={draft.channelId}
onChange={(event) => setDraft((prev) => ({ ...prev, channelId: event.target.value }))} onChange={(channelId) => setDraft((prev) => ({ ...prev, channelId }))}
placeholder="123456789012345678"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('modulePages.feeds.rolePingId')}</Label> <Label>{t('modulePages.feeds.rolePingId')}</Label>
<Input <DiscordRoleSelect
guildId={guildId}
value={draft.rolePingId} value={draft.rolePingId}
onChange={(event) => setDraft((prev) => ({ ...prev, rolePingId: event.target.value }))} onChange={(rolePingId) => setDraft((prev) => ({ ...prev, rolePingId }))}
placeholder={t('common.optional')} placeholder={t('common.optional')}
/> />
</div> </div>

View File

@@ -9,6 +9,8 @@ import { useTranslations } from '@/components/locale-provider';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
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 { DiscordChannelSelect } from '@/components/ui/discord-channel-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';
@@ -127,10 +129,10 @@ export function GiveawaysManager({ guildId, initialGiveaways }: GiveawaysManager
<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.giveaways.channelId')}</Label> <Label>{t('modulePages.giveaways.channelId')}</Label>
<Input <DiscordChannelSelect
guildId={guildId}
value={draft.channelId} value={draft.channelId}
onChange={(event) => setDraft((prev) => ({ ...prev, channelId: event.target.value }))} onChange={(channelId) => setDraft((prev) => ({ ...prev, channelId }))}
placeholder="123456789012345678"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -156,9 +158,10 @@ export function GiveawaysManager({ guildId, initialGiveaways }: GiveawaysManager
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('modulePages.giveaways.requiredRoleId')}</Label> <Label>{t('modulePages.giveaways.requiredRoleId')}</Label>
<Input <DiscordRoleSelect
guildId={guildId}
value={draft.requiredRoleId} value={draft.requiredRoleId}
onChange={(event) => setDraft((prev) => ({ ...prev, requiredRoleId: event.target.value }))} onChange={(requiredRoleId) => setDraft((prev) => ({ ...prev, requiredRoleId }))}
placeholder={t('common.optional')} placeholder={t('common.optional')}
/> />
</div> </div>

View File

@@ -1,10 +1,11 @@
'use client'; 'use client';
import type { LevelingConfigDashboard } from '@nexumi/shared'; import type { LevelingConfigDashboard } from '@nexumi/shared';
import { useId } 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 { 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 { 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -14,24 +15,11 @@ import type { SettingsSaveResult } from '@/components/settings/settings-form';
import { SettingsForm } from '@/components/settings/settings-form'; import { SettingsForm } from '@/components/settings/settings-form';
interface LocalLevelingValue interface LocalLevelingValue
extends Omit<LevelingConfigDashboard, 'noXpChannelIds' | 'noXpRoleIds' | 'roleMultipliers' | 'channelMultipliers'> { extends Omit<LevelingConfigDashboard, 'roleMultipliers' | 'channelMultipliers'> {
noXpChannelIdsText: string;
noXpRoleIdsText: string;
roleMultipliersText: string; roleMultipliersText: string;
channelMultipliersText: string; channelMultipliersText: string;
} }
function toCsv(ids: string[]): string {
return ids.join(', ');
}
function fromCsv(text: string): string[] {
return text
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
function toMultiplierText(map: Record<string, number>): string { function toMultiplierText(map: Record<string, number>): string {
return Object.entries(map) return Object.entries(map)
.map(([id, multiplier]) => `${id}:${multiplier}`) .map(([id, multiplier]) => `${id}:${multiplier}`)
@@ -58,11 +46,9 @@ function fromMultiplierText(text: string): { value: Record<string, number>; erro
} }
function toLocal(config: LevelingConfigDashboard): LocalLevelingValue { function toLocal(config: LevelingConfigDashboard): LocalLevelingValue {
const { noXpChannelIds, noXpRoleIds, roleMultipliers, channelMultipliers, ...rest } = config; const { roleMultipliers, channelMultipliers, ...rest } = config;
return { return {
...rest, ...rest,
noXpChannelIdsText: toCsv(noXpChannelIds),
noXpRoleIdsText: toCsv(noXpRoleIds),
roleMultipliersText: toMultiplierText(roleMultipliers), roleMultipliersText: toMultiplierText(roleMultipliers),
channelMultipliersText: toMultiplierText(channelMultipliers) channelMultipliersText: toMultiplierText(channelMultipliers)
}; };
@@ -78,12 +64,10 @@ async function saveLeveling(guildId: string, value: LocalLevelingValue): Promise
return { ok: false, error: channelMultipliers.error }; return { ok: false, error: channelMultipliers.error };
} }
const { noXpChannelIdsText, noXpRoleIdsText, roleMultipliersText: _roleMultipliersText, channelMultipliersText: _channelMultipliersText, ...rest } = value; const { roleMultipliersText: _roleMultipliersText, channelMultipliersText: _channelMultipliersText, ...rest } = value;
const payload: LevelingConfigDashboard = { const payload: LevelingConfigDashboard = {
...rest, ...rest,
noXpChannelIds: fromCsv(noXpChannelIdsText),
noXpRoleIds: fromCsv(noXpRoleIdsText),
roleMultipliers: roleMultipliers.value, roleMultipliers: roleMultipliers.value,
channelMultipliers: channelMultipliers.value channelMultipliers: channelMultipliers.value
}; };
@@ -111,8 +95,6 @@ interface LevelingFormProps {
export function LevelingForm({ guildId, initialValue }: LevelingFormProps) { export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
const t = useTranslations(); const t = useTranslations();
const noXpChannelsId = useId();
const noXpRolesId = useId();
return ( return (
<SettingsForm<LocalLevelingValue> <SettingsForm<LocalLevelingValue>
@@ -192,23 +174,21 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
<FieldAnchor id="field-leveling-no-xp-channels"> <FieldAnchor id="field-leveling-no-xp-channels">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={noXpChannelsId}>{t('modulePages.leveling.noXpChannels')}</Label> <Label>{t('modulePages.leveling.noXpChannels')}</Label>
<Textarea <DiscordChannelMultiSelect
id={noXpChannelsId} guildId={guildId}
value={value.noXpChannelIdsText} value={value.noXpChannelIds}
onChange={(event) => setValue((prev) => ({ ...prev, noXpChannelIdsText: event.target.value }))} onChange={(noXpChannelIds) => setValue((prev) => ({ ...prev, noXpChannelIds }))}
placeholder={t('modulePages.logging.idsPlaceholder')}
/> />
</div> </div>
</FieldAnchor> </FieldAnchor>
<FieldAnchor id="field-leveling-no-xp-roles"> <FieldAnchor id="field-leveling-no-xp-roles">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={noXpRolesId}>{t('modulePages.leveling.noXpRoles')}</Label> <Label>{t('modulePages.leveling.noXpRoles')}</Label>
<Textarea <DiscordRoleMultiSelect
id={noXpRolesId} guildId={guildId}
value={value.noXpRoleIdsText} value={value.noXpRoleIds}
onChange={(event) => setValue((prev) => ({ ...prev, noXpRoleIdsText: event.target.value }))} onChange={(noXpRoleIds) => setValue((prev) => ({ ...prev, noXpRoleIds }))}
placeholder={t('modulePages.logging.idsPlaceholder')}
/> />
</div> </div>
</FieldAnchor> </FieldAnchor>
@@ -271,10 +251,10 @@ export function LevelingForm({ guildId, initialValue }: LevelingFormProps) {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('modulePages.leveling.levelUpChannelId')}</Label> <Label>{t('modulePages.leveling.levelUpChannelId')}</Label>
<Input <DiscordChannelSelect
guildId={guildId}
value={value.levelUpChannelId} value={value.levelUpChannelId}
onChange={(event) => setValue((prev) => ({ ...prev, levelUpChannelId: event.target.value.trim() }))} onChange={(levelUpChannelId) => setValue((prev) => ({ ...prev, levelUpChannelId }))}
placeholder="123456789012345678"
/> />
</div> </div>
</div> </div>

View File

@@ -2,16 +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 { useId } 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 { 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 { 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 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';
@@ -51,29 +51,18 @@ interface LocalLogChannel extends LogChannelMapping {
interface LocalLoggingValue { interface LocalLoggingValue {
enabled: boolean; enabled: boolean;
ignoreBots: boolean; ignoreBots: boolean;
ignoredChannelIdsText: string; ignoredChannelIds: string[];
ignoredRoleIdsText: string; ignoredRoleIds: string[];
retentionDays: number; retentionDays: number;
logChannels: LocalLogChannel[]; logChannels: LocalLogChannel[];
} }
function toCsv(ids: string[]): string {
return ids.join(', ');
}
function fromCsv(text: string): string[] {
return text
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
function toLocal(config: LoggingConfigDashboard): LocalLoggingValue { function toLocal(config: LoggingConfigDashboard): LocalLoggingValue {
return { return {
enabled: config.enabled, enabled: config.enabled,
ignoreBots: config.ignoreBots, ignoreBots: config.ignoreBots,
ignoredChannelIdsText: toCsv(config.ignoredChannelIds), ignoredChannelIds: config.ignoredChannelIds,
ignoredRoleIdsText: toCsv(config.ignoredRoleIds), ignoredRoleIds: config.ignoredRoleIds,
retentionDays: config.retentionDays, retentionDays: config.retentionDays,
logChannels: config.logChannels.map((mapping, index) => ({ ...mapping, clientKey: `${mapping.eventType}-${index}` })) logChannels: config.logChannels.map((mapping, index) => ({ ...mapping, clientKey: `${mapping.eventType}-${index}` }))
}; };
@@ -83,8 +72,8 @@ function toRemote(value: LocalLoggingValue): LoggingConfigDashboard {
return { return {
enabled: value.enabled, enabled: value.enabled,
ignoreBots: value.ignoreBots, ignoreBots: value.ignoreBots,
ignoredChannelIds: fromCsv(value.ignoredChannelIdsText), ignoredChannelIds: value.ignoredChannelIds,
ignoredRoleIds: fromCsv(value.ignoredRoleIdsText), ignoredRoleIds: value.ignoredRoleIds,
retentionDays: value.retentionDays, retentionDays: value.retentionDays,
logChannels: value.logChannels.map(({ clientKey: _clientKey, ...rest }) => rest) logChannels: value.logChannels.map(({ clientKey: _clientKey, ...rest }) => rest)
}; };
@@ -114,8 +103,6 @@ interface LoggingFormProps {
export function LoggingForm({ guildId, initialValue }: LoggingFormProps) { export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
const t = useTranslations(); const t = useTranslations();
const ignoredChannelsId = useId();
const ignoredRolesId = useId();
return ( return (
<SettingsForm<LocalLoggingValue> <SettingsForm<LocalLoggingValue>
@@ -150,26 +137,22 @@ export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
</FieldAnchor> </FieldAnchor>
<FieldAnchor id="field-logging-ignored-channels"> <FieldAnchor id="field-logging-ignored-channels">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={ignoredChannelsId}>{t('modulePages.logging.ignoredChannelsLabel')}</Label> <Label>{t('modulePages.logging.ignoredChannelsLabel')}</Label>
<Textarea <DiscordChannelMultiSelect
id={ignoredChannelsId} guildId={guildId}
value={value.ignoredChannelIdsText} value={value.ignoredChannelIds}
onChange={(event) => setValue((prev) => ({ ...prev, ignoredChannelIdsText: event.target.value }))} onChange={(ignoredChannelIds) => setValue((prev) => ({ ...prev, ignoredChannelIds }))}
placeholder={t('modulePages.logging.idsPlaceholder')}
/> />
<p className="text-xs text-muted-foreground">{t('modulePages.logging.idsHint')}</p>
</div> </div>
</FieldAnchor> </FieldAnchor>
<FieldAnchor id="field-logging-ignored-roles"> <FieldAnchor id="field-logging-ignored-roles">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={ignoredRolesId}>{t('modulePages.logging.ignoredRolesLabel')}</Label> <Label>{t('modulePages.logging.ignoredRolesLabel')}</Label>
<Textarea <DiscordRoleMultiSelect
id={ignoredRolesId} guildId={guildId}
value={value.ignoredRoleIdsText} value={value.ignoredRoleIds}
onChange={(event) => setValue((prev) => ({ ...prev, ignoredRoleIdsText: event.target.value }))} onChange={(ignoredRoleIds) => setValue((prev) => ({ ...prev, ignoredRoleIds }))}
placeholder={t('modulePages.logging.idsPlaceholder')}
/> />
<p className="text-xs text-muted-foreground">{t('modulePages.logging.idsHint')}</p>
</div> </div>
</FieldAnchor> </FieldAnchor>
<FieldAnchor id="field-logging-retention"> <FieldAnchor id="field-logging-retention">
@@ -233,17 +216,17 @@ export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
</div> </div>
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<Label>{t('modulePages.logging.channelId')}</Label> <Label>{t('modulePages.logging.channelId')}</Label>
<Input <DiscordChannelSelect
guildId={guildId}
value={mapping.channelId} value={mapping.channelId}
onChange={(event) => onChange={(channelId) =>
setValue((prev) => ({ setValue((prev) => ({
...prev, ...prev,
logChannels: prev.logChannels.map((entry) => logChannels: prev.logChannels.map((entry) =>
entry.clientKey === mapping.clientKey ? { ...entry, channelId: event.target.value.trim() } : entry entry.clientKey === mapping.clientKey ? { ...entry, channelId } : entry
) )
})) }))
} }
placeholder="123456789012345678"
/> />
</div> </div>
<Button <Button

View File

@@ -8,6 +8,8 @@ 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 { DiscordChannelSelect } from '@/components/ui/discord-channel-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 { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
@@ -108,17 +110,18 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager
<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.scheduler.channelId')}</Label> <Label>{t('modulePages.scheduler.channelId')}</Label>
<Input <DiscordChannelSelect
guildId={guildId}
value={draft.channelId} value={draft.channelId}
onChange={(event) => setDraft((prev) => ({ ...prev, channelId: event.target.value }))} onChange={(channelId) => setDraft((prev) => ({ ...prev, channelId }))}
placeholder="123456789012345678"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('modulePages.scheduler.rolePingId')}</Label> <Label>{t('modulePages.scheduler.rolePingId')}</Label>
<Input <DiscordRoleSelect
guildId={guildId}
value={draft.rolePingId} value={draft.rolePingId}
onChange={(event) => setDraft((prev) => ({ ...prev, rolePingId: event.target.value }))} onChange={(rolePingId) => setDraft((prev) => ({ ...prev, rolePingId }))}
placeholder={t('common.optional')} placeholder={t('common.optional')}
/> />
</div> </div>

View File

@@ -8,6 +8,7 @@ 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 { 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -164,7 +165,11 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
<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.selfroles.channelId')}</Label> <Label>{t('modulePages.selfroles.channelId')}</Label>
<Input value={draft.channelId} onChange={(event) => setDraft((prev) => ({ ...prev, channelId: event.target.value }))} placeholder="123456789012345678" /> <DiscordChannelSelect
guildId={guildId}
value={draft.channelId}
onChange={(channelId) => setDraft((prev) => ({ ...prev, channelId }))}
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('modulePages.selfroles.panelTitle')}</Label> <Label>{t('modulePages.selfroles.panelTitle')}</Label>
@@ -237,10 +242,13 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
<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.selfroles.channelId')}</Label> <Label>{t('modulePages.selfroles.channelId')}</Label>
<Input <DiscordChannelSelect
guildId={guildId}
value={panel.channelId} value={panel.channelId}
onChange={(event) => onChange={(channelId) =>
setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, channelId: event.target.value } : entry))) setPanels((prev) =>
prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, channelId } : entry))
)
} }
/> />
</div> </div>

View File

@@ -5,53 +5,19 @@ import { useId } 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 { 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 { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
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';
interface LocalValue { async function saveStarboard(guildId: string, value: StarboardConfigDashboard): Promise<SettingsSaveResult> {
enabled: boolean;
channelId: string;
emoji: string;
threshold: number;
allowSelfStar: boolean;
ignoredChannelIdsText: string;
}
function toLocal(config: StarboardConfigDashboard): LocalValue {
return {
enabled: config.enabled,
channelId: config.channelId,
emoji: config.emoji,
threshold: config.threshold,
allowSelfStar: config.allowSelfStar,
ignoredChannelIdsText: config.ignoredChannelIds.join(', ')
};
}
function toRemote(value: LocalValue): StarboardConfigDashboard {
return {
enabled: value.enabled,
channelId: value.channelId,
emoji: value.emoji,
threshold: value.threshold,
allowSelfStar: value.allowSelfStar,
ignoredChannelIds: value.ignoredChannelIdsText
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
};
}
async function saveStarboard(guildId: string, value: LocalValue): Promise<SettingsSaveResult> {
try { try {
const response = await fetch(`/api/guilds/${guildId}/starboard`, { const response = await fetch(`/api/guilds/${guildId}/starboard`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(toRemote(value)) body: JSON.stringify(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;
@@ -70,13 +36,14 @@ interface StarboardFormProps {
export function StarboardForm({ guildId, initialValue }: StarboardFormProps) { export function StarboardForm({ guildId, initialValue }: StarboardFormProps) {
const t = useTranslations(); const t = useTranslations();
const channelId = useId();
const emojiId = useId(); const emojiId = useId();
const thresholdId = useId(); const thresholdId = useId();
const ignoredId = useId();
return ( return (
<SettingsForm<LocalValue> initialValue={toLocal(initialValue)} onSave={(value) => saveStarboard(guildId, value)}> <SettingsForm<StarboardConfigDashboard>
initialValue={initialValue}
onSave={(value) => saveStarboard(guildId, value)}
>
{({ value, setValue }) => ( {({ value, setValue }) => (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -93,8 +60,12 @@ export function StarboardForm({ guildId, initialValue }: StarboardFormProps) {
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<FieldAnchor id="field-starboard-channel"> <FieldAnchor id="field-starboard-channel">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={channelId}>{t('modulePages.starboard.channelId')}</Label> <Label>{t('modulePages.starboard.channelId')}</Label>
<Input id={channelId} value={value.channelId} onChange={(event) => setValue((prev) => ({ ...prev, channelId: event.target.value.trim() }))} placeholder="123456789012345678" /> <DiscordChannelSelect
guildId={guildId}
value={value.channelId}
onChange={(channelId) => setValue((prev) => ({ ...prev, channelId }))}
/>
</div> </div>
</FieldAnchor> </FieldAnchor>
<FieldAnchor id="field-starboard-emoji"> <FieldAnchor id="field-starboard-emoji">
@@ -118,9 +89,12 @@ export function StarboardForm({ guildId, initialValue }: StarboardFormProps) {
</FieldAnchor> </FieldAnchor>
<FieldAnchor id="field-starboard-ignored"> <FieldAnchor id="field-starboard-ignored">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={ignoredId}>{t('modulePages.starboard.ignoredChannelIds')}</Label> <Label>{t('modulePages.starboard.ignoredChannelIds')}</Label>
<Textarea id={ignoredId} value={value.ignoredChannelIdsText} onChange={(event) => setValue((prev) => ({ ...prev, ignoredChannelIdsText: event.target.value }))} /> <DiscordChannelMultiSelect
<p className="text-xs text-muted-foreground">{t('modulePages.starboard.idsHint')}</p> guildId={guildId}
value={value.ignoredChannelIds}
onChange={(ignoredChannelIds) => setValue((prev) => ({ ...prev, ignoredChannelIds }))}
/>
</div> </div>
</FieldAnchor> </FieldAnchor>
</CardContent> </CardContent>

View File

@@ -5,6 +5,7 @@ import { useId } 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
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 { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
@@ -35,9 +36,6 @@ interface StatsFormProps {
export function StatsForm({ guildId, initialValue }: StatsFormProps) { export function StatsForm({ guildId, initialValue }: StatsFormProps) {
const t = useTranslations(); const t = useTranslations();
const membersId = useId();
const onlineId = useId();
const boostsId = useId();
const membersTemplateId = useId(); const membersTemplateId = useId();
const onlineTemplateId = useId(); const onlineTemplateId = useId();
const boostsTemplateId = useId(); const boostsTemplateId = useId();
@@ -60,8 +58,13 @@ export function StatsForm({ guildId, initialValue }: StatsFormProps) {
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<FieldAnchor id="field-stats-members"> <FieldAnchor id="field-stats-members">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={membersId}>{t('modulePages.stats.membersChannelId')}</Label> <Label>{t('modulePages.stats.membersChannelId')}</Label>
<Input id={membersId} value={value.membersChannelId} onChange={(event) => setValue((prev) => ({ ...prev, membersChannelId: event.target.value.trim() }))} placeholder="123456789012345678" /> <DiscordChannelSelect
guildId={guildId}
kinds={['voice']}
value={value.membersChannelId}
onChange={(membersChannelId) => setValue((prev) => ({ ...prev, membersChannelId }))}
/>
</div> </div>
</FieldAnchor> </FieldAnchor>
<div className="space-y-2"> <div className="space-y-2">
@@ -70,8 +73,13 @@ export function StatsForm({ guildId, initialValue }: StatsFormProps) {
</div> </div>
<FieldAnchor id="field-stats-online"> <FieldAnchor id="field-stats-online">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={onlineId}>{t('modulePages.stats.onlineChannelId')}</Label> <Label>{t('modulePages.stats.onlineChannelId')}</Label>
<Input id={onlineId} value={value.onlineChannelId} onChange={(event) => setValue((prev) => ({ ...prev, onlineChannelId: event.target.value.trim() }))} placeholder="123456789012345678" /> <DiscordChannelSelect
guildId={guildId}
kinds={['voice']}
value={value.onlineChannelId}
onChange={(onlineChannelId) => setValue((prev) => ({ ...prev, onlineChannelId }))}
/>
</div> </div>
</FieldAnchor> </FieldAnchor>
<div className="space-y-2"> <div className="space-y-2">
@@ -80,8 +88,13 @@ export function StatsForm({ guildId, initialValue }: StatsFormProps) {
</div> </div>
<FieldAnchor id="field-stats-boosts"> <FieldAnchor id="field-stats-boosts">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={boostsId}>{t('modulePages.stats.boostsChannelId')}</Label> <Label>{t('modulePages.stats.boostsChannelId')}</Label>
<Input id={boostsId} value={value.boostsChannelId} onChange={(event) => setValue((prev) => ({ ...prev, boostsChannelId: event.target.value.trim() }))} placeholder="123456789012345678" /> <DiscordChannelSelect
guildId={guildId}
kinds={['voice']}
value={value.boostsChannelId}
onChange={(boostsChannelId) => setValue((prev) => ({ ...prev, boostsChannelId }))}
/>
</div> </div>
</FieldAnchor> </FieldAnchor>
<div className="space-y-2"> <div className="space-y-2">

View File

@@ -1,11 +1,10 @@
'use client'; 'use client';
import type { SuggestionConfigDashboard } from '@nexumi/shared'; import type { SuggestionConfigDashboard } from '@nexumi/shared';
import { useId } 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { DiscordChannelSelect } from '@/components/ui/discord-channel-select';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
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';
@@ -38,9 +37,6 @@ interface SuggestionsConfigFormProps {
export function SuggestionsConfigForm({ guildId, initialValue }: SuggestionsConfigFormProps) { export function SuggestionsConfigForm({ guildId, initialValue }: SuggestionsConfigFormProps) {
const t = useTranslations(); const t = useTranslations();
const openId = useId();
const approvedId = useId();
const deniedId = useId();
return ( return (
<SettingsForm<SuggestionConfigDashboard> <SettingsForm<SuggestionConfigDashboard>
@@ -63,20 +59,34 @@ export function SuggestionsConfigForm({ guildId, initialValue }: SuggestionsConf
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<FieldAnchor id="field-suggestions-open"> <FieldAnchor id="field-suggestions-open">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={openId}>{t('modulePages.suggestions.openChannelId')}</Label> <Label>{t('modulePages.suggestions.openChannelId')}</Label>
<Input id={openId} value={value.openChannelId} onChange={(event) => setValue((prev) => ({ ...prev, openChannelId: event.target.value.trim() }))} placeholder="123456789012345678" /> <DiscordChannelSelect
guildId={guildId}
value={value.openChannelId}
onChange={(openChannelId) => setValue((prev) => ({ ...prev, openChannelId }))}
/>
</div> </div>
</FieldAnchor> </FieldAnchor>
<FieldAnchor id="field-suggestions-approved"> <FieldAnchor id="field-suggestions-approved">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={approvedId}>{t('modulePages.suggestions.approvedChannelId')}</Label> <Label>{t('modulePages.suggestions.approvedChannelId')}</Label>
<Input id={approvedId} value={value.approvedChannelId} onChange={(event) => setValue((prev) => ({ ...prev, approvedChannelId: event.target.value.trim() }))} placeholder={t('common.optional')} /> <DiscordChannelSelect
guildId={guildId}
value={value.approvedChannelId}
onChange={(approvedChannelId) => setValue((prev) => ({ ...prev, approvedChannelId }))}
placeholder={t('common.optional')}
/>
</div> </div>
</FieldAnchor> </FieldAnchor>
<FieldAnchor id="field-suggestions-denied"> <FieldAnchor id="field-suggestions-denied">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={deniedId}>{t('modulePages.suggestions.deniedChannelId')}</Label> <Label>{t('modulePages.suggestions.deniedChannelId')}</Label>
<Input id={deniedId} value={value.deniedChannelId} onChange={(event) => setValue((prev) => ({ ...prev, deniedChannelId: event.target.value.trim() }))} placeholder={t('common.optional')} /> <DiscordChannelSelect
guildId={guildId}
value={value.deniedChannelId}
onChange={(deniedChannelId) => setValue((prev) => ({ ...prev, deniedChannelId }))}
placeholder={t('common.optional')}
/>
</div> </div>
</FieldAnchor> </FieldAnchor>
</div> </div>

View File

@@ -8,6 +8,8 @@ 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 } from '@/components/ui/discord-channel-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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -15,17 +17,8 @@ import { Textarea } from '@/components/ui/textarea';
const RESPONSE_TYPES: TagResponseType[] = ['TEXT', 'EMBED']; const RESPONSE_TYPES: TagResponseType[] = ['TEXT', 'EMBED'];
function toCsv(ids: string[]): string {
return ids.join(', ');
}
function fromCsv(text: string): string[] {
return text.split(',').map((entry) => entry.trim()).filter(Boolean);
}
interface LocalTag extends TagDashboard { interface LocalTag extends TagDashboard {
clientKey: string; clientKey: string;
allowedRoleIdsText: string;
allowedChannelIdsText: string;
saving?: boolean; saving?: boolean;
} }
@@ -34,8 +27,8 @@ const EMPTY_DRAFT = {
content: '', content: '',
responseType: 'TEXT' as TagResponseType, responseType: 'TEXT' as TagResponseType,
triggerWord: '', triggerWord: '',
allowedRoleIdsText: '', allowedRoleIds: [] as string[],
allowedChannelIdsText: '' allowedChannelIds: [] as string[]
}; };
interface TagsManagerProps { interface TagsManagerProps {
@@ -48,9 +41,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,
clientKey: tag.id ?? `existing-${index}`, clientKey: tag.id ?? `existing-${index}`
allowedRoleIdsText: toCsv(tag.allowedRoleIds),
allowedChannelIdsText: toCsv(tag.allowedChannelIds)
})) }))
); );
const [draft, setDraft] = useState(EMPTY_DRAFT); const [draft, setDraft] = useState(EMPTY_DRAFT);
@@ -71,8 +62,8 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
content: draft.content.trim() || null, content: draft.content.trim() || null,
responseType: draft.responseType, responseType: draft.responseType,
triggerWord: draft.triggerWord.trim() || null, triggerWord: draft.triggerWord.trim() || null,
allowedRoleIds: fromCsv(draft.allowedRoleIdsText), allowedRoleIds: draft.allowedRoleIds,
allowedChannelIds: fromCsv(draft.allowedChannelIdsText) allowedChannelIds: draft.allowedChannelIds
}) })
}); });
const body = (await response.json().catch(() => null)) as (TagDashboard & { error?: string }) | null; const body = (await response.json().catch(() => null)) as (TagDashboard & { error?: string }) | null;
@@ -83,9 +74,7 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
setTags((prev) => [ setTags((prev) => [
{ {
...body, ...body,
clientKey: body.id ?? `new-${Date.now()}`, clientKey: body.id ?? `new-${Date.now()}`
allowedRoleIdsText: toCsv(body.allowedRoleIds),
allowedChannelIdsText: toCsv(body.allowedChannelIds)
}, },
...prev ...prev
]); ]);
@@ -109,8 +98,8 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
content: tag.content, content: tag.content,
responseType: tag.responseType, responseType: tag.responseType,
triggerWord: tag.triggerWord, triggerWord: tag.triggerWord,
allowedRoleIds: fromCsv(tag.allowedRoleIdsText), allowedRoleIds: tag.allowedRoleIds,
allowedChannelIds: fromCsv(tag.allowedChannelIdsText) allowedChannelIds: tag.allowedChannelIds
}) })
}); });
const body = (await response.json().catch(() => null)) as (TagDashboard & { error?: string }) | null; const body = (await response.json().catch(() => null)) as (TagDashboard & { error?: string }) | null;
@@ -184,11 +173,19 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
<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>
<Input value={draft.allowedRoleIdsText} onChange={(event) => setDraft((prev) => ({ ...prev, allowedRoleIdsText: event.target.value }))} placeholder={t('modulePages.tags.idsPlaceholder')} /> <DiscordRoleMultiSelect
guildId={guildId}
value={draft.allowedRoleIds}
onChange={(allowedRoleIds) => setDraft((prev) => ({ ...prev, allowedRoleIds }))}
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('modulePages.tags.allowedChannelIds')}</Label> <Label>{t('modulePages.tags.allowedChannelIds')}</Label>
<Input value={draft.allowedChannelIdsText} onChange={(event) => setDraft((prev) => ({ ...prev, allowedChannelIdsText: event.target.value }))} placeholder={t('modulePages.tags.idsPlaceholder')} /> <DiscordChannelMultiSelect
guildId={guildId}
value={draft.allowedChannelIds}
onChange={(allowedChannelIds) => setDraft((prev) => ({ ...prev, allowedChannelIds }))}
/>
</div> </div>
</div> </div>
<Button type="button" onClick={handleCreate} disabled={creating}> <Button type="button" onClick={handleCreate} disabled={creating}>
@@ -242,11 +239,27 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
<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>
<Input value={tag.allowedRoleIdsText} onChange={(event) => setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, allowedRoleIdsText: event.target.value } : entry)))} /> <DiscordRoleMultiSelect
guildId={guildId}
value={tag.allowedRoleIds}
onChange={(allowedRoleIds) =>
setTags((prev) =>
prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, allowedRoleIds } : entry))
)
}
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('modulePages.tags.allowedChannelIds')}</Label> <Label>{t('modulePages.tags.allowedChannelIds')}</Label>
<Input value={tag.allowedChannelIdsText} onChange={(event) => setTags((prev) => prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, allowedChannelIdsText: event.target.value } : entry)))} /> <DiscordChannelMultiSelect
guildId={guildId}
value={tag.allowedChannelIds}
onChange={(allowedChannelIds) =>
setTags((prev) =>
prev.map((entry) => (entry.clientKey === tag.clientKey ? { ...entry, allowedChannelIds } : entry))
)
}
/>
</div> </div>
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">

View File

@@ -5,6 +5,7 @@ import { useId } 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
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 { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
@@ -35,8 +36,6 @@ interface TempVoiceFormProps {
export function TempVoiceForm({ guildId, initialValue }: TempVoiceFormProps) { export function TempVoiceForm({ guildId, initialValue }: TempVoiceFormProps) {
const t = useTranslations(); const t = useTranslations();
const hubId = useId();
const categoryId = useId();
const nameId = useId(); const nameId = useId();
const limitId = useId(); const limitId = useId();
@@ -58,14 +57,24 @@ export function TempVoiceForm({ guildId, initialValue }: TempVoiceFormProps) {
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<FieldAnchor id="field-tempvoice-hub"> <FieldAnchor id="field-tempvoice-hub">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={hubId}>{t('modulePages.tempvoice.hubChannelId')}</Label> <Label>{t('modulePages.tempvoice.hubChannelId')}</Label>
<Input id={hubId} value={value.hubChannelId} onChange={(event) => setValue((prev) => ({ ...prev, hubChannelId: event.target.value.trim() }))} placeholder="123456789012345678" /> <DiscordChannelSelect
guildId={guildId}
kinds={['voice']}
value={value.hubChannelId}
onChange={(hubChannelId) => setValue((prev) => ({ ...prev, hubChannelId }))}
/>
</div> </div>
</FieldAnchor> </FieldAnchor>
<FieldAnchor id="field-tempvoice-category"> <FieldAnchor id="field-tempvoice-category">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={categoryId}>{t('modulePages.tempvoice.categoryId')}</Label> <Label>{t('modulePages.tempvoice.categoryId')}</Label>
<Input id={categoryId} value={value.categoryId} onChange={(event) => setValue((prev) => ({ ...prev, categoryId: event.target.value.trim() }))} placeholder="123456789012345678" /> <DiscordChannelSelect
guildId={guildId}
kinds={['category']}
value={value.categoryId}
onChange={(categoryId) => setValue((prev) => ({ ...prev, categoryId }))}
/>
</div> </div>
</FieldAnchor> </FieldAnchor>
<FieldAnchor id="field-tempvoice-name"> <FieldAnchor id="field-tempvoice-name">

View File

@@ -8,6 +8,7 @@ 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 { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@@ -16,18 +17,7 @@ interface LocalCategory extends TicketCategoryDashboard {
saving?: boolean; saving?: boolean;
} }
function toCsv(ids: string[]): string { const EMPTY_DRAFT = { name: '', description: '', emoji: '', supportRoleIds: [] as string[] };
return ids.join(', ');
}
function fromCsv(text: string): string[] {
return text
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
const EMPTY_DRAFT = { name: '', description: '', emoji: '', supportRoleIdsText: '' };
interface TicketCategoriesManagerProps { interface TicketCategoriesManagerProps {
guildId: string; guildId: string;
@@ -56,7 +46,7 @@ export function TicketCategoriesManager({ guildId, initialCategories }: TicketCa
name: draft.name.trim(), name: draft.name.trim(),
description: draft.description.trim() || null, description: draft.description.trim() || null,
emoji: draft.emoji.trim() || null, emoji: draft.emoji.trim() || null,
supportRoleIds: fromCsv(draft.supportRoleIdsText) supportRoleIds: draft.supportRoleIds
}) })
}); });
const body = (await response.json().catch(() => null)) as (TicketCategoryDashboard & { error?: string }) | null; const body = (await response.json().catch(() => null)) as (TicketCategoryDashboard & { error?: string }) | null;
@@ -178,18 +168,16 @@ export function TicketCategoriesManager({ guildId, initialCategories }: TicketCa
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('modulePages.tickets.supportRoleIds')}</Label> <Label>{t('modulePages.tickets.supportRoleIds')}</Label>
<Input <DiscordRoleMultiSelect
value={toCsv(category.supportRoleIds)} guildId={guildId}
onChange={(event) => value={category.supportRoleIds}
onChange={(supportRoleIds) =>
setCategories((prev) => setCategories((prev) =>
prev.map((entry) => prev.map((entry) =>
entry.clientKey === category.clientKey entry.clientKey === category.clientKey ? { ...entry, supportRoleIds } : entry
? { ...entry, supportRoleIds: fromCsv(event.target.value) }
: entry
) )
) )
} }
placeholder={t('modulePages.tickets.idsPlaceholder')}
/> />
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
@@ -224,10 +212,10 @@ export function TicketCategoriesManager({ guildId, initialCategories }: TicketCa
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('modulePages.tickets.supportRoleIds')}</Label> <Label>{t('modulePages.tickets.supportRoleIds')}</Label>
<Input <DiscordRoleMultiSelect
value={draft.supportRoleIdsText} guildId={guildId}
onChange={(event) => setDraft((prev) => ({ ...prev, supportRoleIdsText: event.target.value }))} value={draft.supportRoleIds}
placeholder={t('modulePages.tickets.idsPlaceholder')} onChange={(supportRoleIds) => setDraft((prev) => ({ ...prev, supportRoleIds }))}
/> />
</div> </div>
<Button type="button" variant="outline" onClick={handleCreate} disabled={creating}> <Button type="button" variant="outline" onClick={handleCreate} disabled={creating}>

View File

@@ -5,6 +5,7 @@ import { useId } 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -36,8 +37,6 @@ interface TicketConfigFormProps {
export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProps) { export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProps) {
const t = useTranslations(); const t = useTranslations();
const categoryId = useId();
const logChannelId = useId();
const inactivityId = useId(); const inactivityId = useId();
return ( return (
@@ -80,24 +79,23 @@ export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProp
<FieldAnchor id="field-tickets-category"> <FieldAnchor id="field-tickets-category">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={categoryId}>{t('modulePages.tickets.categoryId')}</Label> <Label>{t('modulePages.tickets.categoryId')}</Label>
<Input <DiscordChannelSelect
id={categoryId} guildId={guildId}
kinds={['category']}
value={value.categoryId} value={value.categoryId}
onChange={(event) => setValue((prev) => ({ ...prev, categoryId: event.target.value.trim() }))} onChange={(categoryId) => setValue((prev) => ({ ...prev, categoryId }))}
placeholder="123456789012345678"
/> />
</div> </div>
</FieldAnchor> </FieldAnchor>
<FieldAnchor id="field-tickets-log"> <FieldAnchor id="field-tickets-log">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={logChannelId}>{t('modulePages.tickets.logChannelId')}</Label> <Label>{t('modulePages.tickets.logChannelId')}</Label>
<Input <DiscordChannelSelect
id={logChannelId} guildId={guildId}
value={value.logChannelId} value={value.logChannelId}
onChange={(event) => setValue((prev) => ({ ...prev, logChannelId: event.target.value.trim() }))} onChange={(logChannelId) => setValue((prev) => ({ ...prev, logChannelId }))}
placeholder="123456789012345678"
/> />
</div> </div>
</FieldAnchor> </FieldAnchor>

View File

@@ -4,6 +4,8 @@ import type { VerificationConfigDashboard } from '@nexumi/shared';
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DiscordChannelSelect } from '@/components/ui/discord-channel-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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -104,30 +106,30 @@ export function VerificationForm({ guildId, initialValue }: VerificationFormProp
<FieldAnchor id="field-verify-channel"> <FieldAnchor id="field-verify-channel">
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('modulePages.verification.channelId')}</Label> <Label>{t('modulePages.verification.channelId')}</Label>
<Input <DiscordChannelSelect
guildId={guildId}
value={value.channelId} value={value.channelId}
onChange={(event) => setValue((prev) => ({ ...prev, channelId: event.target.value.trim() }))} onChange={(channelId) => setValue((prev) => ({ ...prev, channelId }))}
placeholder="123456789012345678"
/> />
</div> </div>
</FieldAnchor> </FieldAnchor>
<FieldAnchor id="field-verify-role"> <FieldAnchor id="field-verify-role">
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('modulePages.verification.verifiedRoleId')}</Label> <Label>{t('modulePages.verification.verifiedRoleId')}</Label>
<Input <DiscordRoleSelect
guildId={guildId}
value={value.verifiedRoleId} value={value.verifiedRoleId}
onChange={(event) => setValue((prev) => ({ ...prev, verifiedRoleId: event.target.value.trim() }))} onChange={(verifiedRoleId) => setValue((prev) => ({ ...prev, verifiedRoleId }))}
placeholder="123456789012345678"
/> />
</div> </div>
</FieldAnchor> </FieldAnchor>
<FieldAnchor id="field-verify-unverified"> <FieldAnchor id="field-verify-unverified">
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('modulePages.verification.unverifiedRoleId')}</Label> <Label>{t('modulePages.verification.unverifiedRoleId')}</Label>
<Input <DiscordRoleSelect
guildId={guildId}
value={value.unverifiedRoleId} value={value.unverifiedRoleId}
onChange={(event) => setValue((prev) => ({ ...prev, unverifiedRoleId: event.target.value.trim() }))} onChange={(unverifiedRoleId) => setValue((prev) => ({ ...prev, unverifiedRoleId }))}
placeholder="123456789012345678"
/> />
</div> </div>
</FieldAnchor> </FieldAnchor>

View File

@@ -1,10 +1,11 @@
'use client'; 'use client';
import type { WelcomeConfigDashboard } from '@nexumi/shared'; import type { WelcomeConfigDashboard } from '@nexumi/shared';
import { useId } 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DiscordChannelSelect } from '@/components/ui/discord-channel-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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -13,34 +14,19 @@ import { Textarea } from '@/components/ui/textarea';
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';
interface LocalWelcomeValue extends Omit<WelcomeConfigDashboard, 'userAutoroleIds' | 'botAutoroleIds' | 'welcomeEmbed' | 'leaveEmbed'> { interface LocalWelcomeValue extends Omit<WelcomeConfigDashboard, 'welcomeEmbed' | 'leaveEmbed'> {
userAutoroleIdsText: string;
botAutoroleIdsText: string;
welcomeEmbedText: string; welcomeEmbedText: string;
leaveEmbedText: string; leaveEmbedText: string;
} }
function toCsv(ids: string[]): string {
return ids.join(', ');
}
function fromCsv(text: string): string[] {
return text
.split(',')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
function toJsonText(value: Record<string, unknown> | null | undefined): string { function toJsonText(value: Record<string, unknown> | null | undefined): string {
return value ? JSON.stringify(value, null, 2) : ''; return value ? JSON.stringify(value, null, 2) : '';
} }
function toLocal(config: WelcomeConfigDashboard): LocalWelcomeValue { function toLocal(config: WelcomeConfigDashboard): LocalWelcomeValue {
const { userAutoroleIds, botAutoroleIds, welcomeEmbed, leaveEmbed, ...rest } = config; const { welcomeEmbed, leaveEmbed, ...rest } = config;
return { return {
...rest, ...rest,
userAutoroleIdsText: toCsv(userAutoroleIds),
botAutoroleIdsText: toCsv(botAutoroleIds),
welcomeEmbedText: toJsonText(welcomeEmbed), welcomeEmbedText: toJsonText(welcomeEmbed),
leaveEmbedText: toJsonText(leaveEmbed) leaveEmbedText: toJsonText(leaveEmbed)
}; };
@@ -71,12 +57,10 @@ async function saveWelcome(guildId: string, value: LocalWelcomeValue): Promise<S
return { ok: false, error: leaveEmbed.error }; return { ok: false, error: leaveEmbed.error };
} }
const { userAutoroleIdsText, botAutoroleIdsText, welcomeEmbedText: _welcomeEmbedText, leaveEmbedText: _leaveEmbedText, ...rest } = value; const { welcomeEmbedText: _welcomeEmbedText, leaveEmbedText: _leaveEmbedText, ...rest } = value;
const payload: WelcomeConfigDashboard = { const payload: WelcomeConfigDashboard = {
...rest, ...rest,
userAutoroleIds: fromCsv(userAutoroleIdsText),
botAutoroleIds: fromCsv(botAutoroleIdsText),
welcomeEmbed: welcomeEmbed.value, welcomeEmbed: welcomeEmbed.value,
leaveEmbed: leaveEmbed.value leaveEmbed: leaveEmbed.value
}; };
@@ -104,8 +88,6 @@ interface WelcomeFormProps {
export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) { export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
const t = useTranslations(); const t = useTranslations();
const userAutorolesId = useId();
const botAutorolesId = useId();
return ( return (
<SettingsForm<LocalWelcomeValue> initialValue={toLocal(initialValue)} onSave={(value) => saveWelcome(guildId, value)}> <SettingsForm<LocalWelcomeValue> initialValue={toLocal(initialValue)} onSave={(value) => saveWelcome(guildId, value)}>
@@ -130,10 +112,10 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
<FieldAnchor id="field-welcome-channel"> <FieldAnchor id="field-welcome-channel">
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('modulePages.welcome.welcomeChannelId')}</Label> <Label>{t('modulePages.welcome.welcomeChannelId')}</Label>
<Input <DiscordChannelSelect
guildId={guildId}
value={value.welcomeChannelId} value={value.welcomeChannelId}
onChange={(event) => setValue((prev) => ({ ...prev, welcomeChannelId: event.target.value.trim() }))} onChange={(welcomeChannelId) => setValue((prev) => ({ ...prev, welcomeChannelId }))}
placeholder="123456789012345678"
/> />
</div> </div>
</FieldAnchor> </FieldAnchor>
@@ -199,23 +181,21 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
</FieldAnchor> </FieldAnchor>
<FieldAnchor id="field-welcome-user-autoroles"> <FieldAnchor id="field-welcome-user-autoroles">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={userAutorolesId}>{t('modulePages.welcome.userAutoroles')}</Label> <Label>{t('modulePages.welcome.userAutoroles')}</Label>
<Textarea <DiscordRoleMultiSelect
id={userAutorolesId} guildId={guildId}
value={value.userAutoroleIdsText} value={value.userAutoroleIds}
onChange={(event) => setValue((prev) => ({ ...prev, userAutoroleIdsText: event.target.value }))} onChange={(userAutoroleIds) => setValue((prev) => ({ ...prev, userAutoroleIds }))}
placeholder={t('modulePages.logging.idsPlaceholder')}
/> />
</div> </div>
</FieldAnchor> </FieldAnchor>
<FieldAnchor id="field-welcome-bot-autoroles"> <FieldAnchor id="field-welcome-bot-autoroles">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={botAutorolesId}>{t('modulePages.welcome.botAutoroles')}</Label> <Label>{t('modulePages.welcome.botAutoroles')}</Label>
<Textarea <DiscordRoleMultiSelect
id={botAutorolesId} guildId={guildId}
value={value.botAutoroleIdsText} value={value.botAutoroleIds}
onChange={(event) => setValue((prev) => ({ ...prev, botAutoroleIdsText: event.target.value }))} onChange={(botAutoroleIds) => setValue((prev) => ({ ...prev, botAutoroleIds }))}
placeholder={t('modulePages.logging.idsPlaceholder')}
/> />
</div> </div>
</FieldAnchor> </FieldAnchor>
@@ -260,10 +240,10 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
<FieldAnchor id="field-leave-channel"> <FieldAnchor id="field-leave-channel">
<div className="space-y-2"> <div className="space-y-2">
<Label>{t('modulePages.welcome.leaveChannelId')}</Label> <Label>{t('modulePages.welcome.leaveChannelId')}</Label>
<Input <DiscordChannelSelect
guildId={guildId}
value={value.leaveChannelId} value={value.leaveChannelId}
onChange={(event) => setValue((prev) => ({ ...prev, leaveChannelId: event.target.value.trim() }))} onChange={(leaveChannelId) => setValue((prev) => ({ ...prev, leaveChannelId }))}
placeholder="123456789012345678"
/> />
</div> </div>
</FieldAnchor> </FieldAnchor>

View File

@@ -2,12 +2,11 @@
import { DASHBOARD_MODULES, type DashboardAccessRule, type DashboardModuleId } from '@nexumi/shared'; import { DASHBOARD_MODULES, type DashboardAccessRule, type DashboardModuleId } from '@nexumi/shared';
import { Plus, Trash2 } from 'lucide-react'; import { Plus, Trash2 } from 'lucide-react';
import { useId } 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 { Input } from '@/components/ui/input'; import { DiscordRoleSelect } from '@/components/ui/discord-role-select';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import type { SettingsSaveResult } from './settings-form'; import type { SettingsSaveResult } from './settings-form';
@@ -54,18 +53,19 @@ async function saveAccessRules(guildId: string, rules: LocalRule[]): Promise<Set
} }
function AccessRuleRow({ function AccessRuleRow({
guildId,
rule, rule,
onChange, onChange,
onRemove, onRemove,
anchorIds anchorIds
}: { }: {
guildId: string;
rule: LocalRule; rule: LocalRule;
onChange: (rule: LocalRule) => void; onChange: (rule: LocalRule) => void;
onRemove: () => void; onRemove: () => void;
anchorIds?: boolean; anchorIds?: boolean;
}) { }) {
const t = useTranslations(); const t = useTranslations();
const roleInputId = useId();
function toggleModule(moduleId: DashboardModuleId, checked: boolean) { function toggleModule(moduleId: DashboardModuleId, checked: boolean) {
const next = checked const next = checked
@@ -77,14 +77,15 @@ function AccessRuleRow({
const roleBlock = ( const roleBlock = (
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<Label htmlFor={roleInputId}>{t('access.roleIdLabel')}</Label> <Label>{t('access.roleIdLabel')}</Label>
<Input <div className="max-w-xs">
id={roleInputId} <DiscordRoleSelect
value={rule.roleId} guildId={guildId}
onChange={(event) => onChange({ ...rule, roleId: event.target.value.trim() })} value={rule.roleId}
placeholder={t('access.roleIdPlaceholder')} onChange={(roleId) => onChange({ ...rule, roleId })}
className="max-w-xs" placeholder={t('access.roleIdPlaceholder')}
/> />
</div>
<p className="text-xs text-muted-foreground">{t('access.roleIdHint')}</p> <p className="text-xs text-muted-foreground">{t('access.roleIdHint')}</p>
</div> </div>
<Button type="button" variant="ghost" size="icon" onClick={onRemove} aria-label={t('access.removeRule')}> <Button type="button" variant="ghost" size="icon" onClick={onRemove} aria-label={t('access.removeRule')}>
@@ -169,6 +170,7 @@ export function AccessRulesForm({ guildId, initialRules }: AccessRulesFormProps)
{value.map((rule, index) => ( {value.map((rule, index) => (
<AccessRuleRow <AccessRuleRow
key={rule.clientKey} key={rule.clientKey}
guildId={guildId}
rule={rule} rule={rule}
anchorIds={index === 0} anchorIds={index === 0}
onChange={(updated) => onChange={(updated) =>

View File

@@ -0,0 +1,161 @@
'use client';
import type { DiscordChannelOption } from '@nexumi/shared';
import { Check, ChevronsUpDown, X } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command';
import { DiscordResourceMultiSelect } from '@/components/ui/discord-resource-multi-select';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useGuildChannels } from '@/hooks/use-guild-channels';
import { CHANNEL_TYPES, channelPrefix } from '@/lib/discord-guild-resources';
import { cn } from '@/lib/utils';
type ChannelKind = keyof typeof CHANNEL_TYPES;
function filterChannels(
channels: DiscordChannelOption[],
kinds: ChannelKind[] | undefined
): DiscordChannelOption[] {
if (!kinds || kinds.length === 0) {
return channels;
}
const allowed = new Set<number>(kinds.flatMap((kind) => [...CHANNEL_TYPES[kind]]));
return channels.filter((channel) => allowed.has(channel.type));
}
function toOptions(channels: DiscordChannelOption[]) {
return channels.map((channel) => ({
id: channel.id,
name: channel.name,
prefix: channelPrefix(channel.type)
}));
}
interface ChannelSelectBaseProps {
guildId: string;
kinds?: ChannelKind[];
disabled?: boolean;
placeholder?: string;
}
interface DiscordChannelSelectProps extends ChannelSelectBaseProps {
value: string;
onChange: (next: string) => void;
allowClear?: boolean;
}
/** Single channel picker (searchable). Empty string = cleared. */
export function DiscordChannelSelect({
guildId,
value,
onChange,
kinds = ['messageable'],
disabled = false,
placeholder,
allowClear = true
}: DiscordChannelSelectProps) {
const t = useTranslations();
const { channels, loading } = useGuildChannels(guildId);
const [open, setOpen] = useState(false);
const options = useMemo(() => toOptions(filterChannels(channels, kinds)), [channels, kinds]);
const selected = options.find((option) => option.id === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled || loading}
className="h-9 w-full justify-between px-3 font-normal"
>
<span className={cn('truncate', !selected && 'text-muted-foreground')}>
{selected
? `${selected.prefix}${selected.name}`
: (placeholder ?? t('modulePages.commands.channelSearchPlaceholder'))}
</span>
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput placeholder={placeholder ?? t('modulePages.commands.channelSearchPlaceholder')} />
<CommandList>
<CommandEmpty>{t('common.noResults')}</CommandEmpty>
<CommandGroup>
{allowClear && value ? (
<CommandItem
value="__clear__"
onSelect={() => {
onChange('');
setOpen(false);
}}
>
<X className="size-4 opacity-60" />
<span className="text-muted-foreground">{t('common.clearSelection')}</span>
</CommandItem>
) : null}
{options.map((option) => (
<CommandItem
key={option.id}
value={`${option.prefix}${option.name} ${option.id}`}
onSelect={() => {
onChange(option.id);
setOpen(false);
}}
>
<Check className={cn('size-4', value === option.id ? 'opacity-100' : 'opacity-0')} />
<span className="truncate">
{option.prefix}
{option.name}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
interface DiscordChannelMultiSelectProps extends ChannelSelectBaseProps {
value: string[];
onChange: (next: string[]) => void;
}
/** Multi channel picker (searchable), same UX as Commands. */
export function DiscordChannelMultiSelect({
guildId,
value,
onChange,
kinds = ['messageable'],
disabled = false,
placeholder
}: DiscordChannelMultiSelectProps) {
const t = useTranslations();
const { channels, loading } = useGuildChannels(guildId);
const options = useMemo(() => toOptions(filterChannels(channels, kinds)), [channels, kinds]);
return (
<DiscordResourceMultiSelect
options={options}
value={value}
onChange={onChange}
disabled={disabled || loading}
placeholder={placeholder ?? t('modulePages.commands.channelSearchPlaceholder')}
/>
);
}

View File

@@ -0,0 +1,144 @@
'use client';
import type { DiscordRoleOption } from '@nexumi/shared';
import { Check, ChevronsUpDown, X } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from '@/components/ui/command';
import { DiscordResourceMultiSelect } from '@/components/ui/discord-resource-multi-select';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useGuildRoles } from '@/hooks/use-guild-roles';
import { cn } from '@/lib/utils';
function toOptions(roles: DiscordRoleOption[]) {
return roles.map((role) => ({
id: role.id,
name: role.name,
prefix: '@'
}));
}
interface RoleSelectBaseProps {
guildId: string;
disabled?: boolean;
placeholder?: string;
}
interface DiscordRoleSelectProps extends RoleSelectBaseProps {
value: string;
onChange: (next: string) => void;
allowClear?: boolean;
}
/** Single role picker (searchable). Empty string = cleared. */
export function DiscordRoleSelect({
guildId,
value,
onChange,
disabled = false,
placeholder,
allowClear = true
}: DiscordRoleSelectProps) {
const t = useTranslations();
const { roles, loading } = useGuildRoles(guildId);
const [open, setOpen] = useState(false);
const options = useMemo(() => toOptions(roles), [roles]);
const selected = options.find((option) => option.id === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled || loading}
className="h-9 w-full justify-between px-3 font-normal"
>
<span className={cn('truncate', !selected && 'text-muted-foreground')}>
{selected
? `${selected.prefix}${selected.name}`
: (placeholder ?? t('modulePages.commands.roleSearchPlaceholder'))}
</span>
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput placeholder={placeholder ?? t('modulePages.commands.roleSearchPlaceholder')} />
<CommandList>
<CommandEmpty>{t('common.noResults')}</CommandEmpty>
<CommandGroup>
{allowClear && value ? (
<CommandItem
value="__clear__"
onSelect={() => {
onChange('');
setOpen(false);
}}
>
<X className="size-4 opacity-60" />
<span className="text-muted-foreground">{t('common.clearSelection')}</span>
</CommandItem>
) : null}
{options.map((option) => (
<CommandItem
key={option.id}
value={`${option.prefix}${option.name} ${option.id}`}
onSelect={() => {
onChange(option.id);
setOpen(false);
}}
>
<Check className={cn('size-4', value === option.id ? 'opacity-100' : 'opacity-0')} />
<span className="truncate">
{option.prefix}
{option.name}
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
interface DiscordRoleMultiSelectProps extends RoleSelectBaseProps {
value: string[];
onChange: (next: string[]) => void;
}
/** Multi role picker (searchable). */
export function DiscordRoleMultiSelect({
guildId,
value,
onChange,
disabled = false,
placeholder
}: DiscordRoleMultiSelectProps) {
const t = useTranslations();
const { roles, loading } = useGuildRoles(guildId);
const options = useMemo(() => toOptions(roles), [roles]);
return (
<DiscordResourceMultiSelect
options={options}
value={value}
onChange={onChange}
disabled={disabled || loading}
placeholder={placeholder ?? t('modulePages.commands.roleSearchPlaceholder')}
/>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import type { DiscordChannelOption } from '@nexumi/shared';
import { useEffect, useState } from 'react';
const cache = new Map<string, DiscordChannelOption[]>();
export function useGuildChannels(guildId: string): {
channels: DiscordChannelOption[];
loading: boolean;
} {
const [channels, setChannels] = useState<DiscordChannelOption[]>(() => cache.get(guildId) ?? []);
const [loading, setLoading] = useState(!cache.has(guildId));
useEffect(() => {
let cancelled = false;
const cached = cache.get(guildId);
if (cached) {
setChannels(cached);
setLoading(false);
return;
}
setLoading(true);
void fetch(`/api/guilds/${guildId}/channels`)
.then(async (response) => {
if (!response.ok) {
return [] as DiscordChannelOption[];
}
return (await response.json()) as DiscordChannelOption[];
})
.then((data) => {
if (cancelled) {
return;
}
cache.set(guildId, data);
setChannels(data);
})
.catch(() => {
if (!cancelled) {
setChannels([]);
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [guildId]);
return { channels, loading };
}

View File

@@ -0,0 +1,56 @@
'use client';
import type { DiscordRoleOption } from '@nexumi/shared';
import { useEffect, useState } from 'react';
const cache = new Map<string, DiscordRoleOption[]>();
export function useGuildRoles(guildId: string): {
roles: DiscordRoleOption[];
loading: boolean;
} {
const [roles, setRoles] = useState<DiscordRoleOption[]>(() => cache.get(guildId) ?? []);
const [loading, setLoading] = useState(!cache.has(guildId));
useEffect(() => {
let cancelled = false;
const cached = cache.get(guildId);
if (cached) {
setRoles(cached);
setLoading(false);
return;
}
setLoading(true);
void fetch(`/api/guilds/${guildId}/roles`)
.then(async (response) => {
if (!response.ok) {
return [] as DiscordRoleOption[];
}
return (await response.json()) as DiscordRoleOption[];
})
.then((data) => {
if (cancelled) {
return;
}
cache.set(guildId, data);
setRoles(data);
})
.catch(() => {
if (!cancelled) {
setRoles([]);
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [guildId]);
return { roles, loading };
}

View File

@@ -5,17 +5,24 @@ import { redis } from './redis';
const DISCORD_API_BASE = 'https://discord.com/api/v10'; const DISCORD_API_BASE = 'https://discord.com/api/v10';
const CACHE_TTL_SECONDS = 60; const CACHE_TTL_SECONDS = 60;
/** Discord channel types usable for slash commands. */ /** Channel types useful in the dashboard (text, voice, category, forum, …). */
const COMMAND_CHANNEL_TYPES = new Set([ const DASHBOARD_CHANNEL_TYPES = new Set([
0, // GUILD_TEXT 0, // GUILD_TEXT
2, // GUILD_VOICE
4, // GUILD_CATEGORY
5, // GUILD_ANNOUNCEMENT 5, // GUILD_ANNOUNCEMENT
10, // ANNOUNCEMENT_THREAD 13, // GUILD_STAGE_VOICE
11, // PUBLIC_THREAD
12, // PRIVATE_THREAD
15, // GUILD_FORUM 15, // GUILD_FORUM
16 // GUILD_MEDIA 16 // GUILD_MEDIA
]); ]);
export const CHANNEL_TYPES = {
text: [0, 5, 15, 16],
voice: [2, 13],
category: [4],
messageable: [0, 5, 15, 16]
} as const;
async function discordBotFetch<T>(path: string): Promise<T> { async function discordBotFetch<T>(path: string): Promise<T> {
const response = await fetch(`${DISCORD_API_BASE}${path}`, { const response = await fetch(`${DISCORD_API_BASE}${path}`, {
headers: { Authorization: `Bot ${env.BOT_TOKEN}` }, headers: { Authorization: `Bot ${env.BOT_TOKEN}` },
@@ -44,7 +51,7 @@ interface DiscordRoleRest {
} }
export async function listGuildChannelsForDashboard(guildId: string): Promise<DiscordChannelOption[]> { export async function listGuildChannelsForDashboard(guildId: string): Promise<DiscordChannelOption[]> {
const cacheKey = `dashboard:guild:${guildId}:channels`; const cacheKey = `dashboard:guild:${guildId}:channels:v2`;
const cached = await redis.get(cacheKey); const cached = await redis.get(cacheKey);
if (cached) { if (cached) {
return JSON.parse(cached) as DiscordChannelOption[]; return JSON.parse(cached) as DiscordChannelOption[];
@@ -52,7 +59,7 @@ export async function listGuildChannelsForDashboard(guildId: string): Promise<Di
const channels = await discordBotFetch<DiscordChannelRest[]>(`/guilds/${guildId}/channels`); const channels = await discordBotFetch<DiscordChannelRest[]>(`/guilds/${guildId}/channels`);
const options = channels const options = channels
.filter((channel) => COMMAND_CHANNEL_TYPES.has(channel.type)) .filter((channel) => DASHBOARD_CHANNEL_TYPES.has(channel.type))
.map((channel) => ({ .map((channel) => ({
id: channel.id, id: channel.id,
name: channel.name, name: channel.name,
@@ -86,3 +93,13 @@ export async function listGuildRolesForDashboard(guildId: string): Promise<Disco
await redis.set(cacheKey, JSON.stringify(options), 'EX', CACHE_TTL_SECONDS); await redis.set(cacheKey, JSON.stringify(options), 'EX', CACHE_TTL_SECONDS);
return options; return options;
} }
export function channelPrefix(type: number): string {
if (type === 2 || type === 13) {
return '🔊 ';
}
if (type === 4) {
return '📁 ';
}
return '#';
}

View File

@@ -20,7 +20,8 @@
"comingSoon": "Demnächst verfügbar", "comingSoon": "Demnächst verfügbar",
"close": "Schließen", "close": "Schließen",
"searchSelect": "Suchen und auswählen…", "searchSelect": "Suchen und auswählen…",
"noResults": "Keine Treffer" "noResults": "Keine Treffer",
"clearSelection": "Auswahl löschen"
}, },
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",

View File

@@ -20,7 +20,8 @@
"close": "Close", "close": "Close",
"optional": "Optional", "optional": "Optional",
"searchSelect": "Search and select…", "searchSelect": "Search and select…",
"noResults": "No results" "noResults": "No results",
"clearSelection": "Clear selection"
}, },
"nav": { "nav": {
"dashboard": "Dashboard", "dashboard": "Dashboard",

View File

@@ -88,6 +88,28 @@
- Musik- und KI-Modul (nur auf Zuruf) - Musik- und KI-Modul (nur auf Zuruf)
- Premium-Zahlungsanbindung - Premium-Zahlungsanbindung
## Post-Phase Channel-Picker überall (Status: implementiert)
### Abgeschlossen (Code)
- `DiscordChannelSelect` / `DiscordChannelMultiSelect` + `useGuildChannels`
- Alle Modul-Forms: Kanal-ID-Texteingaben durch durchsuchbare Channel-Auswahl ersetzt
- Voice-/Category-Filter für TempVoice/Stats/Tickets
## Post-Phase Role-Picker überall (Status: implementiert)
### Abgeschlossen (Code)
- `DiscordRoleSelect` / `DiscordRoleMultiSelect` + `useGuildRoles`
- Modul-Forms/Manager: Rollen-ID-Texteingaben durch durchsuchbare Role-Auswahl ersetzt (Welcome, Verification, Leveling, Logging, Birthdays, Giveaways, Tags, Tickets, Access Rules, Feeds, Scheduler, Commands)
- Selfroles-Textarea (`roleId | label | emoji`) bewusst unverändert
### Manuell testen
- [ ] Rollen in den Modul-Settings per Dropdown setzen und speichern
- [ ] Multi-Select (Autoroles, No-XP-Roles, Support-Roles, …) speichert string[] korrekt
- [ ] Access-Rules: Rolle wählen und Module zuweisen
## Post-Phase Command-Global-Rules + Channel-Picker (Status: implementiert) ## Post-Phase Command-Global-Rules + Channel-Picker (Status: implementiert)
### Abgeschlossen (Code) ### Abgeschlossen (Code)