deploy #2

Merged
smueller merged 24 commits from deploy into main 2026-07-29 07:44:03 +00:00
6 changed files with 481 additions and 195 deletions
Showing only changes of commit dba7bb3cdc - Show all commits

View File

@@ -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",

View File

@@ -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'} <div className="flex items-center gap-2 border-b border-border px-3 py-2">
className="flex-1" <div className="relative min-w-0 flex-1">
onClick={() => setTab('server')} <Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
> <Input
{t('modulePages.selfroles.emojiServer')} value={query}
</Button> onChange={(event) => setQuery(event.target.value)}
<Button placeholder={t('modulePages.selfroles.emojiSearch')}
type="button" className="h-8 border-0 bg-muted/60 pl-8 shadow-none focus-visible:ring-0"
size="sm" />
variant={tab === 'unicode' ? 'default' : 'ghost'} </div>
className="flex-1" <div className="relative">
onClick={() => setTab('unicode')} <Button
> type="button"
{t('modulePages.selfroles.emojiUnicode')} variant="ghost"
</Button> 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>
<Command shouldFilter={false}>
<CommandInput <div className="flex h-72">
value={query} {!isSearching ? (
onValueChange={setQuery} <div className="flex w-10 shrink-0 flex-col items-center gap-0.5 overflow-y-auto border-r border-border py-2">
placeholder={placeholder ?? t('modulePages.selfroles.emojiPlaceholder')} {categoryButtons.map((entry) => (
/> <button
<CommandList> key={entry.id}
<CommandEmpty> type="button"
{tab === 'server' && error ? ( title={entry.label}
<button type="button" className="underline" onClick={() => reload()}> aria-label={entry.label}
{t('common.saveError')} className={cn(
</button> 'flex size-8 items-center justify-center rounded-md text-base transition-colors hover:bg-accent',
) : ( category === entry.id && 'bg-accent'
t('common.noResults') )}
)} onClick={() => {
</CommandEmpty> setCategory(entry.id);
<CommandGroup> gridRef.current?.scrollTo({ top: 0 });
{allowClear && value ? (
<CommandItem
value="__clear__"
onSelect={() => {
onChange(undefined);
setOpen(false);
}} }}
> >
<X className="size-4 opacity-60" /> {entry.icon}
<span className="text-muted-foreground">{t('common.clearSelection')}</span> </button>
</CommandItem> ))}
) : null} </div>
{tab === 'server' ) : null}
? filteredServer.map((emoji) => {
const encoded = formatCustomEmoji(emoji); <div ref={gridRef} className="min-w-0 flex-1 overflow-y-auto p-2">
return ( {isSearching ? (
<CommandItem <>
<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} key={emoji.id}
value={`${emoji.name} ${emoji.id}`} type="button"
onSelect={() => { title={`:${emoji.id}:`}
onChange(encoded); 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 {native}
className={cn('size-4', value === encoded ? 'opacity-100' : 'opacity-0')} </button>
/> ))}
{/* eslint-disable-next-line @next/next/no-img-element -- Discord CDN emoji preview */} </div>
<img src={emoji.url} alt={emoji.name} className="size-5 object-contain" /> )}
<span className="truncate">:{emoji.name}:</span> </>
</CommandItem> ) : 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) => ( </div>
<CommandItem </>
key={emoji} ) : null}
value={emoji} </div>
onSelect={() => { </div>
onChange(emoji);
setOpen(false); <div className="flex min-h-11 items-center gap-2 border-t border-border bg-muted/30 px-3 py-2">
}} {footerPreview ? (
> <>
<Check className={cn('size-4', value === emoji ? 'opacity-100' : 'opacity-0')} /> <EmojiPreview value={footerPreview.native} className="size-6 text-xl" />
<span className="text-base leading-none">{emoji}</span> <span className="truncate text-xs text-muted-foreground">{footerPreview.name}</span>
</CommandItem> </>
))} ) : (
</CommandGroup> <span className="text-xs text-muted-foreground">
</CommandList> {t('modulePages.selfroles.emojiHoverHint')}
</Command> </span>
)}
</div>
</PopoverContent> </PopoverContent>
</Popover> </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)", "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.",

View File

@@ -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.",

View File

@@ -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