Enhance Discord emoji selection with Unicode support and improved UI

- Added support for Unicode emojis in the Discord emoji selector, allowing users to search and select from a comprehensive emoji library.
- Refactored the emoji selection component to include a category sidebar and skin tone options, improving user experience and accessibility.
- Updated localization files to include new emoji-related terms and categories in both English and German.
- Introduced a new dependency on `@emoji-mart/data` for better emoji management and search functionality.
- Enhanced the overall layout and functionality of the emoji picker, ensuring a more intuitive interface for users.
This commit is contained in:
TheOnlyMace
2026-07-25 15:59:53 +02:00
parent ef1803f0ab
commit dba7bb3cdc
6 changed files with 481 additions and 195 deletions

View File

@@ -1,112 +1,24 @@
'use client';
import type { DiscordEmojiOption } from '@nexumi/shared';
import { Check, ChevronsUpDown, X } from 'lucide-react';
import { useMemo, useState } from 'react';
import { ChevronsUpDown, Search, X } from 'lucide-react';
import { useEffect, useMemo, useRef, 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 { Input } from '@/components/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useGuildEmojis } from '@/hooks/use-guild-emojis';
import {
ALL_UNICODE_EMOJIS,
pickEmojiNative,
searchUnicodeEmojis,
SKIN_TONE_PREVIEW,
UNICODE_EMOJI_CATEGORIES,
type UnicodeEmojiCategoryId,
type UnicodeEmojiItem
} from '@/lib/unicode-emoji-data';
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 {
@@ -133,7 +45,7 @@ function normalizeSearch(value: string): string {
return value.trim().toLocaleLowerCase();
}
type EmojiTab = 'server' | 'unicode';
type PickerCategory = 'server' | UnicodeEmojiCategoryId;
interface DiscordEmojiSelectProps {
guildId: string;
@@ -144,7 +56,7 @@ interface DiscordEmojiSelectProps {
allowClear?: boolean;
}
function EmojiPreview({ value }: { value: string }) {
function EmojiPreview({ value, className }: { value: string; className?: string }) {
const custom = parseCustomEmoji(value);
if (custom) {
return (
@@ -152,11 +64,15 @@ function EmojiPreview({ value }: { value: string }) {
<img
src={customEmojiUrl(custom.id, custom.animated)}
alt={custom.name}
className="size-5 shrink-0 object-contain"
className={cn('size-5 shrink-0 object-contain', className)}
/>
);
}
return <span className="text-base leading-none">{value}</span>;
return <span className={cn('text-base leading-none', className)}>{value}</span>;
}
function findUnicodeByNative(native: string): UnicodeEmojiItem | undefined {
return ALL_UNICODE_EMOJIS.find((emoji) => emoji.skins.includes(native));
}
export function DiscordEmojiSelect({
@@ -171,7 +87,20 @@ export function DiscordEmojiSelect({
const { emojis, loading, error, reload } = useGuildEmojis(guildId);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [tab, setTab] = useState<EmojiTab>('server');
const [category, setCategory] = useState<PickerCategory>('people');
const [skinTone, setSkinTone] = useState(0);
const [skinOpen, setSkinOpen] = useState(false);
const [hoverLabel, setHoverLabel] = useState<{ native: string; name: string } | null>(null);
const gridRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) {
return;
}
setHoverLabel(null);
setSkinOpen(false);
setCategory(emojis.length > 0 ? 'server' : 'people');
}, [open, emojis.length]);
const filteredServer = useMemo(() => {
const needle = normalizeSearch(query);
@@ -184,18 +113,65 @@ export function DiscordEmojiSelect({
});
}, [emojis, query]);
const filteredUnicode = useMemo(() => {
const needle = normalizeSearch(query);
if (!needle) {
return [...UNICODE_EMOJI_OPTIONS];
const searchResults = useMemo(() => {
if (!query.trim()) {
return [];
}
return UNICODE_EMOJI_OPTIONS.filter((emoji) => emoji.includes(query.trim()) || emoji === query.trim());
}, [query]);
return searchUnicodeEmojis(query, skinTone);
}, [query, skinTone]);
const activeUnicodeCategory = useMemo(() => {
if (category === 'server') {
return null;
}
return UNICODE_EMOJI_CATEGORIES.find((entry) => entry.id === category) ?? null;
}, [category]);
const selectedCustom = value ? parseCustomEmoji(value) : null;
const selectedServer = selectedCustom
? emojis.find((emoji) => emoji.id === selectedCustom.id)
: undefined;
const selectedUnicode = value && !selectedCustom ? findUnicodeByNative(value) : undefined;
const footerPreview = hoverLabel ?? (value
? {
native: value,
name: selectedServer
? `:${selectedServer.name}:`
: selectedCustom
? `:${selectedCustom.name}:`
: selectedUnicode
? `:${selectedUnicode.id}:`
: value
}
: null);
function selectNative(native: string, name: string) {
onChange(native);
setHoverLabel({ native, name });
setOpen(false);
}
function selectServerEmoji(emoji: DiscordEmojiOption) {
const encoded = formatCustomEmoji(emoji);
onChange(encoded);
setHoverLabel({ native: encoded, name: `:${emoji.name}:` });
setOpen(false);
}
const isSearching = query.trim().length > 0;
const categoryButtons: Array<{ id: PickerCategory; icon: string; label: string }> = [
{
id: 'server',
icon: '🏠',
label: t('modulePages.selfroles.emojiServer')
},
...UNICODE_EMOJI_CATEGORIES.map((entry) => ({
id: entry.id as PickerCategory,
icon: entry.icon,
label: t(`modulePages.selfroles.emojiCategories.${entry.id}`)
}))
];
return (
<Popover
@@ -204,6 +180,7 @@ export function DiscordEmojiSelect({
setOpen(next);
if (!next) {
setQuery('');
setSkinOpen(false);
}
}}
>
@@ -221,7 +198,7 @@ export function DiscordEmojiSelect({
<>
<EmojiPreview value={value} />
<span className="truncate">
{selectedServer?.name ?? selectedCustom?.name ?? value}
{selectedServer?.name ?? selectedCustom?.name ?? selectedUnicode?.id ?? value}
</span>
</>
) : (
@@ -231,93 +208,238 @@ export function DiscordEmojiSelect({
<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>
<PopoverContent
className="w-[22.5rem] overflow-hidden border-border bg-popover p-0 shadow-xl"
align="start"
sideOffset={6}
>
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('modulePages.selfroles.emojiSearch')}
className="h-8 border-0 bg-muted/60 pl-8 shadow-none focus-visible:ring-0"
/>
</div>
<div className="relative">
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
aria-label={t('modulePages.selfroles.emojiSkinTone')}
onClick={() => setSkinOpen((prev) => !prev)}
>
<span className="text-base leading-none">{SKIN_TONE_PREVIEW[skinTone]}</span>
</Button>
{skinOpen ? (
<div className="absolute top-full right-0 z-10 mt-1 flex gap-0.5 rounded-md border border-border bg-popover p-1 shadow-md">
{SKIN_TONE_PREVIEW.map((preview, index) => (
<button
key={preview}
type="button"
className={cn(
'flex size-7 items-center justify-center rounded-sm text-base hover:bg-accent',
skinTone === index && 'bg-accent'
)}
onClick={() => {
setSkinTone(index);
setSkinOpen(false);
}}
>
{preview}
</button>
))}
</div>
) : null}
</div>
{allowClear && value ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
aria-label={t('common.clearSelection')}
onClick={() => {
onChange(undefined);
setHoverLabel(null);
}}
>
<X className="size-4" />
</Button>
) : null}
</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);
<div className="flex h-72">
{!isSearching ? (
<div className="flex w-10 shrink-0 flex-col items-center gap-0.5 overflow-y-auto border-r border-border py-2">
{categoryButtons.map((entry) => (
<button
key={entry.id}
type="button"
title={entry.label}
aria-label={entry.label}
className={cn(
'flex size-8 items-center justify-center rounded-md text-base transition-colors hover:bg-accent',
category === entry.id && 'bg-accent'
)}
onClick={() => {
setCategory(entry.id);
gridRef.current?.scrollTo({ top: 0 });
}}
>
<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
{entry.icon}
</button>
))}
</div>
) : null}
<div ref={gridRef} className="min-w-0 flex-1 overflow-y-auto p-2">
{isSearching ? (
<>
<p className="mb-2 px-1 text-xs font-medium text-muted-foreground">
{t('modulePages.selfroles.emojiSearchResults')}
</p>
{filteredServer.length === 0 && searchResults.length === 0 ? (
<div className="px-1 py-8 text-center text-sm text-muted-foreground">
{error ? (
<button type="button" className="underline" onClick={() => reload()}>
{t('common.saveError')}
</button>
) : (
t('common.noResults')
)}
</div>
) : (
<div className="grid grid-cols-9 gap-0.5">
{filteredServer.map((emoji) => {
const encoded = formatCustomEmoji(emoji);
return (
<button
key={emoji.id}
type="button"
title={`:${emoji.name}:`}
className={cn(
'flex size-8 items-center justify-center rounded-md hover:bg-accent',
value === encoded && 'bg-accent'
)}
onMouseEnter={() =>
setHoverLabel({ native: encoded, name: `:${emoji.name}:` })
}
onClick={() => selectServerEmoji(emoji)}
>
{/* 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" />
</button>
);
})}
{searchResults.map(({ emoji, native }) => (
<button
key={emoji.id}
value={`${emoji.name} ${emoji.id}`}
onSelect={() => {
onChange(encoded);
setOpen(false);
}}
type="button"
title={`:${emoji.id}:`}
className={cn(
'flex size-8 items-center justify-center rounded-md text-lg leading-none hover:bg-accent',
value === native && 'bg-accent'
)}
onMouseEnter={() =>
setHoverLabel({ native, name: `:${emoji.id}:` })
}
onClick={() => selectNative(native, `:${emoji.id}:`)}
>
<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>
{native}
</button>
))}
</div>
)}
</>
) : category === 'server' ? (
<>
<p className="mb-2 px-1 text-xs font-medium text-muted-foreground">
{t('modulePages.selfroles.emojiServer')}
</p>
{error ? (
<div className="px-1 py-8 text-center text-sm text-muted-foreground">
<button type="button" className="underline" onClick={() => reload()}>
{t('common.saveError')}
</button>
</div>
) : filteredServer.length === 0 ? (
<div className="px-1 py-8 text-center text-sm text-muted-foreground">
{t('modulePages.selfroles.emojiServerEmpty')}
</div>
) : (
<div className="grid grid-cols-9 gap-0.5">
{filteredServer.map((emoji) => {
const encoded = formatCustomEmoji(emoji);
return (
<button
key={emoji.id}
type="button"
title={`:${emoji.name}:`}
className={cn(
'flex size-8 items-center justify-center rounded-md hover:bg-accent',
value === encoded && 'bg-accent'
)}
onMouseEnter={() =>
setHoverLabel({ native: encoded, name: `:${emoji.name}:` })
}
onClick={() => selectServerEmoji(emoji)}
>
{/* 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" />
</button>
);
})}
</div>
)}
</>
) : activeUnicodeCategory ? (
<>
<p className="mb-2 px-1 text-xs font-medium text-muted-foreground">
{t(`modulePages.selfroles.emojiCategories.${activeUnicodeCategory.id}`)}
</p>
<div className="grid grid-cols-9 gap-0.5">
{activeUnicodeCategory.emojis.map((emoji) => {
const native = pickEmojiNative(emoji, skinTone);
return (
<button
key={emoji.id}
type="button"
title={`:${emoji.id}:`}
className={cn(
'flex size-8 items-center justify-center rounded-md text-lg leading-none hover:bg-accent',
value === native && 'bg-accent'
)}
onMouseEnter={() =>
setHoverLabel({ native, name: `:${emoji.id}:` })
}
onClick={() => selectNative(native, `:${emoji.id}:`)}
>
{native}
</button>
);
})
: 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>
})}
</div>
</>
) : null}
</div>
</div>
<div className="flex min-h-11 items-center gap-2 border-t border-border bg-muted/30 px-3 py-2">
{footerPreview ? (
<>
<EmojiPreview value={footerPreview.native} className="size-6 text-xl" />
<span className="truncate text-xs text-muted-foreground">{footerPreview.name}</span>
</>
) : (
<span className="text-xs text-muted-foreground">
{t('modulePages.selfroles.emojiHoverHint')}
</span>
)}
</div>
</PopoverContent>
</Popover>
);

View File

@@ -0,0 +1,131 @@
import data from '@emoji-mart/data';
export type UnicodeEmojiCategoryId =
| 'people'
| 'nature'
| 'foods'
| 'activity'
| 'places'
| 'objects'
| 'symbols'
| 'flags';
export interface UnicodeEmojiItem {
id: string;
name: string;
keywords: string[];
skins: string[];
}
export interface UnicodeEmojiCategory {
id: UnicodeEmojiCategoryId;
/** Representative emoji for the sidebar icon */
icon: string;
emojis: UnicodeEmojiItem[];
}
type EmojiMartSkin = { native: string; unified: string };
type EmojiMartEmoji = {
id: string;
name: string;
keywords?: string[];
skins: EmojiMartSkin[];
};
type EmojiMartData = {
categories: Array<{ id: string; emojis: string[] }>;
emojis: Record<string, EmojiMartEmoji>;
};
const mart = data as unknown as EmojiMartData;
const CATEGORY_ICONS: Record<UnicodeEmojiCategoryId, string> = {
people: '😀',
nature: '🐻',
foods: '🍔',
activity: '⚽',
places: '✈️',
objects: '💡',
symbols: '❤️',
flags: '🏳️'
};
const CATEGORY_ORDER: UnicodeEmojiCategoryId[] = [
'people',
'nature',
'foods',
'activity',
'places',
'objects',
'symbols',
'flags'
];
function toItem(emoji: EmojiMartEmoji): UnicodeEmojiItem {
return {
id: emoji.id,
name: emoji.name,
keywords: emoji.keywords ?? [],
skins: emoji.skins.map((skin) => skin.native)
};
}
export const UNICODE_EMOJI_CATEGORIES: UnicodeEmojiCategory[] = CATEGORY_ORDER.map((id) => {
const category = mart.categories.find((entry) => entry.id === id);
const emojis = (category?.emojis ?? [])
.map((emojiId) => mart.emojis[emojiId])
.filter((emoji): emoji is EmojiMartEmoji => Boolean(emoji))
.map(toItem);
return {
id,
icon: CATEGORY_ICONS[id],
emojis
};
});
export const ALL_UNICODE_EMOJIS: UnicodeEmojiItem[] = UNICODE_EMOJI_CATEGORIES.flatMap(
(category) => category.emojis
);
export function pickEmojiNative(emoji: UnicodeEmojiItem, skinToneIndex: number): string {
if (emoji.skins.length <= 1) {
return emoji.skins[0] ?? '';
}
const index = Math.min(Math.max(skinToneIndex, 0), emoji.skins.length - 1);
return emoji.skins[index] ?? emoji.skins[0] ?? '';
}
export function searchUnicodeEmojis(query: string, skinToneIndex: number): Array<{
emoji: UnicodeEmojiItem;
native: string;
categoryId: UnicodeEmojiCategoryId;
}> {
const needle = query.trim().toLocaleLowerCase();
if (!needle) {
return [];
}
const results: Array<{
emoji: UnicodeEmojiItem;
native: string;
categoryId: UnicodeEmojiCategoryId;
}> = [];
for (const category of UNICODE_EMOJI_CATEGORIES) {
for (const emoji of category.emojis) {
const haystack = [emoji.id, emoji.name, ...emoji.keywords].join(' ').toLocaleLowerCase();
if (!haystack.includes(needle) && !emoji.skins.some((skin) => skin.includes(needle))) {
continue;
}
results.push({
emoji,
native: pickEmojiNative(emoji, skinToneIndex),
categoryId: category.id
});
}
}
return results;
}
export const SKIN_TONE_PREVIEW = ['👏', '👏🏻', '👏🏼', '👏🏽', '👏🏾', '👏🏿'] as const;

View File

@@ -748,9 +748,24 @@
"roleDescription": "Beschreibung (optional)",
"roleDescriptionPlaceholder": "Kurztext für Dropdown",
"addRole": "Rolle hinzufügen",
"emojiPlaceholder": "Emoji suchen…",
"emojiPlaceholder": "Emoji wählen…",
"emojiSearch": "Emoji suchen…",
"emojiSearchResults": "Suchergebnisse",
"emojiServer": "Server",
"emojiServerEmpty": "Keine Server-Emojis verfügbar.",
"emojiUnicode": "Standard",
"emojiSkinTone": "Hautfarbe",
"emojiHoverHint": "Emoji auswählen",
"emojiCategories": {
"people": "Smileys & Personen",
"nature": "Tiere & Natur",
"foods": "Essen & Trinken",
"activity": "Aktivitäten",
"places": "Reisen & Orte",
"objects": "Objekte",
"symbols": "Symbole",
"flags": "Flaggen"
},
"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.",

View File

@@ -748,9 +748,24 @@
"roleDescription": "Description (optional)",
"roleDescriptionPlaceholder": "Short text for dropdown",
"addRole": "Add role",
"emojiPlaceholder": "Search emoji…",
"emojiPlaceholder": "Choose emoji…",
"emojiSearch": "Find the perfect emoji",
"emojiSearchResults": "Search results",
"emojiServer": "Server",
"emojiServerEmpty": "No server emojis available.",
"emojiUnicode": "Standard",
"emojiSkinTone": "Skin tone",
"emojiHoverHint": "Pick an emoji",
"emojiCategories": {
"people": "Smileys & People",
"nature": "Animals & Nature",
"foods": "Food & Drink",
"activity": "Activities",
"places": "Travel & Places",
"objects": "Objects",
"symbols": "Symbols",
"flags": "Flags"
},
"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.",