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:
18
apps/webui/src/app/api/guilds/[guildId]/emojis/route.ts
Normal file
18
apps/webui/src/app/api/guilds/[guildId]/emojis/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { requireGuildAccess, toApiErrorResponse } from '@/lib/auth';
|
||||
import { listGuildEmojisForDashboard } from '@/lib/discord-guild-resources';
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{ guildId: string }>;
|
||||
}
|
||||
|
||||
export async function GET(_request: NextRequest, { params }: RouteParams) {
|
||||
const { guildId } = await params;
|
||||
try {
|
||||
await requireGuildAccess(guildId);
|
||||
const emojis = await listGuildEmojisForDashboard(guildId);
|
||||
return NextResponse.json(emojis);
|
||||
} catch (error) {
|
||||
return toApiErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
324
apps/webui/src/components/ui/discord-emoji-select.tsx
Normal file
324
apps/webui/src/components/ui/discord-emoji-select.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
apps/webui/src/components/ui/self-role-entries-builder.tsx
Normal file
186
apps/webui/src/components/ui/self-role-entries-builder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
apps/webui/src/hooks/use-guild-emojis.ts
Normal file
78
apps/webui/src/hooks/use-guild-emojis.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import type { DiscordEmojiOption } from '@nexumi/shared';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
const cache = new Map<string, DiscordEmojiOption[]>();
|
||||
|
||||
export function useGuildEmojis(guildId: string): {
|
||||
emojis: DiscordEmojiOption[];
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
reload: () => void;
|
||||
} {
|
||||
const [emojis, setEmojis] = useState<DiscordEmojiOption[]>(() => cache.get(guildId) ?? []);
|
||||
const [loading, setLoading] = useState(!cache.has(guildId));
|
||||
const [error, setError] = useState(false);
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
cache.delete(guildId);
|
||||
setReloadToken((token) => token + 1);
|
||||
}, [guildId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const cached = reloadToken === 0 ? cache.get(guildId) : undefined;
|
||||
if (cached) {
|
||||
setEmojis(cached);
|
||||
setLoading(false);
|
||||
setError(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
void fetch(`/api/guilds/${guildId}/emojis`)
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Emojis request failed with ${response.status}`);
|
||||
}
|
||||
const data = (await response.json()) as DiscordEmojiOption[];
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('Emojis response was not an array');
|
||||
}
|
||||
return data;
|
||||
})
|
||||
.then((data) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (data.length > 0) {
|
||||
cache.set(guildId, data);
|
||||
} else {
|
||||
cache.delete(guildId);
|
||||
}
|
||||
setEmojis(data);
|
||||
setError(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
cache.delete(guildId);
|
||||
setEmojis([]);
|
||||
setError(true);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [guildId, reloadToken]);
|
||||
|
||||
return { emojis, loading, error, reload };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DiscordChannelOption, DiscordRoleOption } from '@nexumi/shared';
|
||||
import type { DiscordChannelOption, DiscordEmojiOption, DiscordRoleOption } from '@nexumi/shared';
|
||||
import { UpstreamError } from './api-errors';
|
||||
import { DASHBOARD_CHANNEL_TYPES } from './discord-channel-types';
|
||||
import { env } from './env';
|
||||
@@ -50,6 +50,12 @@ interface DiscordRoleRest {
|
||||
managed?: boolean;
|
||||
}
|
||||
|
||||
interface DiscordEmojiRest {
|
||||
id: string;
|
||||
name: string;
|
||||
animated?: boolean;
|
||||
}
|
||||
|
||||
async function readCache<T>(cacheKey: string): Promise<T | null> {
|
||||
try {
|
||||
if (redis.status === 'wait') {
|
||||
@@ -137,4 +143,35 @@ export async function listGuildRolesForDashboard(guildId: string): Promise<Disco
|
||||
return options;
|
||||
}
|
||||
|
||||
export async function listGuildEmojisForDashboard(guildId: string): Promise<DiscordEmojiOption[]> {
|
||||
const cacheKey = `dashboard:guild:${guildId}:emojis:v1`;
|
||||
const cached = await readCache<DiscordEmojiOption[]>(cacheKey);
|
||||
if (cached && cached.length > 0) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const emojis = await discordBotFetch<DiscordEmojiRest[]>(`/guilds/${guildId}/emojis`);
|
||||
if (!Array.isArray(emojis)) {
|
||||
throw new Error(`Discord API returned non-array emojis for guild ${guildId}`);
|
||||
}
|
||||
|
||||
const options = emojis
|
||||
.filter((emoji) => Boolean(emoji.id) && Boolean(emoji.name))
|
||||
.map((emoji) => {
|
||||
const animated = Boolean(emoji.animated);
|
||||
return {
|
||||
id: emoji.id,
|
||||
name: emoji.name,
|
||||
animated,
|
||||
url: `https://cdn.discordapp.com/emojis/${emoji.id}.${animated ? 'gif' : 'png'}?size=64`
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
if (options.length > 0) {
|
||||
await writeCache(cacheKey, options);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
export { CHANNEL_TYPES, channelPrefix, type ChannelKind } from './discord-channel-types';
|
||||
|
||||
@@ -736,8 +736,19 @@
|
||||
},
|
||||
"description": "Beschreibung",
|
||||
"roles": "Rollen",
|
||||
"rolesPlaceholder": "roleId | Label | Emoji | Beschreibung (eine pro Zeile)",
|
||||
"rolesHint": "Eine Rolle pro Zeile: roleId | Label | Emoji (optional) | Beschreibung (optional).",
|
||||
"roleSelect": "Rolle",
|
||||
"roleLabel": "Label",
|
||||
"roleLabelPlaceholder": "Anzeigename auf dem Button",
|
||||
"roleEmoji": "Emoji",
|
||||
"roleDescription": "Beschreibung (optional)",
|
||||
"roleDescriptionPlaceholder": "Kurztext für Dropdown",
|
||||
"addRole": "Rolle hinzufügen",
|
||||
"emojiPlaceholder": "Emoji suchen…",
|
||||
"emojiServer": "Server",
|
||||
"emojiUnicode": "Standard",
|
||||
"emojiRequired": "Im Reaktions-Modus braucht jede Rolle ein Emoji.",
|
||||
"tooManyRoles": "Maximal 25 Rollen pro Panel.",
|
||||
"rolesBuilderHint": "Wähle Rolle, Label und optional Emoji/Beschreibung. Im Reaktions-Modus ist ein Emoji Pflicht.",
|
||||
"create": "Panel erstellen",
|
||||
"listTitle": "Self-Role-Panels",
|
||||
"listDescription": "Vorhandene Panels für diesen Server.",
|
||||
|
||||
@@ -736,8 +736,19 @@
|
||||
},
|
||||
"description": "Description",
|
||||
"roles": "Roles",
|
||||
"rolesPlaceholder": "roleId | label | emoji | description (one per line)",
|
||||
"rolesHint": "One role per line: roleId | label | emoji (optional) | description (optional).",
|
||||
"roleSelect": "Role",
|
||||
"roleLabel": "Label",
|
||||
"roleLabelPlaceholder": "Button display name",
|
||||
"roleEmoji": "Emoji",
|
||||
"roleDescription": "Description (optional)",
|
||||
"roleDescriptionPlaceholder": "Short text for dropdown",
|
||||
"addRole": "Add role",
|
||||
"emojiPlaceholder": "Search emoji…",
|
||||
"emojiServer": "Server",
|
||||
"emojiUnicode": "Standard",
|
||||
"emojiRequired": "Reaction mode requires an emoji for every role.",
|
||||
"tooManyRoles": "Maximum 25 roles per panel.",
|
||||
"rolesBuilderHint": "Pick a role, label, and optional emoji/description. Reaction mode requires an emoji.",
|
||||
"create": "Create panel",
|
||||
"listTitle": "Self-role panels",
|
||||
"listDescription": "Existing panels for this server.",
|
||||
|
||||
Reference in New Issue
Block a user