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:
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
161
apps/webui/src/components/ui/discord-channel-select.tsx
Normal file
161
apps/webui/src/components/ui/discord-channel-select.tsx
Normal 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')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
apps/webui/src/components/ui/discord-role-select.tsx
Normal file
144
apps/webui/src/components/ui/discord-role-select.tsx
Normal 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')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
apps/webui/src/hooks/use-guild-channels.ts
Normal file
56
apps/webui/src/hooks/use-guild-channels.ts
Normal 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 };
|
||||||
|
}
|
||||||
56
apps/webui/src/hooks/use-guild-roles.ts
Normal file
56
apps/webui/src/hooks/use-guild-roles.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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 '#';
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user