Refactor self-role management to use a role builder interface

- Replaced the text area for role input with a structured role builder, enhancing user experience and reducing input errors.
- Updated the self-role management logic to handle roles as structured objects instead of plain text, improving data integrity.
- Introduced new functions for parsing and validating roles, ensuring compliance with the new builder format.
- Enhanced error handling for role creation and updates, providing clearer feedback for missing or invalid data.
- Updated localization files to reflect changes in role management terminology and instructions.
This commit is contained in:
TheOnlyMace
2026-07-25 15:53:27 +02:00
parent ba2d144038
commit 67ac16d36b
10 changed files with 955 additions and 192 deletions

View File

@@ -1,6 +1,6 @@
'use client';
import type { SelfRoleBehavior, SelfRoleEntry, SelfRoleMode, SelfRolePanelDashboard } from '@nexumi/shared';
import type { SelfRoleBehavior, SelfRoleMode, SelfRolePanelDashboard } from '@nexumi/shared';
import { Plus, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
@@ -12,35 +12,20 @@ import { DiscordChannelSelect } from '@/components/ui/discord-channel-select';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import {
builderRowsToRoles,
emptyBuilderRow,
rolesToBuilderRows,
type SelfRoleBuilderRow,
SelfRoleEntriesBuilder
} from '@/components/ui/self-role-entries-builder';
const MODES: SelfRoleMode[] = ['BUTTONS', 'DROPDOWN', 'REACTIONS'];
const BEHAVIORS: SelfRoleBehavior[] = ['NORMAL', 'UNIQUE', 'VERIFY', 'TEMPORARY'];
function rolesToText(roles: SelfRoleEntry[]): string {
return roles.map((role) => [role.roleId, role.label, role.emoji ?? '', role.description ?? ''].join(' | ')).join('\n');
}
function textToRoles(text: string): SelfRoleEntry[] {
return text
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => {
const [roleId, label, emoji, description] = line.split('|').map((part) => part.trim());
return {
roleId: roleId ?? '',
label: label || roleId || '',
emoji: emoji || undefined,
description: description || undefined
};
})
.filter((role) => role.roleId.length > 0);
}
interface LocalPanel extends SelfRolePanelDashboard {
interface LocalPanel extends Omit<SelfRolePanelDashboard, 'roles'> {
clientKey: string;
rolesText: string;
roles: SelfRoleBuilderRow[];
saving?: boolean;
}
@@ -50,7 +35,7 @@ const EMPTY_DRAFT = {
description: '',
mode: 'BUTTONS' as SelfRoleMode,
behavior: 'NORMAL' as SelfRoleBehavior,
rolesText: ''
roles: [emptyBuilderRow()] as SelfRoleBuilderRow[]
};
interface SelfRolesManagerProps {
@@ -58,24 +43,43 @@ interface SelfRolesManagerProps {
initialPanels: SelfRolePanelDashboard[];
}
function toastBuilderError(
t: ReturnType<typeof useTranslations>,
error: 'incomplete' | 'emojiRequired' | 'tooMany'
) {
if (error === 'emojiRequired') {
toast.error(t('modulePages.selfroles.emojiRequired'));
return;
}
if (error === 'tooMany') {
toast.error(t('modulePages.selfroles.tooManyRoles'));
return;
}
toast.error(t('modulePages.selfroles.formIncomplete'));
}
export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerProps) {
const t = useTranslations();
const [panels, setPanels] = useState<LocalPanel[]>(
initialPanels.map((panel, index) => ({
...panel,
clientKey: panel.id ?? `existing-${index}`,
rolesText: rolesToText(panel.roles)
roles: rolesToBuilderRows(panel.roles)
}))
);
const [draft, setDraft] = useState(EMPTY_DRAFT);
const [creating, setCreating] = useState(false);
async function handleCreate() {
const roles = textToRoles(draft.rolesText);
if (!draft.channelId.trim() || !draft.title.trim() || roles.length === 0) {
if (!draft.channelId.trim() || !draft.title.trim()) {
toast.error(t('modulePages.selfroles.formIncomplete'));
return;
}
const parsed = builderRowsToRoles(draft.roles, draft.mode === 'REACTIONS');
if (parsed.error || !parsed.value.length) {
toastBuilderError(t, parsed.error ?? 'incomplete');
return;
}
setCreating(true);
try {
const response = await fetch(`/api/guilds/${guildId}/selfroles`, {
@@ -87,7 +91,7 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
description: draft.description.trim() || null,
mode: draft.mode,
behavior: draft.behavior,
roles
roles: parsed.value
})
});
const body = (await response.json().catch(() => null)) as (SelfRolePanelDashboard & { error?: string }) | null;
@@ -96,10 +100,14 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
return;
}
setPanels((prev) => [
{ ...body, clientKey: body.id ?? `new-${Date.now()}`, rolesText: rolesToText(body.roles) },
{
...body,
clientKey: body.id ?? `new-${Date.now()}`,
roles: rolesToBuilderRows(body.roles)
},
...prev
]);
setDraft(EMPTY_DRAFT);
setDraft({ ...EMPTY_DRAFT, roles: [emptyBuilderRow()] });
toast.success(t('common.saveSuccess'));
} catch {
toast.error(t('common.saveError'));
@@ -109,6 +117,14 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
}
async function handleSave(panel: LocalPanel) {
if (!panel.id) {
return;
}
const parsed = builderRowsToRoles(panel.roles, panel.mode === 'REACTIONS');
if (parsed.error || !parsed.value.length) {
toastBuilderError(t, parsed.error ?? 'incomplete');
return;
}
setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, saving: true } : entry)));
try {
const response = await fetch(`/api/guilds/${guildId}/selfroles/${panel.id}`, {
@@ -120,7 +136,7 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
description: panel.description,
mode: panel.mode,
behavior: panel.behavior,
roles: textToRoles(panel.rolesText)
roles: parsed.value
})
});
const body = (await response.json().catch(() => null)) as (SelfRolePanelDashboard & { error?: string }) | null;
@@ -128,11 +144,25 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
toast.error(body?.error ?? t('common.saveError'));
return;
}
setPanels((prev) =>
prev.map((entry) =>
entry.clientKey === panel.clientKey
? {
...entry,
...body,
roles: rolesToBuilderRows(body.roles),
saving: false
}
: entry
)
);
toast.success(t('common.saveSuccess'));
} catch {
toast.error(t('common.saveError'));
} finally {
setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, saving: false } : entry)));
setPanels((prev) =>
prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, saving: false } : entry))
);
}
}
@@ -156,176 +186,220 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
return (
<div className="space-y-6">
<FieldAnchor id="field-selfroles-create">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.selfroles.createTitle')}</CardTitle>
<CardDescription>{t('modulePages.selfroles.createDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.selfroles.createTitle')}</CardTitle>
<CardDescription>{t('modulePages.selfroles.createDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.selfroles.channelId')}</Label>
<DiscordChannelSelect
guildId={guildId}
value={draft.channelId}
onChange={(channelId) => setDraft((prev) => ({ ...prev, channelId }))}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.panelTitle')}</Label>
<Input
value={draft.title}
onChange={(event) => setDraft((prev) => ({ ...prev, title: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.mode')}</Label>
<Select
value={draft.mode}
onValueChange={(mode) => setDraft((prev) => ({ ...prev, mode: mode as SelfRoleMode }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODES.map((mode) => (
<SelectItem key={mode} value={mode}>
{t(`modulePages.selfroles.modes.${mode}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.behavior')}</Label>
<Select
value={draft.behavior}
onValueChange={(behavior) =>
setDraft((prev) => ({ ...prev, behavior: behavior as SelfRoleBehavior }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{BEHAVIORS.map((behavior) => (
<SelectItem key={behavior} value={behavior}>
{t(`modulePages.selfroles.behaviors.${behavior}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.channelId')}</Label>
<DiscordChannelSelect
guildId={guildId}
value={draft.channelId}
onChange={(channelId) => setDraft((prev) => ({ ...prev, channelId }))}
<Label>{t('modulePages.selfroles.description')}</Label>
<Input
value={draft.description}
onChange={(event) => setDraft((prev) => ({ ...prev, description: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.panelTitle')}</Label>
<Input value={draft.title} onChange={(event) => setDraft((prev) => ({ ...prev, title: event.target.value }))} />
<Label>{t('modulePages.selfroles.roles')}</Label>
<SelfRoleEntriesBuilder
guildId={guildId}
value={draft.roles}
requireEmoji={draft.mode === 'REACTIONS'}
onChange={(roles) => setDraft((prev) => ({ ...prev, roles }))}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.mode')}</Label>
<Select value={draft.mode} onValueChange={(mode) => setDraft((prev) => ({ ...prev, mode: mode as SelfRoleMode }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODES.map((mode) => (
<SelectItem key={mode} value={mode}>
{t(`modulePages.selfroles.modes.${mode}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.behavior')}</Label>
<Select value={draft.behavior} onValueChange={(behavior) => setDraft((prev) => ({ ...prev, behavior: behavior as SelfRoleBehavior }))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{BEHAVIORS.map((behavior) => (
<SelectItem key={behavior} value={behavior}>
{t(`modulePages.selfroles.behaviors.${behavior}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.description')}</Label>
<Input value={draft.description} onChange={(event) => setDraft((prev) => ({ ...prev, description: event.target.value }))} />
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.roles')}</Label>
<Textarea
rows={5}
value={draft.rolesText}
onChange={(event) => setDraft((prev) => ({ ...prev, rolesText: event.target.value }))}
placeholder={t('modulePages.selfroles.rolesPlaceholder')}
/>
<p className="text-xs text-muted-foreground">{t('modulePages.selfroles.rolesHint')}</p>
</div>
<Button type="button" onClick={handleCreate} disabled={creating}>
<Plus className="size-4" />
{creating ? t('common.saving') : t('modulePages.selfroles.create')}
</Button>
</CardContent>
</Card>
<Button type="button" onClick={handleCreate} disabled={creating}>
<Plus className="size-4" />
{creating ? t('common.saving') : t('modulePages.selfroles.create')}
</Button>
</CardContent>
</Card>
</FieldAnchor>
<FieldAnchor id="field-selfroles-list">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.selfroles.listTitle')}</CardTitle>
<CardDescription>{t('modulePages.selfroles.listDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{panels.length === 0 && <p className="text-sm text-muted-foreground">{t('modulePages.selfroles.empty')}</p>}
{panels.map((panel) => (
<div key={panel.clientKey} className="space-y-3 rounded-md border border-border p-4">
<div className="grid gap-3 sm:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>{t('modulePages.selfroles.listTitle')}</CardTitle>
<CardDescription>{t('modulePages.selfroles.listDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{panels.length === 0 && (
<p className="text-sm text-muted-foreground">{t('modulePages.selfroles.empty')}</p>
)}
{panels.map((panel) => (
<div key={panel.clientKey} className="space-y-3 rounded-md border border-border p-4">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label>{t('modulePages.selfroles.channelId')}</Label>
<DiscordChannelSelect
guildId={guildId}
value={panel.channelId}
onChange={(channelId) =>
setPanels((prev) =>
prev.map((entry) =>
entry.clientKey === panel.clientKey ? { ...entry, channelId } : entry
)
)
}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.panelTitle')}</Label>
<Input
value={panel.title}
onChange={(event) =>
setPanels((prev) =>
prev.map((entry) =>
entry.clientKey === panel.clientKey
? { ...entry, title: event.target.value }
: entry
)
)
}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.mode')}</Label>
<Select
value={panel.mode}
onValueChange={(mode) =>
setPanels((prev) =>
prev.map((entry) =>
entry.clientKey === panel.clientKey
? { ...entry, mode: mode as SelfRoleMode }
: entry
)
)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODES.map((mode) => (
<SelectItem key={mode} value={mode}>
{t(`modulePages.selfroles.modes.${mode}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.behavior')}</Label>
<Select
value={panel.behavior}
onValueChange={(behavior) =>
setPanels((prev) =>
prev.map((entry) =>
entry.clientKey === panel.clientKey
? { ...entry, behavior: behavior as SelfRoleBehavior }
: entry
)
)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{BEHAVIORS.map((behavior) => (
<SelectItem key={behavior} value={behavior}>
{t(`modulePages.selfroles.behaviors.${behavior}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.channelId')}</Label>
<DiscordChannelSelect
<Label>{t('modulePages.selfroles.roles')}</Label>
<SelfRoleEntriesBuilder
guildId={guildId}
value={panel.channelId}
onChange={(channelId) =>
value={panel.roles}
requireEmoji={panel.mode === 'REACTIONS'}
disabled={panel.saving}
onChange={(roles) =>
setPanels((prev) =>
prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, channelId } : entry))
prev.map((entry) =>
entry.clientKey === panel.clientKey ? { ...entry, roles } : entry
)
)
}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.panelTitle')}</Label>
<Input
value={panel.title}
onChange={(event) =>
setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, title: event.target.value } : entry)))
}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.mode')}</Label>
<Select
value={panel.mode}
onValueChange={(mode) =>
setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, mode: mode as SelfRoleMode } : entry)))
}
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" disabled={panel.saving} onClick={() => handleSave(panel)}>
{panel.saving ? t('common.saving') : t('common.save')}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t('common.remove')}
onClick={() => handleDelete(panel)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODES.map((mode) => (
<SelectItem key={mode} value={mode}>
{t(`modulePages.selfroles.modes.${mode}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.behavior')}</Label>
<Select
value={panel.behavior}
onValueChange={(behavior) =>
setPanels((prev) =>
prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, behavior: behavior as SelfRoleBehavior } : entry))
)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{BEHAVIORS.map((behavior) => (
<SelectItem key={behavior} value={behavior}>
{t(`modulePages.selfroles.behaviors.${behavior}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Trash2 className="size-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.roles')}</Label>
<Textarea
rows={4}
value={panel.rolesText}
onChange={(event) =>
setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, rolesText: event.target.value } : entry)))
}
/>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" disabled={panel.saving} onClick={() => handleSave(panel)}>
{panel.saving ? t('common.saving') : t('common.save')}
</Button>
<Button type="button" variant="ghost" size="icon" aria-label={t('common.remove')} onClick={() => handleDelete(panel)}>
<Trash2 className="size-4" />
</Button>
</div>
</div>
))}
</CardContent>
</Card>
))}
</CardContent>
</Card>
</FieldAnchor>
</div>
);

View File

@@ -0,0 +1,324 @@
'use client';
import type { DiscordEmojiOption } 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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useGuildEmojis } from '@/hooks/use-guild-emojis';
import { cn } from '@/lib/utils';
/** Curated unicode set for dashboard pickers (no third-party emoji pack). */
export const UNICODE_EMOJI_OPTIONS = [
'😀',
'😁',
'😂',
'🤣',
'😊',
'😍',
'🤩',
'😎',
'🤔',
'😴',
'😭',
'😡',
'👍',
'👎',
'👏',
'🙌',
'👋',
'🤝',
'💪',
'🔥',
'⭐',
'✨',
'💯',
'🎉',
'🎊',
'❤️',
'🧡',
'💛',
'💚',
'💙',
'💜',
'🖤',
'🤍',
'✅',
'❌',
'⚠️',
'❓',
'❗',
'🔔',
'📌',
'🎮',
'🎲',
'🎯',
'🏆',
'🥇',
'🎵',
'🎶',
'🎤',
'🎧',
'📷',
'🎬',
'💻',
'🖥️',
'📱',
'⚙️',
'🛠️',
'🔒',
'🔓',
'🔑',
'💬',
'📢',
'📣',
'🛡️',
'⚔️',
'🏹',
'🪄',
'🧙',
'🐉',
'🐱',
'🐶',
'🦊',
'🐻',
'🐼',
'🐸',
'🌈',
'☀️',
'🌙',
'⚡',
'❄️',
'🌊',
'🌍',
'🍕',
'🍔',
'☕',
'🍺',
'🎂'
] as const;
const CUSTOM_EMOJI_RE = /^<(a)?:([A-Za-z0-9_]+):(\d+)>$/;
export function formatCustomEmoji(emoji: Pick<DiscordEmojiOption, 'id' | 'name' | 'animated'>): string {
return emoji.animated ? `<a:${emoji.name}:${emoji.id}>` : `<:${emoji.name}:${emoji.id}>`;
}
export function parseCustomEmoji(value: string): { id: string; name: string; animated: boolean } | null {
const match = value.match(CUSTOM_EMOJI_RE);
if (!match) {
return null;
}
return {
animated: Boolean(match[1]),
name: match[2]!,
id: match[3]!
};
}
function customEmojiUrl(id: string, animated: boolean): string {
return `https://cdn.discordapp.com/emojis/${id}.${animated ? 'gif' : 'png'}?size=64`;
}
function normalizeSearch(value: string): string {
return value.trim().toLocaleLowerCase();
}
type EmojiTab = 'server' | 'unicode';
interface DiscordEmojiSelectProps {
guildId: string;
value?: string;
onChange: (next: string | undefined) => void;
disabled?: boolean;
placeholder?: string;
allowClear?: boolean;
}
function EmojiPreview({ value }: { value: string }) {
const custom = parseCustomEmoji(value);
if (custom) {
return (
// eslint-disable-next-line @next/next/no-img-element -- Discord CDN emoji preview
<img
src={customEmojiUrl(custom.id, custom.animated)}
alt={custom.name}
className="size-5 shrink-0 object-contain"
/>
);
}
return <span className="text-base leading-none">{value}</span>;
}
export function DiscordEmojiSelect({
guildId,
value,
onChange,
disabled = false,
placeholder,
allowClear = true
}: DiscordEmojiSelectProps) {
const t = useTranslations();
const { emojis, loading, error, reload } = useGuildEmojis(guildId);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [tab, setTab] = useState<EmojiTab>('server');
const filteredServer = useMemo(() => {
const needle = normalizeSearch(query);
if (!needle) {
return emojis;
}
return emojis.filter((emoji) => {
const haystack = normalizeSearch(`${emoji.name} ${emoji.id}`);
return haystack.includes(needle);
});
}, [emojis, query]);
const filteredUnicode = useMemo(() => {
const needle = normalizeSearch(query);
if (!needle) {
return [...UNICODE_EMOJI_OPTIONS];
}
return UNICODE_EMOJI_OPTIONS.filter((emoji) => emoji.includes(query.trim()) || emoji === query.trim());
}, [query]);
const selectedCustom = value ? parseCustomEmoji(value) : null;
const selectedServer = selectedCustom
? emojis.find((emoji) => emoji.id === selectedCustom.id)
: undefined;
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) {
setQuery('');
}
}}
>
<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('flex min-w-0 items-center gap-2', !value && 'text-muted-foreground')}>
{value ? (
<>
<EmojiPreview value={value} />
<span className="truncate">
{selectedServer?.name ?? selectedCustom?.name ?? value}
</span>
</>
) : (
(placeholder ?? t('modulePages.selfroles.emojiPlaceholder'))
)}
</span>
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="start">
<div className="flex gap-1 border-b border-border p-2">
<Button
type="button"
size="sm"
variant={tab === 'server' ? 'default' : 'ghost'}
className="flex-1"
onClick={() => setTab('server')}
>
{t('modulePages.selfroles.emojiServer')}
</Button>
<Button
type="button"
size="sm"
variant={tab === 'unicode' ? 'default' : 'ghost'}
className="flex-1"
onClick={() => setTab('unicode')}
>
{t('modulePages.selfroles.emojiUnicode')}
</Button>
</div>
<Command shouldFilter={false}>
<CommandInput
value={query}
onValueChange={setQuery}
placeholder={placeholder ?? t('modulePages.selfroles.emojiPlaceholder')}
/>
<CommandList>
<CommandEmpty>
{tab === 'server' && error ? (
<button type="button" className="underline" onClick={() => reload()}>
{t('common.saveError')}
</button>
) : (
t('common.noResults')
)}
</CommandEmpty>
<CommandGroup>
{allowClear && value ? (
<CommandItem
value="__clear__"
onSelect={() => {
onChange(undefined);
setOpen(false);
}}
>
<X className="size-4 opacity-60" />
<span className="text-muted-foreground">{t('common.clearSelection')}</span>
</CommandItem>
) : null}
{tab === 'server'
? filteredServer.map((emoji) => {
const encoded = formatCustomEmoji(emoji);
return (
<CommandItem
key={emoji.id}
value={`${emoji.name} ${emoji.id}`}
onSelect={() => {
onChange(encoded);
setOpen(false);
}}
>
<Check
className={cn('size-4', value === encoded ? 'opacity-100' : 'opacity-0')}
/>
{/* eslint-disable-next-line @next/next/no-img-element -- Discord CDN emoji preview */}
<img src={emoji.url} alt={emoji.name} className="size-5 object-contain" />
<span className="truncate">:{emoji.name}:</span>
</CommandItem>
);
})
: filteredUnicode.map((emoji) => (
<CommandItem
key={emoji}
value={emoji}
onSelect={() => {
onChange(emoji);
setOpen(false);
}}
>
<Check className={cn('size-4', value === emoji ? 'opacity-100' : 'opacity-0')} />
<span className="text-base leading-none">{emoji}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,186 @@
'use client';
import type { SelfRoleEntry } from '@nexumi/shared';
import { Plus, Trash2 } from 'lucide-react';
import { useTranslations } from '@/components/locale-provider';
import { Button } from '@/components/ui/button';
import { DiscordEmojiSelect } from '@/components/ui/discord-emoji-select';
import { DiscordRoleSelect } from '@/components/ui/discord-role-select';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useGuildRoles } from '@/hooks/use-guild-roles';
export const MAX_SELF_ROLE_ENTRIES = 25;
export interface SelfRoleBuilderRow extends SelfRoleEntry {
clientKey: string;
}
export function rolesToBuilderRows(roles: SelfRoleEntry[]): SelfRoleBuilderRow[] {
return roles.map((role, index) => ({
clientKey: `role-${role.roleId || 'empty'}-${index}`,
roleId: role.roleId,
label: role.label,
emoji: role.emoji,
description: role.description
}));
}
export function emptyBuilderRow(): SelfRoleBuilderRow {
return {
clientKey: `new-role-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
roleId: '',
label: '',
emoji: undefined,
description: undefined
};
}
export function builderRowsToRoles(
rows: SelfRoleBuilderRow[],
requireEmoji = false
): { value: SelfRoleEntry[]; error?: 'incomplete' | 'emojiRequired' | 'tooMany' } {
if (rows.length > MAX_SELF_ROLE_ENTRIES) {
return { value: [], error: 'tooMany' };
}
const value: SelfRoleEntry[] = [];
for (const row of rows) {
const roleId = row.roleId.trim();
const label = row.label.trim();
if (!roleId && !label && !row.emoji && !row.description?.trim()) {
continue;
}
if (!roleId || !label) {
return { value: [], error: 'incomplete' };
}
if (requireEmoji && !row.emoji?.trim()) {
return { value: [], error: 'emojiRequired' };
}
value.push({
roleId,
label,
...(row.emoji?.trim() ? { emoji: row.emoji.trim() } : {}),
...(row.description?.trim() ? { description: row.description.trim() } : {})
});
}
if (value.length === 0) {
return { value: [], error: 'incomplete' };
}
if (value.length > MAX_SELF_ROLE_ENTRIES) {
return { value: [], error: 'tooMany' };
}
return { value };
}
interface SelfRoleEntriesBuilderProps {
guildId: string;
value: SelfRoleBuilderRow[];
onChange: (next: SelfRoleBuilderRow[]) => void;
requireEmoji?: boolean;
disabled?: boolean;
}
export function SelfRoleEntriesBuilder({
guildId,
value,
onChange,
requireEmoji = false,
disabled = false
}: SelfRoleEntriesBuilderProps) {
const t = useTranslations();
const { roles } = useGuildRoles(guildId);
function updateRow(clientKey: string, patch: Partial<SelfRoleEntry>) {
onChange(value.map((row) => (row.clientKey === clientKey ? { ...row, ...patch } : row)));
}
function handleRoleChange(clientKey: string, roleId: string, currentLabel: string) {
const role = roles.find((item) => item.id === roleId);
const shouldPrefillLabel = !currentLabel.trim() && Boolean(role?.name);
updateRow(clientKey, {
roleId,
...(shouldPrefillLabel ? { label: role!.name } : {})
});
}
return (
<div className="space-y-3">
{value.map((row) => (
<div
key={row.clientKey}
className="space-y-3 rounded-md border border-border p-3 sm:grid sm:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_auto_minmax(0,1fr)_auto] sm:items-end sm:gap-2 sm:space-y-0 sm:border-0 sm:p-0"
>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.roleSelect')}</Label>
<DiscordRoleSelect
guildId={guildId}
value={row.roleId}
disabled={disabled}
onChange={(roleId) => handleRoleChange(row.clientKey, roleId, row.label)}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.roleLabel')}</Label>
<Input
value={row.label}
disabled={disabled}
maxLength={80}
onChange={(event) => updateRow(row.clientKey, { label: event.target.value })}
placeholder={t('modulePages.selfroles.roleLabelPlaceholder')}
/>
</div>
<div className="min-w-[7.5rem] space-y-2">
<Label>
{t('modulePages.selfroles.roleEmoji')}
{requireEmoji ? ' *' : ''}
</Label>
<DiscordEmojiSelect
guildId={guildId}
value={row.emoji}
disabled={disabled}
onChange={(emoji) => updateRow(row.clientKey, { emoji })}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.selfroles.roleDescription')}</Label>
<Input
value={row.description ?? ''}
disabled={disabled}
maxLength={100}
onChange={(event) =>
updateRow(row.clientKey, {
description: event.target.value || undefined
})
}
placeholder={t('modulePages.selfroles.roleDescriptionPlaceholder')}
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
disabled={disabled || value.length <= 1}
aria-label={t('common.remove')}
onClick={() => onChange(value.filter((item) => item.clientKey !== row.clientKey))}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled || value.length >= MAX_SELF_ROLE_ENTRIES}
onClick={() => onChange([...value, emptyBuilderRow()])}
>
<Plus className="size-4" />
{t('modulePages.selfroles.addRole')}
</Button>
<p className="text-xs text-muted-foreground">{t('modulePages.selfroles.rolesBuilderHint')}</p>
</div>
);
}