deploy #1
@@ -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