deploy #2
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';
|
'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 { Plus, Trash2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -12,35 +12,20 @@ import { DiscordChannelSelect } from '@/components/ui/discord-channel-select';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
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 MODES: SelfRoleMode[] = ['BUTTONS', 'DROPDOWN', 'REACTIONS'];
|
||||||
const BEHAVIORS: SelfRoleBehavior[] = ['NORMAL', 'UNIQUE', 'VERIFY', 'TEMPORARY'];
|
const BEHAVIORS: SelfRoleBehavior[] = ['NORMAL', 'UNIQUE', 'VERIFY', 'TEMPORARY'];
|
||||||
|
|
||||||
function rolesToText(roles: SelfRoleEntry[]): string {
|
interface LocalPanel extends Omit<SelfRolePanelDashboard, 'roles'> {
|
||||||
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 {
|
|
||||||
clientKey: string;
|
clientKey: string;
|
||||||
rolesText: string;
|
roles: SelfRoleBuilderRow[];
|
||||||
saving?: boolean;
|
saving?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +35,7 @@ const EMPTY_DRAFT = {
|
|||||||
description: '',
|
description: '',
|
||||||
mode: 'BUTTONS' as SelfRoleMode,
|
mode: 'BUTTONS' as SelfRoleMode,
|
||||||
behavior: 'NORMAL' as SelfRoleBehavior,
|
behavior: 'NORMAL' as SelfRoleBehavior,
|
||||||
rolesText: ''
|
roles: [emptyBuilderRow()] as SelfRoleBuilderRow[]
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SelfRolesManagerProps {
|
interface SelfRolesManagerProps {
|
||||||
@@ -58,24 +43,43 @@ interface SelfRolesManagerProps {
|
|||||||
initialPanels: SelfRolePanelDashboard[];
|
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) {
|
export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [panels, setPanels] = useState<LocalPanel[]>(
|
const [panels, setPanels] = useState<LocalPanel[]>(
|
||||||
initialPanels.map((panel, index) => ({
|
initialPanels.map((panel, index) => ({
|
||||||
...panel,
|
...panel,
|
||||||
clientKey: panel.id ?? `existing-${index}`,
|
clientKey: panel.id ?? `existing-${index}`,
|
||||||
rolesText: rolesToText(panel.roles)
|
roles: rolesToBuilderRows(panel.roles)
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const [draft, setDraft] = useState(EMPTY_DRAFT);
|
const [draft, setDraft] = useState(EMPTY_DRAFT);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
const roles = textToRoles(draft.rolesText);
|
if (!draft.channelId.trim() || !draft.title.trim()) {
|
||||||
if (!draft.channelId.trim() || !draft.title.trim() || roles.length === 0) {
|
|
||||||
toast.error(t('modulePages.selfroles.formIncomplete'));
|
toast.error(t('modulePages.selfroles.formIncomplete'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const parsed = builderRowsToRoles(draft.roles, draft.mode === 'REACTIONS');
|
||||||
|
if (parsed.error || !parsed.value.length) {
|
||||||
|
toastBuilderError(t, parsed.error ?? 'incomplete');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/guilds/${guildId}/selfroles`, {
|
const response = await fetch(`/api/guilds/${guildId}/selfroles`, {
|
||||||
@@ -87,7 +91,7 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
|
|||||||
description: draft.description.trim() || null,
|
description: draft.description.trim() || null,
|
||||||
mode: draft.mode,
|
mode: draft.mode,
|
||||||
behavior: draft.behavior,
|
behavior: draft.behavior,
|
||||||
roles
|
roles: parsed.value
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const body = (await response.json().catch(() => null)) as (SelfRolePanelDashboard & { error?: string }) | null;
|
const body = (await response.json().catch(() => null)) as (SelfRolePanelDashboard & { error?: string }) | null;
|
||||||
@@ -96,10 +100,14 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPanels((prev) => [
|
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
|
...prev
|
||||||
]);
|
]);
|
||||||
setDraft(EMPTY_DRAFT);
|
setDraft({ ...EMPTY_DRAFT, roles: [emptyBuilderRow()] });
|
||||||
toast.success(t('common.saveSuccess'));
|
toast.success(t('common.saveSuccess'));
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('common.saveError'));
|
toast.error(t('common.saveError'));
|
||||||
@@ -109,6 +117,14 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave(panel: LocalPanel) {
|
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)));
|
setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, saving: true } : entry)));
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/guilds/${guildId}/selfroles/${panel.id}`, {
|
const response = await fetch(`/api/guilds/${guildId}/selfroles/${panel.id}`, {
|
||||||
@@ -120,7 +136,7 @@ export function SelfRolesManager({ guildId, initialPanels }: SelfRolesManagerPro
|
|||||||
description: panel.description,
|
description: panel.description,
|
||||||
mode: panel.mode,
|
mode: panel.mode,
|
||||||
behavior: panel.behavior,
|
behavior: panel.behavior,
|
||||||
roles: textToRoles(panel.rolesText)
|
roles: parsed.value
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const body = (await response.json().catch(() => null)) as (SelfRolePanelDashboard & { error?: string }) | null;
|
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'));
|
toast.error(body?.error ?? t('common.saveError'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setPanels((prev) =>
|
||||||
|
prev.map((entry) =>
|
||||||
|
entry.clientKey === panel.clientKey
|
||||||
|
? {
|
||||||
|
...entry,
|
||||||
|
...body,
|
||||||
|
roles: rolesToBuilderRows(body.roles),
|
||||||
|
saving: false
|
||||||
|
}
|
||||||
|
: entry
|
||||||
|
)
|
||||||
|
);
|
||||||
toast.success(t('common.saveSuccess'));
|
toast.success(t('common.saveSuccess'));
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('common.saveError'));
|
toast.error(t('common.saveError'));
|
||||||
} finally {
|
} 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<FieldAnchor id="field-selfroles-create">
|
<FieldAnchor id="field-selfroles-create">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('modulePages.selfroles.createTitle')}</CardTitle>
|
<CardTitle>{t('modulePages.selfroles.createTitle')}</CardTitle>
|
||||||
<CardDescription>{t('modulePages.selfroles.createDescription')}</CardDescription>
|
<CardDescription>{t('modulePages.selfroles.createDescription')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<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">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.selfroles.channelId')}</Label>
|
<Label>{t('modulePages.selfroles.description')}</Label>
|
||||||
<DiscordChannelSelect
|
<Input
|
||||||
guildId={guildId}
|
value={draft.description}
|
||||||
value={draft.channelId}
|
onChange={(event) => setDraft((prev) => ({ ...prev, description: event.target.value }))}
|
||||||
onChange={(channelId) => setDraft((prev) => ({ ...prev, channelId }))}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.selfroles.panelTitle')}</Label>
|
<Label>{t('modulePages.selfroles.roles')}</Label>
|
||||||
<Input value={draft.title} onChange={(event) => setDraft((prev) => ({ ...prev, title: event.target.value }))} />
|
<SelfRoleEntriesBuilder
|
||||||
|
guildId={guildId}
|
||||||
|
value={draft.roles}
|
||||||
|
requireEmoji={draft.mode === 'REACTIONS'}
|
||||||
|
onChange={(roles) => setDraft((prev) => ({ ...prev, roles }))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<Button type="button" onClick={handleCreate} disabled={creating}>
|
||||||
<Label>{t('modulePages.selfroles.mode')}</Label>
|
<Plus className="size-4" />
|
||||||
<Select value={draft.mode} onValueChange={(mode) => setDraft((prev) => ({ ...prev, mode: mode as SelfRoleMode }))}>
|
{creating ? t('common.saving') : t('modulePages.selfroles.create')}
|
||||||
<SelectTrigger>
|
</Button>
|
||||||
<SelectValue />
|
</CardContent>
|
||||||
</SelectTrigger>
|
</Card>
|
||||||
<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>
|
|
||||||
</FieldAnchor>
|
</FieldAnchor>
|
||||||
|
|
||||||
<FieldAnchor id="field-selfroles-list">
|
<FieldAnchor id="field-selfroles-list">
|
||||||
|
<Card>
|
||||||
<Card>
|
<CardHeader>
|
||||||
<CardHeader>
|
<CardTitle>{t('modulePages.selfroles.listTitle')}</CardTitle>
|
||||||
<CardTitle>{t('modulePages.selfroles.listTitle')}</CardTitle>
|
<CardDescription>{t('modulePages.selfroles.listDescription')}</CardDescription>
|
||||||
<CardDescription>{t('modulePages.selfroles.listDescription')}</CardDescription>
|
</CardHeader>
|
||||||
</CardHeader>
|
<CardContent className="space-y-4">
|
||||||
<CardContent className="space-y-4">
|
{panels.length === 0 && (
|
||||||
{panels.length === 0 && <p className="text-sm text-muted-foreground">{t('modulePages.selfroles.empty')}</p>}
|
<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">
|
{panels.map((panel) => (
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<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">
|
<div className="space-y-2">
|
||||||
<Label>{t('modulePages.selfroles.channelId')}</Label>
|
<Label>{t('modulePages.selfroles.roles')}</Label>
|
||||||
<DiscordChannelSelect
|
<SelfRoleEntriesBuilder
|
||||||
guildId={guildId}
|
guildId={guildId}
|
||||||
value={panel.channelId}
|
value={panel.roles}
|
||||||
onChange={(channelId) =>
|
requireEmoji={panel.mode === 'REACTIONS'}
|
||||||
|
disabled={panel.saving}
|
||||||
|
onChange={(roles) =>
|
||||||
setPanels((prev) =>
|
setPanels((prev) =>
|
||||||
prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, channelId } : entry))
|
prev.map((entry) =>
|
||||||
|
entry.clientKey === panel.clientKey ? { ...entry, roles } : entry
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Label>{t('modulePages.selfroles.panelTitle')}</Label>
|
<Button type="button" variant="outline" disabled={panel.saving} onClick={() => handleSave(panel)}>
|
||||||
<Input
|
{panel.saving ? t('common.saving') : t('common.save')}
|
||||||
value={panel.title}
|
</Button>
|
||||||
onChange={(event) =>
|
<Button
|
||||||
setPanels((prev) => prev.map((entry) => (entry.clientKey === panel.clientKey ? { ...entry, title: event.target.value } : entry)))
|
type="button"
|
||||||
}
|
variant="ghost"
|
||||||
/>
|
size="icon"
|
||||||
</div>
|
aria-label={t('common.remove')}
|
||||||
<div className="space-y-2">
|
onClick={() => handleDelete(panel)}
|
||||||
<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>
|
<Trash2 className="size-4" />
|
||||||
<SelectValue />
|
</Button>
|
||||||
</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>
|
</div>
|
||||||
<div className="space-y-2">
|
))}
|
||||||
<Label>{t('modulePages.selfroles.roles')}</Label>
|
</CardContent>
|
||||||
<Textarea
|
</Card>
|
||||||
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>
|
|
||||||
</FieldAnchor>
|
</FieldAnchor>
|
||||||
</div>
|
</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 { UpstreamError } from './api-errors';
|
||||||
import { DASHBOARD_CHANNEL_TYPES } from './discord-channel-types';
|
import { DASHBOARD_CHANNEL_TYPES } from './discord-channel-types';
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
@@ -50,6 +50,12 @@ interface DiscordRoleRest {
|
|||||||
managed?: boolean;
|
managed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DiscordEmojiRest {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
animated?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
async function readCache<T>(cacheKey: string): Promise<T | null> {
|
async function readCache<T>(cacheKey: string): Promise<T | null> {
|
||||||
try {
|
try {
|
||||||
if (redis.status === 'wait') {
|
if (redis.status === 'wait') {
|
||||||
@@ -137,4 +143,35 @@ export async function listGuildRolesForDashboard(guildId: string): Promise<Disco
|
|||||||
return options;
|
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';
|
export { CHANNEL_TYPES, channelPrefix, type ChannelKind } from './discord-channel-types';
|
||||||
|
|||||||
@@ -736,8 +736,19 @@
|
|||||||
},
|
},
|
||||||
"description": "Beschreibung",
|
"description": "Beschreibung",
|
||||||
"roles": "Rollen",
|
"roles": "Rollen",
|
||||||
"rolesPlaceholder": "roleId | Label | Emoji | Beschreibung (eine pro Zeile)",
|
"roleSelect": "Rolle",
|
||||||
"rolesHint": "Eine Rolle pro Zeile: roleId | Label | Emoji (optional) | Beschreibung (optional).",
|
"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",
|
"create": "Panel erstellen",
|
||||||
"listTitle": "Self-Role-Panels",
|
"listTitle": "Self-Role-Panels",
|
||||||
"listDescription": "Vorhandene Panels für diesen Server.",
|
"listDescription": "Vorhandene Panels für diesen Server.",
|
||||||
|
|||||||
@@ -736,8 +736,19 @@
|
|||||||
},
|
},
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
"roles": "Roles",
|
"roles": "Roles",
|
||||||
"rolesPlaceholder": "roleId | label | emoji | description (one per line)",
|
"roleSelect": "Role",
|
||||||
"rolesHint": "One role per line: roleId | label | emoji (optional) | description (optional).",
|
"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",
|
"create": "Create panel",
|
||||||
"listTitle": "Self-role panels",
|
"listTitle": "Self-role panels",
|
||||||
"listDescription": "Existing panels for this server.",
|
"listDescription": "Existing panels for this server.",
|
||||||
|
|||||||
@@ -102,7 +102,7 @@
|
|||||||
|
|
||||||
- `DiscordRoleSelect` / `DiscordRoleMultiSelect` + `useGuildRoles`
|
- `DiscordRoleSelect` / `DiscordRoleMultiSelect` + `useGuildRoles`
|
||||||
- Modul-Forms/Manager: Rollen-ID-Texteingaben durch durchsuchbare Role-Auswahl ersetzt (Welcome, Verification, Leveling, Logging, Birthdays, Giveaways, Tags, Tickets, Access Rules, Feeds, Scheduler, Commands)
|
- Modul-Forms/Manager: Rollen-ID-Texteingaben durch durchsuchbare Role-Auswahl ersetzt (Welcome, Verification, Leveling, Logging, Birthdays, Giveaways, Tags, Tickets, Access Rules, Feeds, Scheduler, Commands)
|
||||||
- Selfroles-Textarea (`roleId | label | emoji`) bewusst unverändert
|
- Selfroles: Role-Builder statt Textarea (`roleId | label | emoji`)
|
||||||
|
|
||||||
### Manuell testen
|
### Manuell testen
|
||||||
|
|
||||||
@@ -444,6 +444,22 @@
|
|||||||
- [ ] Panel löschen → Discord-Nachricht entfernt
|
- [ ] Panel löschen → Discord-Nachricht entfernt
|
||||||
- [ ] Alt-Panel ohne Message speichern → Nachricht wird nachgezogen
|
- [ ] Alt-Panel ohne Message speichern → Nachricht wird nachgezogen
|
||||||
|
|
||||||
|
## Post-Phase – Selfroles Role-Builder (Status: implementiert)
|
||||||
|
|
||||||
|
### Abgeschlossen (Code)
|
||||||
|
|
||||||
|
- Textarea `roleId | label | emoji` durch zeilenbasierten Builder ersetzt
|
||||||
|
- `DiscordRoleSelect` + Label/Beschreibung-Felder + `DiscordEmojiSelect` (Server-CDN + Unicode)
|
||||||
|
- API `GET /api/guilds/[guildId]/emojis` inkl. Redis-Cache; Shared `DiscordEmojiOption`
|
||||||
|
- Reaktions-Modus: Emoji je Rolle vor Save Pflicht
|
||||||
|
|
||||||
|
### Manuell testen
|
||||||
|
|
||||||
|
- [ ] Panel mit Role-Picker, Label, Server-Emoji und Unicode speichern → Discord-Nachricht korrekt
|
||||||
|
- [ ] Dropdown: optionale Beschreibung erscheint in den Select-Optionen
|
||||||
|
- [ ] REACTIONS ohne Emoji → Toast, kein Save
|
||||||
|
- [ ] Bestehende Panels mit Custom-Emoji-Strings laden und editieren
|
||||||
|
|
||||||
## Nächster geplanter Schritt
|
## Nächster geplanter Schritt
|
||||||
|
|
||||||
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe).
|
- Deploy auf Traefik-Server manuell verifizieren; danach ggf. `deploy` → `main` mergen (nur auf Freigabe).
|
||||||
|
|||||||
@@ -845,6 +845,14 @@ export const DiscordRoleOptionSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type DiscordRoleOption = z.infer<typeof DiscordRoleOptionSchema>;
|
export type DiscordRoleOption = z.infer<typeof DiscordRoleOptionSchema>;
|
||||||
|
|
||||||
|
export const DiscordEmojiOptionSchema = z.object({
|
||||||
|
id: SnowflakeSchema,
|
||||||
|
name: z.string(),
|
||||||
|
animated: z.boolean(),
|
||||||
|
url: z.string().url()
|
||||||
|
});
|
||||||
|
export type DiscordEmojiOption = z.infer<typeof DiscordEmojiOptionSchema>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Known slash command names, aggregated from every module's
|
* Known slash command names, aggregated from every module's
|
||||||
* `command-definitions.ts` (`SlashCommandBuilder#setName`). Used by the
|
* `command-definitions.ts` (`SlashCommandBuilder#setName`). Used by the
|
||||||
|
|||||||
Reference in New Issue
Block a user