deploy #2

Merged
smueller merged 24 commits from deploy into main 2026-07-29 07:44:03 +00:00
10 changed files with 955 additions and 192 deletions
Showing only changes of commit 67ac16d36b - Show all commits

View 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);
}
}

View File

@@ -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>
); );

View File

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

View File

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

View 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 };
}

View File

@@ -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';

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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).

View File

@@ -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