Add global command channel rules and enhance command handling
- Introduced global command channel whitelist and blacklist in the GuildSettings model for improved command access control. - Implemented command gate logic in the command routing to handle channel and role restrictions, providing user feedback for denied commands. - Enhanced the CommandsManager component to support global rules and channel/role selection, improving the user interface for managing command permissions. - Updated localization files to include new keys for global command rules and related messages, ensuring clarity in user interactions.
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import type { CommandOverrideDashboard } from '@nexumi/shared';
|
||||
import type {
|
||||
CommandGlobalRules,
|
||||
CommandOverrideDashboard,
|
||||
DiscordChannelOption,
|
||||
DiscordRoleOption
|
||||
} from '@nexumi/shared';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
@@ -8,20 +13,10 @@ import { toast } from 'sonner';
|
||||
import { useTranslations } from '@/components/locale-provider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { DiscordResourceMultiSelect } from '@/components/ui/discord-resource-multi-select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
interface LocalOverride extends CommandOverrideDashboard {
|
||||
saving?: boolean;
|
||||
}
|
||||
@@ -29,13 +24,34 @@ interface LocalOverride extends CommandOverrideDashboard {
|
||||
interface CommandsManagerProps {
|
||||
guildId: string;
|
||||
initialCommands: CommandOverrideDashboard[];
|
||||
initialGlobalRules: CommandGlobalRules;
|
||||
channels: DiscordChannelOption[];
|
||||
roles: DiscordRoleOption[];
|
||||
}
|
||||
|
||||
export function CommandsManager({ guildId, initialCommands }: CommandsManagerProps) {
|
||||
export function CommandsManager({
|
||||
guildId,
|
||||
initialCommands,
|
||||
initialGlobalRules,
|
||||
channels,
|
||||
roles
|
||||
}: CommandsManagerProps) {
|
||||
const t = useTranslations();
|
||||
const searchParams = useSearchParams();
|
||||
const [commands, setCommands] = useState<LocalOverride[]>(initialCommands);
|
||||
const [search, setSearch] = useState('');
|
||||
const [globalRules, setGlobalRules] = useState(initialGlobalRules);
|
||||
const [globalSaving, setGlobalSaving] = useState(false);
|
||||
const [globalDirty, setGlobalDirty] = useState(false);
|
||||
|
||||
const channelOptions = useMemo(
|
||||
() => channels.map((channel) => ({ id: channel.id, name: channel.name, prefix: '#' })),
|
||||
[channels]
|
||||
);
|
||||
const roleOptions = useMemo(
|
||||
() => roles.map((role) => ({ id: role.id, name: role.name, prefix: '@' })),
|
||||
[roles]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const q = searchParams.get('q');
|
||||
@@ -58,6 +74,37 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
|
||||
);
|
||||
}
|
||||
|
||||
function patchGlobal(patch: Partial<CommandGlobalRules>) {
|
||||
setGlobalRules((prev) => ({ ...prev, ...patch }));
|
||||
setGlobalDirty(true);
|
||||
}
|
||||
|
||||
async function handleSaveGlobal() {
|
||||
setGlobalSaving(true);
|
||||
try {
|
||||
const response = await fetch(`/api/guilds/${guildId}/commands/globals`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(globalRules)
|
||||
});
|
||||
const body = (await response.json().catch(() => null)) as (CommandGlobalRules & { error?: string }) | null;
|
||||
if (!response.ok || !body) {
|
||||
toast.error(body?.error ?? t('common.saveError'));
|
||||
return;
|
||||
}
|
||||
setGlobalRules({
|
||||
allowedChannelIds: body.allowedChannelIds,
|
||||
deniedChannelIds: body.deniedChannelIds
|
||||
});
|
||||
setGlobalDirty(false);
|
||||
toast.success(t('common.saveSuccess'));
|
||||
} catch {
|
||||
toast.error(t('common.saveError'));
|
||||
} finally {
|
||||
setGlobalSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(entry: LocalOverride) {
|
||||
patchLocal(entry.commandName, { saving: true });
|
||||
try {
|
||||
@@ -110,122 +157,168 @@ export function CommandsManager({ guildId, initialCommands }: CommandsManagerPro
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.commands.title')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.commands.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t('modulePages.commands.searchPlaceholder')}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t('modulePages.commands.empty')}</p>
|
||||
)}
|
||||
{filtered.map((entry) => (
|
||||
<div key={entry.commandName} className="space-y-3 rounded-md border border-border p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="font-mono text-sm font-medium">/{entry.commandName}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={entry.enabled}
|
||||
disabled={entry.saving}
|
||||
onCheckedChange={(enabled) => patchLocal(entry.commandName, { enabled })}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{entry.enabled ? t('common.enabled') : t('common.disabled')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.commands.cooldownSeconds')}</p>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={entry.cooldownSeconds ?? ''}
|
||||
onChange={(event) =>
|
||||
patchLocal(entry.commandName, {
|
||||
cooldownSeconds: event.target.value === '' ? null : Number(event.target.value)
|
||||
})
|
||||
}
|
||||
placeholder={t('common.optional')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.commands.allowedRoleIds')}</p>
|
||||
<Input
|
||||
value={toCsv(entry.allowedRoleIds)}
|
||||
onChange={(event) =>
|
||||
patchLocal(entry.commandName, { allowedRoleIds: fromCsv(event.target.value) })
|
||||
}
|
||||
placeholder={t('modulePages.commands.idsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.commands.deniedRoleIds')}</p>
|
||||
<Input
|
||||
value={toCsv(entry.deniedRoleIds)}
|
||||
onChange={(event) =>
|
||||
patchLocal(entry.commandName, { deniedRoleIds: fromCsv(event.target.value) })
|
||||
}
|
||||
placeholder={t('modulePages.commands.idsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.commands.allowedChannelIds')}</p>
|
||||
<Input
|
||||
value={toCsv(entry.allowedChannelIds)}
|
||||
onChange={(event) =>
|
||||
patchLocal(entry.commandName, {
|
||||
allowedChannelIds: fromCsv(event.target.value)
|
||||
})
|
||||
}
|
||||
placeholder={t('modulePages.commands.idsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.commands.deniedChannelIds')}</p>
|
||||
<Input
|
||||
value={toCsv(entry.deniedChannelIds)}
|
||||
onChange={(event) =>
|
||||
patchLocal(entry.commandName, {
|
||||
deniedChannelIds: fromCsv(event.target.value)
|
||||
})
|
||||
}
|
||||
placeholder={t('modulePages.commands.idsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={entry.saving}
|
||||
onClick={() => handleReset(entry)}
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
{t('modulePages.commands.reset')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={entry.saving}
|
||||
onClick={() => handleSave(entry)}
|
||||
>
|
||||
{entry.saving ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.commands.globalTitle')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.commands.globalDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div id="field-commands-global-allow" className="scroll-mt-24 space-y-2">
|
||||
<p className="text-sm font-medium">{t('modulePages.commands.globalAllowedChannels')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.commands.globalAllowedHint')}</p>
|
||||
<DiscordResourceMultiSelect
|
||||
options={channelOptions}
|
||||
value={globalRules.allowedChannelIds}
|
||||
onChange={(allowedChannelIds) => patchGlobal({ allowedChannelIds })}
|
||||
placeholder={t('modulePages.commands.channelSearchPlaceholder')}
|
||||
disabled={globalSaving}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div id="field-commands-global-deny" className="scroll-mt-24 space-y-2">
|
||||
<p className="text-sm font-medium">{t('modulePages.commands.globalDeniedChannels')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.commands.globalDeniedHint')}</p>
|
||||
<DiscordResourceMultiSelect
|
||||
options={channelOptions}
|
||||
value={globalRules.deniedChannelIds}
|
||||
onChange={(deniedChannelIds) => patchGlobal({ deniedChannelIds })}
|
||||
placeholder={t('modulePages.commands.channelSearchPlaceholder')}
|
||||
disabled={globalSaving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!globalDirty || globalSaving}
|
||||
onClick={() => void handleSaveGlobal()}
|
||||
>
|
||||
{globalSaving ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('modulePages.commands.title')}</CardTitle>
|
||||
<CardDescription>{t('modulePages.commands.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t('modulePages.commands.searchPlaceholder')}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t('modulePages.commands.empty')}</p>
|
||||
)}
|
||||
{filtered.map((entry) => (
|
||||
<div key={entry.commandName} className="space-y-3 rounded-md border border-border p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="font-mono text-sm font-medium">/{entry.commandName}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={entry.enabled}
|
||||
disabled={entry.saving}
|
||||
onCheckedChange={(enabled) => patchLocal(entry.commandName, { enabled })}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{entry.enabled ? t('common.enabled') : t('common.disabled')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.commands.cooldownSeconds')}</p>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={entry.cooldownSeconds ?? ''}
|
||||
onChange={(event) =>
|
||||
patchLocal(entry.commandName, {
|
||||
cooldownSeconds: event.target.value === '' ? null : Number(event.target.value)
|
||||
})
|
||||
}
|
||||
placeholder={t('common.optional')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 sm:col-span-2">
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.commands.allowedRoleIds')}</p>
|
||||
<DiscordResourceMultiSelect
|
||||
options={roleOptions}
|
||||
value={entry.allowedRoleIds}
|
||||
onChange={(allowedRoleIds) => patchLocal(entry.commandName, { allowedRoleIds })}
|
||||
placeholder={t('modulePages.commands.roleSearchPlaceholder')}
|
||||
disabled={entry.saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 sm:col-span-2">
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.commands.deniedRoleIds')}</p>
|
||||
<DiscordResourceMultiSelect
|
||||
options={roleOptions}
|
||||
value={entry.deniedRoleIds}
|
||||
onChange={(deniedRoleIds) => patchLocal(entry.commandName, { deniedRoleIds })}
|
||||
placeholder={t('modulePages.commands.roleSearchPlaceholder')}
|
||||
disabled={entry.saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 sm:col-span-2">
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.commands.allowedChannelIds')}</p>
|
||||
<DiscordResourceMultiSelect
|
||||
options={channelOptions}
|
||||
value={entry.allowedChannelIds}
|
||||
onChange={(allowedChannelIds) =>
|
||||
patchLocal(entry.commandName, { allowedChannelIds })
|
||||
}
|
||||
placeholder={t('modulePages.commands.channelSearchPlaceholder')}
|
||||
disabled={entry.saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 sm:col-span-2">
|
||||
<p className="text-xs text-muted-foreground">{t('modulePages.commands.deniedChannelIds')}</p>
|
||||
<DiscordResourceMultiSelect
|
||||
options={channelOptions}
|
||||
value={entry.deniedChannelIds}
|
||||
onChange={(deniedChannelIds) =>
|
||||
patchLocal(entry.commandName, { deniedChannelIds })
|
||||
}
|
||||
placeholder={t('modulePages.commands.channelSearchPlaceholder')}
|
||||
disabled={entry.saving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={entry.saving}
|
||||
onClick={() => void handleReset(entry)}
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
{t('modulePages.commands.reset')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={entry.saving}
|
||||
onClick={() => void handleSave(entry)}
|
||||
>
|
||||
{entry.saving ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
132
apps/webui/src/components/ui/discord-resource-multi-select.tsx
Normal file
132
apps/webui/src/components/ui/discord-resource-multi-select.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface ResourceOption {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Optional prefix shown before the name (e.g. # for channels). */
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
interface DiscordResourceMultiSelectProps {
|
||||
options: ResourceOption[];
|
||||
value: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
emptyLabel?: string;
|
||||
}
|
||||
|
||||
export function DiscordResourceMultiSelect({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
emptyLabel
|
||||
}: DiscordResourceMultiSelectProps) {
|
||||
const t = useTranslations();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selected = useMemo(() => {
|
||||
const byId = new Map(options.map((option) => [option.id, option]));
|
||||
return value.map((id) => byId.get(id) ?? { id, name: id, prefix: '' });
|
||||
}, [options, value]);
|
||||
|
||||
function toggle(id: string) {
|
||||
if (value.includes(id)) {
|
||||
onChange(value.filter((entry) => entry !== id));
|
||||
} else {
|
||||
onChange([...value, id]);
|
||||
}
|
||||
}
|
||||
|
||||
function remove(id: string) {
|
||||
onChange(value.filter((entry) => entry !== id));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className="h-auto min-h-9 w-full justify-between px-3 py-2 font-normal"
|
||||
>
|
||||
<span className="truncate text-left text-muted-foreground">
|
||||
{placeholder ?? t('common.searchSelect')}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={placeholder ?? t('common.searchSelect')} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{emptyLabel ?? t('common.noResults')}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => {
|
||||
const isSelected = value.includes(option.id);
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.id}
|
||||
value={`${option.prefix ?? ''}${option.name} ${option.id}`}
|
||||
onSelect={() => toggle(option.id)}
|
||||
>
|
||||
<Check className={cn('size-4', isSelected ? 'opacity-100' : 'opacity-0')} />
|
||||
<span className="truncate">
|
||||
{option.prefix ?? ''}
|
||||
{option.name}
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[10px] text-muted-foreground">
|
||||
{option.id.slice(-6)}
|
||||
</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{selected.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selected.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => remove(option.id)}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border bg-secondary px-2 py-0.5 text-xs hover:bg-accent"
|
||||
>
|
||||
<span className="max-w-40 truncate">
|
||||
{option.prefix ?? ''}
|
||||
{option.name}
|
||||
</span>
|
||||
<X className="size-3 opacity-60" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
apps/webui/src/components/ui/popover.tsx
Normal file
29
apps/webui/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'start', sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-[var(--radix-popover-trigger-width)] rounded-md border border-border bg-popover p-0 text-popover-foreground shadow-md outline-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent };
|
||||
Reference in New Issue
Block a user