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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user