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

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

View File

@@ -3,8 +3,7 @@
import type {
CommandGlobalRules,
CommandOverrideDashboard,
DiscordChannelOption,
DiscordRoleOption
DiscordChannelOption
} from '@nexumi/shared';
import { RotateCcw } from 'lucide-react';
import { useSearchParams } from 'next/navigation';
@@ -14,6 +13,7 @@ import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { DiscordResourceMultiSelect } from '@/components/ui/discord-resource-multi-select';
import { DiscordRoleMultiSelect } from '@/components/ui/discord-role-select';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
@@ -26,15 +26,13 @@ interface CommandsManagerProps {
initialCommands: CommandOverrideDashboard[];
initialGlobalRules: CommandGlobalRules;
channels: DiscordChannelOption[];
roles: DiscordRoleOption[];
}
export function CommandsManager({
guildId,
initialCommands,
initialGlobalRules,
channels,
roles
channels
}: CommandsManagerProps) {
const t = useTranslations();
const searchParams = useSearchParams();
@@ -48,10 +46,6 @@ export function CommandsManager({
() => channels.map((channel) => ({ id: channel.id, name: channel.name, prefix: '#' })),
[channels]
);
const roleOptions = useMemo(
() => roles.map((role) => ({ id: role.id, name: role.name, prefix: '@' })),
[roles]
);
useEffect(() => {
const q = searchParams.get('q');
@@ -250,8 +244,8 @@ export function CommandsManager({
</div>
<div className="space-y-1 sm:col-span-2">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.allowedRoleIds')}</p>
<DiscordResourceMultiSelect
options={roleOptions}
<DiscordRoleMultiSelect
guildId={guildId}
value={entry.allowedRoleIds}
onChange={(allowedRoleIds) => patchLocal(entry.commandName, { allowedRoleIds })}
placeholder={t('modulePages.commands.roleSearchPlaceholder')}
@@ -260,8 +254,8 @@ export function CommandsManager({
</div>
<div className="space-y-1 sm:col-span-2">
<p className="text-xs text-muted-foreground">{t('modulePages.commands.deniedRoleIds')}</p>
<DiscordResourceMultiSelect
options={roleOptions}
<DiscordRoleMultiSelect
guildId={guildId}
value={entry.deniedRoleIds}
onChange={(deniedRoleIds) => patchLocal(entry.commandName, { deniedRoleIds })}
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 { Button } from '@/components/ui/button';
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 { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -182,17 +184,18 @@ export function FeedsManager({ guildId, initialFeeds }: FeedsManagerProps) {
</div>
<div className="space-y-2">
<Label>{t('modulePages.feeds.channelId')}</Label>
<Input
<DiscordChannelSelect
guildId={guildId}
value={draft.channelId}
onChange={(event) => setDraft((prev) => ({ ...prev, channelId: event.target.value }))}
placeholder="123456789012345678"
onChange={(channelId) => setDraft((prev) => ({ ...prev, channelId }))}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.feeds.rolePingId')}</Label>
<Input
<DiscordRoleSelect
guildId={guildId}
value={draft.rolePingId}
onChange={(event) => setDraft((prev) => ({ ...prev, rolePingId: event.target.value }))}
onChange={(rolePingId) => setDraft((prev) => ({ ...prev, rolePingId }))}
placeholder={t('common.optional')}
/>
</div>

View File

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

View File

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

View File

@@ -2,16 +2,16 @@
import type { LogChannelMapping, LogEventTypeDashboard, LoggingConfigDashboard } from '@nexumi/shared';
import { Plus, Trash2 } from 'lucide-react';
import { useId } from 'react';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
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 { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import type { SettingsSaveResult } from '@/components/settings/settings-form';
import { SettingsForm } from '@/components/settings/settings-form';
@@ -51,29 +51,18 @@ interface LocalLogChannel extends LogChannelMapping {
interface LocalLoggingValue {
enabled: boolean;
ignoreBots: boolean;
ignoredChannelIdsText: string;
ignoredRoleIdsText: string;
ignoredChannelIds: string[];
ignoredRoleIds: string[];
retentionDays: number;
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 {
return {
enabled: config.enabled,
ignoreBots: config.ignoreBots,
ignoredChannelIdsText: toCsv(config.ignoredChannelIds),
ignoredRoleIdsText: toCsv(config.ignoredRoleIds),
ignoredChannelIds: config.ignoredChannelIds,
ignoredRoleIds: config.ignoredRoleIds,
retentionDays: config.retentionDays,
logChannels: config.logChannels.map((mapping, index) => ({ ...mapping, clientKey: `${mapping.eventType}-${index}` }))
};
@@ -83,8 +72,8 @@ function toRemote(value: LocalLoggingValue): LoggingConfigDashboard {
return {
enabled: value.enabled,
ignoreBots: value.ignoreBots,
ignoredChannelIds: fromCsv(value.ignoredChannelIdsText),
ignoredRoleIds: fromCsv(value.ignoredRoleIdsText),
ignoredChannelIds: value.ignoredChannelIds,
ignoredRoleIds: value.ignoredRoleIds,
retentionDays: value.retentionDays,
logChannels: value.logChannels.map(({ clientKey: _clientKey, ...rest }) => rest)
};
@@ -114,8 +103,6 @@ interface LoggingFormProps {
export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
const t = useTranslations();
const ignoredChannelsId = useId();
const ignoredRolesId = useId();
return (
<SettingsForm<LocalLoggingValue>
@@ -150,26 +137,22 @@ export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
</FieldAnchor>
<FieldAnchor id="field-logging-ignored-channels">
<div className="space-y-2">
<Label htmlFor={ignoredChannelsId}>{t('modulePages.logging.ignoredChannelsLabel')}</Label>
<Textarea
id={ignoredChannelsId}
value={value.ignoredChannelIdsText}
onChange={(event) => setValue((prev) => ({ ...prev, ignoredChannelIdsText: event.target.value }))}
placeholder={t('modulePages.logging.idsPlaceholder')}
<Label>{t('modulePages.logging.ignoredChannelsLabel')}</Label>
<DiscordChannelMultiSelect
guildId={guildId}
value={value.ignoredChannelIds}
onChange={(ignoredChannelIds) => setValue((prev) => ({ ...prev, ignoredChannelIds }))}
/>
<p className="text-xs text-muted-foreground">{t('modulePages.logging.idsHint')}</p>
</div>
</FieldAnchor>
<FieldAnchor id="field-logging-ignored-roles">
<div className="space-y-2">
<Label htmlFor={ignoredRolesId}>{t('modulePages.logging.ignoredRolesLabel')}</Label>
<Textarea
id={ignoredRolesId}
value={value.ignoredRoleIdsText}
onChange={(event) => setValue((prev) => ({ ...prev, ignoredRoleIdsText: event.target.value }))}
placeholder={t('modulePages.logging.idsPlaceholder')}
<Label>{t('modulePages.logging.ignoredRolesLabel')}</Label>
<DiscordRoleMultiSelect
guildId={guildId}
value={value.ignoredRoleIds}
onChange={(ignoredRoleIds) => setValue((prev) => ({ ...prev, ignoredRoleIds }))}
/>
<p className="text-xs text-muted-foreground">{t('modulePages.logging.idsHint')}</p>
</div>
</FieldAnchor>
<FieldAnchor id="field-logging-retention">
@@ -233,17 +216,17 @@ export function LoggingForm({ guildId, initialValue }: LoggingFormProps) {
</div>
<div className="flex-1 space-y-2">
<Label>{t('modulePages.logging.channelId')}</Label>
<Input
<DiscordChannelSelect
guildId={guildId}
value={mapping.channelId}
onChange={(event) =>
onChange={(channelId) =>
setValue((prev) => ({
...prev,
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>
<Button

View File

@@ -8,6 +8,8 @@ import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
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 { Label } from '@/components/ui/label';
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="space-y-2">
<Label>{t('modulePages.scheduler.channelId')}</Label>
<Input
<DiscordChannelSelect
guildId={guildId}
value={draft.channelId}
onChange={(event) => setDraft((prev) => ({ ...prev, channelId: event.target.value }))}
placeholder="123456789012345678"
onChange={(channelId) => setDraft((prev) => ({ ...prev, channelId }))}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.scheduler.rolePingId')}</Label>
<Input
<DiscordRoleSelect
guildId={guildId}
value={draft.rolePingId}
onChange={(event) => setDraft((prev) => ({ ...prev, rolePingId: event.target.value }))}
onChange={(rolePingId) => setDraft((prev) => ({ ...prev, rolePingId }))}
placeholder={t('common.optional')}
/>
</div>

View File

@@ -8,6 +8,7 @@ import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
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 { Label } from '@/components/ui/label';
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="space-y-2">
<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 className="space-y-2">
<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="space-y-2">
<Label>{t('modulePages.selfroles.channelId')}</Label>
<Input
<DiscordChannelSelect
guildId={guildId}
value={panel.channelId}
onChange={(event) =>
setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, channelId: event.target.value } : entry)))
onChange={(channelId) =>
setPanels((prev) =>
prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, channelId } : entry))
)
}
/>
</div>

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,8 @@ import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
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 { Label } from '@/components/ui/label';
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'];
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 {
clientKey: string;
allowedRoleIdsText: string;
allowedChannelIdsText: string;
saving?: boolean;
}
@@ -34,8 +27,8 @@ const EMPTY_DRAFT = {
content: '',
responseType: 'TEXT' as TagResponseType,
triggerWord: '',
allowedRoleIdsText: '',
allowedChannelIdsText: ''
allowedRoleIds: [] as string[],
allowedChannelIds: [] as string[]
};
interface TagsManagerProps {
@@ -48,9 +41,7 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
const [tags, setTags] = useState<LocalTag[]>(
initialTags.map((tag, index) => ({
...tag,
clientKey: tag.id ?? `existing-${index}`,
allowedRoleIdsText: toCsv(tag.allowedRoleIds),
allowedChannelIdsText: toCsv(tag.allowedChannelIds)
clientKey: tag.id ?? `existing-${index}`
}))
);
const [draft, setDraft] = useState(EMPTY_DRAFT);
@@ -71,8 +62,8 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
content: draft.content.trim() || null,
responseType: draft.responseType,
triggerWord: draft.triggerWord.trim() || null,
allowedRoleIds: fromCsv(draft.allowedRoleIdsText),
allowedChannelIds: fromCsv(draft.allowedChannelIdsText)
allowedRoleIds: draft.allowedRoleIds,
allowedChannelIds: draft.allowedChannelIds
})
});
const body = (await response.json().catch(() => null)) as (TagDashboard & { error?: string }) | null;
@@ -83,9 +74,7 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
setTags((prev) => [
{
...body,
clientKey: body.id ?? `new-${Date.now()}`,
allowedRoleIdsText: toCsv(body.allowedRoleIds),
allowedChannelIdsText: toCsv(body.allowedChannelIds)
clientKey: body.id ?? `new-${Date.now()}`
},
...prev
]);
@@ -109,8 +98,8 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) {
content: tag.content,
responseType: tag.responseType,
triggerWord: tag.triggerWord,
allowedRoleIds: fromCsv(tag.allowedRoleIdsText),
allowedChannelIds: fromCsv(tag.allowedChannelIdsText)
allowedRoleIds: tag.allowedRoleIds,
allowedChannelIds: tag.allowedChannelIds
})
});
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="space-y-2">
<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 className="space-y-2">
<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>
<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="space-y-2">
<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 className="space-y-2">
<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 className="flex justify-end gap-2">

View File

@@ -5,6 +5,7 @@ import { useId } from 'react';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
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 { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
@@ -35,8 +36,6 @@ interface TempVoiceFormProps {
export function TempVoiceForm({ guildId, initialValue }: TempVoiceFormProps) {
const t = useTranslations();
const hubId = useId();
const categoryId = useId();
const nameId = useId();
const limitId = useId();
@@ -58,14 +57,24 @@ export function TempVoiceForm({ guildId, initialValue }: TempVoiceFormProps) {
<div className="grid gap-4 sm:grid-cols-2">
<FieldAnchor id="field-tempvoice-hub">
<div className="space-y-2">
<Label htmlFor={hubId}>{t('modulePages.tempvoice.hubChannelId')}</Label>
<Input id={hubId} value={value.hubChannelId} onChange={(event) => setValue((prev) => ({ ...prev, hubChannelId: event.target.value.trim() }))} placeholder="123456789012345678" />
<Label>{t('modulePages.tempvoice.hubChannelId')}</Label>
<DiscordChannelSelect
guildId={guildId}
kinds={['voice']}
value={value.hubChannelId}
onChange={(hubChannelId) => setValue((prev) => ({ ...prev, hubChannelId }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-tempvoice-category">
<div className="space-y-2">
<Label htmlFor={categoryId}>{t('modulePages.tempvoice.categoryId')}</Label>
<Input id={categoryId} value={value.categoryId} onChange={(event) => setValue((prev) => ({ ...prev, categoryId: event.target.value.trim() }))} placeholder="123456789012345678" />
<Label>{t('modulePages.tempvoice.categoryId')}</Label>
<DiscordChannelSelect
guildId={guildId}
kinds={['category']}
value={value.categoryId}
onChange={(categoryId) => setValue((prev) => ({ ...prev, categoryId }))}
/>
</div>
</FieldAnchor>
<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 { Button } from '@/components/ui/button';
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 { Label } from '@/components/ui/label';
@@ -16,18 +17,7 @@ interface LocalCategory extends TicketCategoryDashboard {
saving?: boolean;
}
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);
}
const EMPTY_DRAFT = { name: '', description: '', emoji: '', supportRoleIdsText: '' };
const EMPTY_DRAFT = { name: '', description: '', emoji: '', supportRoleIds: [] as string[] };
interface TicketCategoriesManagerProps {
guildId: string;
@@ -56,7 +46,7 @@ export function TicketCategoriesManager({ guildId, initialCategories }: TicketCa
name: draft.name.trim(),
description: draft.description.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;
@@ -178,18 +168,16 @@ export function TicketCategoriesManager({ guildId, initialCategories }: TicketCa
</div>
<div className="space-y-2">
<Label>{t('modulePages.tickets.supportRoleIds')}</Label>
<Input
value={toCsv(category.supportRoleIds)}
onChange={(event) =>
<DiscordRoleMultiSelect
guildId={guildId}
value={category.supportRoleIds}
onChange={(supportRoleIds) =>
setCategories((prev) =>
prev.map((entry) =>
entry.clientKey === category.clientKey
? { ...entry, supportRoleIds: fromCsv(event.target.value) }
: entry
entry.clientKey === category.clientKey ? { ...entry, supportRoleIds } : entry
)
)
}
placeholder={t('modulePages.tickets.idsPlaceholder')}
/>
</div>
<div className="flex justify-end gap-2">
@@ -224,10 +212,10 @@ export function TicketCategoriesManager({ guildId, initialCategories }: TicketCa
</div>
<div className="space-y-2">
<Label>{t('modulePages.tickets.supportRoleIds')}</Label>
<Input
value={draft.supportRoleIdsText}
onChange={(event) => setDraft((prev) => ({ ...prev, supportRoleIdsText: event.target.value }))}
placeholder={t('modulePages.tickets.idsPlaceholder')}
<DiscordRoleMultiSelect
guildId={guildId}
value={draft.supportRoleIds}
onChange={(supportRoleIds) => setDraft((prev) => ({ ...prev, supportRoleIds }))}
/>
</div>
<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 { useTranslations } from '@/components/locale-provider';
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 { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -36,8 +37,6 @@ interface TicketConfigFormProps {
export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProps) {
const t = useTranslations();
const categoryId = useId();
const logChannelId = useId();
const inactivityId = useId();
return (
@@ -80,24 +79,23 @@ export function TicketConfigForm({ guildId, initialValue }: TicketConfigFormProp
<FieldAnchor id="field-tickets-category">
<div className="space-y-2">
<Label htmlFor={categoryId}>{t('modulePages.tickets.categoryId')}</Label>
<Input
id={categoryId}
<Label>{t('modulePages.tickets.categoryId')}</Label>
<DiscordChannelSelect
guildId={guildId}
kinds={['category']}
value={value.categoryId}
onChange={(event) => setValue((prev) => ({ ...prev, categoryId: event.target.value.trim() }))}
placeholder="123456789012345678"
onChange={(categoryId) => setValue((prev) => ({ ...prev, categoryId }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-tickets-log">
<div className="space-y-2">
<Label htmlFor={logChannelId}>{t('modulePages.tickets.logChannelId')}</Label>
<Input
id={logChannelId}
<Label>{t('modulePages.tickets.logChannelId')}</Label>
<DiscordChannelSelect
guildId={guildId}
value={value.logChannelId}
onChange={(event) => setValue((prev) => ({ ...prev, logChannelId: event.target.value.trim() }))}
placeholder="123456789012345678"
onChange={(logChannelId) => setValue((prev) => ({ ...prev, logChannelId }))}
/>
</div>
</FieldAnchor>

View File

@@ -4,6 +4,8 @@ import type { VerificationConfigDashboard } from '@nexumi/shared';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
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 { Label } from '@/components/ui/label';
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">
<div className="space-y-2">
<Label>{t('modulePages.verification.channelId')}</Label>
<Input
<DiscordChannelSelect
guildId={guildId}
value={value.channelId}
onChange={(event) => setValue((prev) => ({ ...prev, channelId: event.target.value.trim() }))}
placeholder="123456789012345678"
onChange={(channelId) => setValue((prev) => ({ ...prev, channelId }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-verify-role">
<div className="space-y-2">
<Label>{t('modulePages.verification.verifiedRoleId')}</Label>
<Input
<DiscordRoleSelect
guildId={guildId}
value={value.verifiedRoleId}
onChange={(event) => setValue((prev) => ({ ...prev, verifiedRoleId: event.target.value.trim() }))}
placeholder="123456789012345678"
onChange={(verifiedRoleId) => setValue((prev) => ({ ...prev, verifiedRoleId }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-verify-unverified">
<div className="space-y-2">
<Label>{t('modulePages.verification.unverifiedRoleId')}</Label>
<Input
<DiscordRoleSelect
guildId={guildId}
value={value.unverifiedRoleId}
onChange={(event) => setValue((prev) => ({ ...prev, unverifiedRoleId: event.target.value.trim() }))}
placeholder="123456789012345678"
onChange={(unverifiedRoleId) => setValue((prev) => ({ ...prev, unverifiedRoleId }))}
/>
</div>
</FieldAnchor>

View File

@@ -1,10 +1,11 @@
'use client';
import type { WelcomeConfigDashboard } from '@nexumi/shared';
import { useId } from 'react';
import { FieldAnchor } from '@/components/layout/field-anchor';
import { useTranslations } from '@/components/locale-provider';
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 { Label } from '@/components/ui/label';
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 { SettingsForm } from '@/components/settings/settings-form';
interface LocalWelcomeValue extends Omit<WelcomeConfigDashboard, 'userAutoroleIds' | 'botAutoroleIds' | 'welcomeEmbed' | 'leaveEmbed'> {
userAutoroleIdsText: string;
botAutoroleIdsText: string;
interface LocalWelcomeValue extends Omit<WelcomeConfigDashboard, 'welcomeEmbed' | 'leaveEmbed'> {
welcomeEmbedText: 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 {
return value ? JSON.stringify(value, null, 2) : '';
}
function toLocal(config: WelcomeConfigDashboard): LocalWelcomeValue {
const { userAutoroleIds, botAutoroleIds, welcomeEmbed, leaveEmbed, ...rest } = config;
const { welcomeEmbed, leaveEmbed, ...rest } = config;
return {
...rest,
userAutoroleIdsText: toCsv(userAutoroleIds),
botAutoroleIdsText: toCsv(botAutoroleIds),
welcomeEmbedText: toJsonText(welcomeEmbed),
leaveEmbedText: toJsonText(leaveEmbed)
};
@@ -71,12 +57,10 @@ async function saveWelcome(guildId: string, value: LocalWelcomeValue): Promise<S
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 = {
...rest,
userAutoroleIds: fromCsv(userAutoroleIdsText),
botAutoroleIds: fromCsv(botAutoroleIdsText),
welcomeEmbed: welcomeEmbed.value,
leaveEmbed: leaveEmbed.value
};
@@ -104,8 +88,6 @@ interface WelcomeFormProps {
export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
const t = useTranslations();
const userAutorolesId = useId();
const botAutorolesId = useId();
return (
<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">
<div className="space-y-2">
<Label>{t('modulePages.welcome.welcomeChannelId')}</Label>
<Input
<DiscordChannelSelect
guildId={guildId}
value={value.welcomeChannelId}
onChange={(event) => setValue((prev) => ({ ...prev, welcomeChannelId: event.target.value.trim() }))}
placeholder="123456789012345678"
onChange={(welcomeChannelId) => setValue((prev) => ({ ...prev, welcomeChannelId }))}
/>
</div>
</FieldAnchor>
@@ -199,23 +181,21 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
</FieldAnchor>
<FieldAnchor id="field-welcome-user-autoroles">
<div className="space-y-2">
<Label htmlFor={userAutorolesId}>{t('modulePages.welcome.userAutoroles')}</Label>
<Textarea
id={userAutorolesId}
value={value.userAutoroleIdsText}
onChange={(event) => setValue((prev) => ({ ...prev, userAutoroleIdsText: event.target.value }))}
placeholder={t('modulePages.logging.idsPlaceholder')}
<Label>{t('modulePages.welcome.userAutoroles')}</Label>
<DiscordRoleMultiSelect
guildId={guildId}
value={value.userAutoroleIds}
onChange={(userAutoroleIds) => setValue((prev) => ({ ...prev, userAutoroleIds }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-welcome-bot-autoroles">
<div className="space-y-2">
<Label htmlFor={botAutorolesId}>{t('modulePages.welcome.botAutoroles')}</Label>
<Textarea
id={botAutorolesId}
value={value.botAutoroleIdsText}
onChange={(event) => setValue((prev) => ({ ...prev, botAutoroleIdsText: event.target.value }))}
placeholder={t('modulePages.logging.idsPlaceholder')}
<Label>{t('modulePages.welcome.botAutoroles')}</Label>
<DiscordRoleMultiSelect
guildId={guildId}
value={value.botAutoroleIds}
onChange={(botAutoroleIds) => setValue((prev) => ({ ...prev, botAutoroleIds }))}
/>
</div>
</FieldAnchor>
@@ -260,10 +240,10 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
<FieldAnchor id="field-leave-channel">
<div className="space-y-2">
<Label>{t('modulePages.welcome.leaveChannelId')}</Label>
<Input
<DiscordChannelSelect
guildId={guildId}
value={value.leaveChannelId}
onChange={(event) => setValue((prev) => ({ ...prev, leaveChannelId: event.target.value.trim() }))}
placeholder="123456789012345678"
onChange={(leaveChannelId) => setValue((prev) => ({ ...prev, leaveChannelId }))}
/>
</div>
</FieldAnchor>

View File

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