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:
@@ -12,6 +12,7 @@
|
|||||||
"prisma:generate": "prisma generate --schema=../bot/prisma/schema.prisma"
|
"prisma:generate": "prisma generate --schema=../bot/prisma/schema.prisma"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@nexumi/shared": "workspace:^",
|
"@nexumi/shared": "workspace:^",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"@radix-ui/react-avatar": "^1.2.3",
|
"@radix-ui/react-avatar": "^1.2.3",
|
||||||
|
|||||||
@@ -1,112 +1,24 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { DiscordEmojiOption } from '@nexumi/shared';
|
import type { DiscordEmojiOption } from '@nexumi/shared';
|
||||||
import { Check, ChevronsUpDown, X } from 'lucide-react';
|
import { ChevronsUpDown, Search, X } from 'lucide-react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import { Input } from '@/components/ui/input';
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList
|
|
||||||
} from '@/components/ui/command';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { useGuildEmojis } from '@/hooks/use-guild-emojis';
|
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';
|
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+)>$/;
|
const CUSTOM_EMOJI_RE = /^<(a)?:([A-Za-z0-9_]+):(\d+)>$/;
|
||||||
|
|
||||||
export function formatCustomEmoji(emoji: Pick<DiscordEmojiOption, 'id' | 'name' | 'animated'>): string {
|
export function formatCustomEmoji(emoji: Pick<DiscordEmojiOption, 'id' | 'name' | 'animated'>): string {
|
||||||
@@ -133,7 +45,7 @@ function normalizeSearch(value: string): string {
|
|||||||
return value.trim().toLocaleLowerCase();
|
return value.trim().toLocaleLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
type EmojiTab = 'server' | 'unicode';
|
type PickerCategory = 'server' | UnicodeEmojiCategoryId;
|
||||||
|
|
||||||
interface DiscordEmojiSelectProps {
|
interface DiscordEmojiSelectProps {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
@@ -144,7 +56,7 @@ interface DiscordEmojiSelectProps {
|
|||||||
allowClear?: boolean;
|
allowClear?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EmojiPreview({ value }: { value: string }) {
|
function EmojiPreview({ value, className }: { value: string; className?: string }) {
|
||||||
const custom = parseCustomEmoji(value);
|
const custom = parseCustomEmoji(value);
|
||||||
if (custom) {
|
if (custom) {
|
||||||
return (
|
return (
|
||||||
@@ -152,11 +64,15 @@ function EmojiPreview({ value }: { value: string }) {
|
|||||||
<img
|
<img
|
||||||
src={customEmojiUrl(custom.id, custom.animated)}
|
src={customEmojiUrl(custom.id, custom.animated)}
|
||||||
alt={custom.name}
|
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({
|
export function DiscordEmojiSelect({
|
||||||
@@ -171,7 +87,20 @@ export function DiscordEmojiSelect({
|
|||||||
const { emojis, loading, error, reload } = useGuildEmojis(guildId);
|
const { emojis, loading, error, reload } = useGuildEmojis(guildId);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState('');
|
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 filteredServer = useMemo(() => {
|
||||||
const needle = normalizeSearch(query);
|
const needle = normalizeSearch(query);
|
||||||
@@ -184,18 +113,65 @@ export function DiscordEmojiSelect({
|
|||||||
});
|
});
|
||||||
}, [emojis, query]);
|
}, [emojis, query]);
|
||||||
|
|
||||||
const filteredUnicode = useMemo(() => {
|
const searchResults = useMemo(() => {
|
||||||
const needle = normalizeSearch(query);
|
if (!query.trim()) {
|
||||||
if (!needle) {
|
return [];
|
||||||
return [...UNICODE_EMOJI_OPTIONS];
|
|
||||||
}
|
}
|
||||||
return UNICODE_EMOJI_OPTIONS.filter((emoji) => emoji.includes(query.trim()) || emoji === query.trim());
|
return searchUnicodeEmojis(query, skinTone);
|
||||||
}, [query]);
|
}, [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 selectedCustom = value ? parseCustomEmoji(value) : null;
|
||||||
const selectedServer = selectedCustom
|
const selectedServer = selectedCustom
|
||||||
? emojis.find((emoji) => emoji.id === selectedCustom.id)
|
? emojis.find((emoji) => emoji.id === selectedCustom.id)
|
||||||
: undefined;
|
: 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 (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
@@ -204,6 +180,7 @@ export function DiscordEmojiSelect({
|
|||||||
setOpen(next);
|
setOpen(next);
|
||||||
if (!next) {
|
if (!next) {
|
||||||
setQuery('');
|
setQuery('');
|
||||||
|
setSkinOpen(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -221,7 +198,7 @@ export function DiscordEmojiSelect({
|
|||||||
<>
|
<>
|
||||||
<EmojiPreview value={value} />
|
<EmojiPreview value={value} />
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{selectedServer?.name ?? selectedCustom?.name ?? value}
|
{selectedServer?.name ?? selectedCustom?.name ?? selectedUnicode?.id ?? value}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -231,93 +208,238 @@ export function DiscordEmojiSelect({
|
|||||||
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-80 p-0" align="start">
|
<PopoverContent
|
||||||
<div className="flex gap-1 border-b border-border p-2">
|
className="w-[22.5rem] overflow-hidden border-border bg-popover p-0 shadow-xl"
|
||||||
<Button
|
align="start"
|
||||||
type="button"
|
sideOffset={6}
|
||||||
size="sm"
|
|
||||||
variant={tab === 'server' ? 'default' : 'ghost'}
|
|
||||||
className="flex-1"
|
|
||||||
onClick={() => setTab('server')}
|
|
||||||
>
|
>
|
||||||
{t('modulePages.selfroles.emojiServer')}
|
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||||
</Button>
|
<div className="relative min-w-0 flex-1">
|
||||||
<Button
|
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
type="button"
|
<Input
|
||||||
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}
|
value={query}
|
||||||
onValueChange={setQuery}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
placeholder={placeholder ?? t('modulePages.selfroles.emojiPlaceholder')}
|
placeholder={t('modulePages.selfroles.emojiSearch')}
|
||||||
|
className="h-8 border-0 bg-muted/60 pl-8 shadow-none focus-visible:ring-0"
|
||||||
/>
|
/>
|
||||||
<CommandList>
|
</div>
|
||||||
<CommandEmpty>
|
<div className="relative">
|
||||||
{tab === 'server' && error ? (
|
<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>
|
||||||
|
|
||||||
|
<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 });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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()}>
|
<button type="button" className="underline" onClick={() => reload()}>
|
||||||
{t('common.saveError')}
|
{t('common.saveError')}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
t('common.noResults')
|
t('common.noResults')
|
||||||
)}
|
)}
|
||||||
</CommandEmpty>
|
</div>
|
||||||
<CommandGroup>
|
) : (
|
||||||
{allowClear && value ? (
|
<div className="grid grid-cols-9 gap-0.5">
|
||||||
<CommandItem
|
{filteredServer.map((emoji) => {
|
||||||
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);
|
const encoded = formatCustomEmoji(emoji);
|
||||||
return (
|
return (
|
||||||
<CommandItem
|
<button
|
||||||
key={emoji.id}
|
key={emoji.id}
|
||||||
value={`${emoji.name} ${emoji.id}`}
|
type="button"
|
||||||
onSelect={() => {
|
title={`:${emoji.name}:`}
|
||||||
onChange(encoded);
|
className={cn(
|
||||||
setOpen(false);
|
'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)}
|
||||||
>
|
>
|
||||||
<Check
|
|
||||||
className={cn('size-4', value === encoded ? 'opacity-100' : 'opacity-0')}
|
|
||||||
/>
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element -- Discord CDN emoji preview */}
|
{/* 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" />
|
<img src={emoji.url} alt={emoji.name} className="size-5 object-contain" />
|
||||||
<span className="truncate">:{emoji.name}:</span>
|
</button>
|
||||||
</CommandItem>
|
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
: filteredUnicode.map((emoji) => (
|
{searchResults.map(({ emoji, native }) => (
|
||||||
<CommandItem
|
<button
|
||||||
key={emoji}
|
key={emoji.id}
|
||||||
value={emoji}
|
type="button"
|
||||||
onSelect={() => {
|
title={`:${emoji.id}:`}
|
||||||
onChange(emoji);
|
className={cn(
|
||||||
setOpen(false);
|
'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 === emoji ? 'opacity-100' : 'opacity-0')} />
|
{native}
|
||||||
<span className="text-base leading-none">{emoji}</span>
|
</button>
|
||||||
</CommandItem>
|
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</div>
|
||||||
</CommandList>
|
)}
|
||||||
</Command>
|
</>
|
||||||
|
) : 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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</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>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
|
|||||||
131
apps/webui/src/lib/unicode-emoji-data.ts
Normal file
131
apps/webui/src/lib/unicode-emoji-data.ts
Normal 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;
|
||||||
@@ -748,9 +748,24 @@
|
|||||||
"roleDescription": "Beschreibung (optional)",
|
"roleDescription": "Beschreibung (optional)",
|
||||||
"roleDescriptionPlaceholder": "Kurztext für Dropdown",
|
"roleDescriptionPlaceholder": "Kurztext für Dropdown",
|
||||||
"addRole": "Rolle hinzufügen",
|
"addRole": "Rolle hinzufügen",
|
||||||
"emojiPlaceholder": "Emoji suchen…",
|
"emojiPlaceholder": "Emoji wählen…",
|
||||||
|
"emojiSearch": "Emoji suchen…",
|
||||||
|
"emojiSearchResults": "Suchergebnisse",
|
||||||
"emojiServer": "Server",
|
"emojiServer": "Server",
|
||||||
|
"emojiServerEmpty": "Keine Server-Emojis verfügbar.",
|
||||||
"emojiUnicode": "Standard",
|
"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.",
|
"emojiRequired": "Im Reaktions-Modus braucht jede Rolle ein Emoji.",
|
||||||
"tooManyRoles": "Maximal 25 Rollen pro Panel.",
|
"tooManyRoles": "Maximal 25 Rollen pro Panel.",
|
||||||
"rolesBuilderHint": "Wähle Rolle, Label und optional Emoji/Beschreibung. Im Reaktions-Modus ist ein Emoji Pflicht.",
|
"rolesBuilderHint": "Wähle Rolle, Label und optional Emoji/Beschreibung. Im Reaktions-Modus ist ein Emoji Pflicht.",
|
||||||
|
|||||||
@@ -748,9 +748,24 @@
|
|||||||
"roleDescription": "Description (optional)",
|
"roleDescription": "Description (optional)",
|
||||||
"roleDescriptionPlaceholder": "Short text for dropdown",
|
"roleDescriptionPlaceholder": "Short text for dropdown",
|
||||||
"addRole": "Add role",
|
"addRole": "Add role",
|
||||||
"emojiPlaceholder": "Search emoji…",
|
"emojiPlaceholder": "Choose emoji…",
|
||||||
|
"emojiSearch": "Find the perfect emoji",
|
||||||
|
"emojiSearchResults": "Search results",
|
||||||
"emojiServer": "Server",
|
"emojiServer": "Server",
|
||||||
|
"emojiServerEmpty": "No server emojis available.",
|
||||||
"emojiUnicode": "Standard",
|
"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.",
|
"emojiRequired": "Reaction mode requires an emoji for every role.",
|
||||||
"tooManyRoles": "Maximum 25 roles per panel.",
|
"tooManyRoles": "Maximum 25 roles per panel.",
|
||||||
"rolesBuilderHint": "Pick a role, label, and optional emoji/description. Reaction mode requires an emoji.",
|
"rolesBuilderHint": "Pick a role, label, and optional emoji/description. Reaction mode requires an emoji.",
|
||||||
|
|||||||
@@ -452,6 +452,7 @@
|
|||||||
- `DiscordRoleSelect` + Label/Beschreibung-Felder + `DiscordEmojiSelect` (Server-CDN + Unicode)
|
- `DiscordRoleSelect` + Label/Beschreibung-Felder + `DiscordEmojiSelect` (Server-CDN + Unicode)
|
||||||
- API `GET /api/guilds/[guildId]/emojis` inkl. Redis-Cache; Shared `DiscordEmojiOption`
|
- API `GET /api/guilds/[guildId]/emojis` inkl. Redis-Cache; Shared `DiscordEmojiOption`
|
||||||
- Reaktions-Modus: Emoji je Rolle vor Save Pflicht
|
- Reaktions-Modus: Emoji je Rolle vor Save Pflicht
|
||||||
|
- Emoji-Picker Discord-ähnlich: 9er-Grid, Kategorie-Sidebar, Vollsatz via `@emoji-mart/data`, Hautfarben, Suche
|
||||||
|
|
||||||
### Manuell testen
|
### Manuell testen
|
||||||
|
|
||||||
@@ -459,6 +460,7 @@
|
|||||||
- [ ] Dropdown: optionale Beschreibung erscheint in den Select-Optionen
|
- [ ] Dropdown: optionale Beschreibung erscheint in den Select-Optionen
|
||||||
- [ ] REACTIONS ohne Emoji → Toast, kein Save
|
- [ ] REACTIONS ohne Emoji → Toast, kein Save
|
||||||
- [ ] Bestehende Panels mit Custom-Emoji-Strings laden und editieren
|
- [ ] Bestehende Panels mit Custom-Emoji-Strings laden und editieren
|
||||||
|
- [ ] Emoji-Picker: Kategorien scrollen, Suche, Hautfarbe, Server-Grid
|
||||||
|
|
||||||
## Nächster geplanter Schritt
|
## Nächster geplanter Schritt
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user