diff --git a/apps/webui/package.json b/apps/webui/package.json index 910df60..d5b3b86 100644 --- a/apps/webui/package.json +++ b/apps/webui/package.json @@ -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", diff --git a/apps/webui/src/components/ui/discord-emoji-select.tsx b/apps/webui/src/components/ui/discord-emoji-select.tsx index 1d477ab..89db713 100644 --- a/apps/webui/src/components/ui/discord-emoji-select.tsx +++ b/apps/webui/src/components/ui/discord-emoji-select.tsx @@ -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): 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 }) { {custom.name} ); } - return {value}; + return {value}; +} + +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('server'); + const [category, setCategory] = useState('people'); + const [skinTone, setSkinTone] = useState(0); + const [skinOpen, setSkinOpen] = useState(false); + const [hoverLabel, setHoverLabel] = useState<{ native: string; name: string } | null>(null); + const gridRef = useRef(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 ( @@ -221,7 +198,7 @@ export function DiscordEmojiSelect({ <> - {selectedServer?.name ?? selectedCustom?.name ?? value} + {selectedServer?.name ?? selectedCustom?.name ?? selectedUnicode?.id ?? value} ) : ( @@ -231,93 +208,238 @@ export function DiscordEmojiSelect({ - -
- - + +
+
+ + 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" + /> +
+
+ + {skinOpen ? ( +
+ {SKIN_TONE_PREVIEW.map((preview, index) => ( + + ))} +
+ ) : null} +
+ {allowClear && value ? ( + + ) : null}
- - - - - {tab === 'server' && error ? ( - - ) : ( - t('common.noResults') - )} - - - {allowClear && value ? ( - { - onChange(undefined); - setOpen(false); + +
+ {!isSearching ? ( +
+ {categoryButtons.map((entry) => ( +
+ ) : null} + +
+ {isSearching ? ( + <> +

+ {t('modulePages.selfroles.emojiSearchResults')} +

+ {filteredServer.length === 0 && searchResults.length === 0 ? ( +
+ {error ? ( + + ) : ( + t('common.noResults') + )} +
+ ) : ( +
+ {filteredServer.map((emoji) => { + const encoded = formatCustomEmoji(emoji); + return ( + + ); + })} + {searchResults.map(({ emoji, native }) => ( + + ))} +
+ )} + + ) : category === 'server' ? ( + <> +

+ {t('modulePages.selfroles.emojiServer')} +

+ {error ? ( +
+ +
+ ) : filteredServer.length === 0 ? ( +
+ {t('modulePages.selfroles.emojiServerEmpty')} +
+ ) : ( +
+ {filteredServer.map((emoji) => { + const encoded = formatCustomEmoji(emoji); + return ( + + ); + })} +
+ )} + + ) : activeUnicodeCategory ? ( + <> +

+ {t(`modulePages.selfroles.emojiCategories.${activeUnicodeCategory.id}`)} +

+
+ {activeUnicodeCategory.emojis.map((emoji) => { + const native = pickEmojiNative(emoji, skinTone); + return ( + ); - }) - : filteredUnicode.map((emoji) => ( - { - onChange(emoji); - setOpen(false); - }} - > - - {emoji} - - ))} - - - + })} +
+ + ) : null} +
+
+ +
+ {footerPreview ? ( + <> + + {footerPreview.name} + + ) : ( + + {t('modulePages.selfroles.emojiHoverHint')} + + )} +
); diff --git a/apps/webui/src/lib/unicode-emoji-data.ts b/apps/webui/src/lib/unicode-emoji-data.ts new file mode 100644 index 0000000..c43af13 --- /dev/null +++ b/apps/webui/src/lib/unicode-emoji-data.ts @@ -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; +}; + +const mart = data as unknown as EmojiMartData; + +const CATEGORY_ICONS: Record = { + 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; diff --git a/apps/webui/src/messages/de.json b/apps/webui/src/messages/de.json index d285566..a730acb 100644 --- a/apps/webui/src/messages/de.json +++ b/apps/webui/src/messages/de.json @@ -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.", diff --git a/apps/webui/src/messages/en.json b/apps/webui/src/messages/en.json index a0830ce..af9ea5f 100644 --- a/apps/webui/src/messages/en.json +++ b/apps/webui/src/messages/en.json @@ -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.", diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index 4030de9..0e7556a 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -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