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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@nexumi/shared": "workspace:^",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"@radix-ui/react-avatar": "^1.2.3",
|
||||
|
||||
@@ -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')}
|
||||
<PopoverContent
|
||||
className="w-[22.5rem] overflow-hidden border-border bg-popover p-0 shadow-xl"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
>
|
||||
{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
|
||||
<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}
|
||||
onValueChange={setQuery}
|
||||
placeholder={placeholder ?? t('modulePages.selfroles.emojiPlaceholder')}
|
||||
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"
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{tab === 'server' && error ? (
|
||||
</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>
|
||||
|
||||
<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()}>
|
||||
{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) => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-9 gap-0.5">
|
||||
{filteredServer.map((emoji) => {
|
||||
const encoded = formatCustomEmoji(emoji);
|
||||
return (
|
||||
<CommandItem
|
||||
<button
|
||||
key={emoji.id}
|
||||
value={`${emoji.name} ${emoji.id}`}
|
||||
onSelect={() => {
|
||||
onChange(encoded);
|
||||
setOpen(false);
|
||||
}}
|
||||
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)}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
: filteredUnicode.map((emoji) => (
|
||||
<CommandItem
|
||||
key={emoji}
|
||||
value={emoji}
|
||||
onSelect={() => {
|
||||
onChange(emoji);
|
||||
setOpen(false);
|
||||
}}
|
||||
})}
|
||||
{searchResults.map(({ emoji, native }) => (
|
||||
<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}:`)}
|
||||
>
|
||||
<Check className={cn('size-4', value === emoji ? 'opacity-100' : 'opacity-0')} />
|
||||
<span className="text-base leading-none">{emoji}</span>
|
||||
</CommandItem>
|
||||
{native}
|
||||
</button>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
|
||||
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)",
|
||||
"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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -452,6 +452,7 @@
|
||||
- `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
|
||||
- Emoji-Picker Discord-ähnlich: 9er-Grid, Kategorie-Sidebar, Vollsatz via `@emoji-mart/data`, Hautfarben, Suche
|
||||
|
||||
### Manuell testen
|
||||
|
||||
@@ -459,6 +460,7 @@
|
||||
- [ ] Dropdown: optionale Beschreibung erscheint in den Select-Optionen
|
||||
- [ ] REACTIONS ohne Emoji → Toast, kein Save
|
||||
- [ ] Bestehende Panels mit Custom-Emoji-Strings laden und editieren
|
||||
- [ ] Emoji-Picker: Kategorien scrollen, Suche, Hautfarbe, Server-Grid
|
||||
|
||||
## Nächster geplanter Schritt
|
||||
|
||||
|
||||
Reference in New Issue
Block a user