deploy #1

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

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
guildId={guildId}
value={rule.roleId} value={rule.roleId}
onChange={(event) => onChange({ ...rule, roleId: event.target.value.trim() })} onChange={(roleId) => onChange({ ...rule, roleId })}
placeholder={t('access.roleIdPlaceholder')} placeholder={t('access.roleIdPlaceholder')}
className="max-w-xs"
/> />
</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)