From 02d8e5c2dd693504c1780a38f16c673ebc7ed2ba Mon Sep 17 00:00:00 2001 From: Life Date: Sun, 22 Mar 2026 18:43:42 +0100 Subject: [PATCH] feat: custom chat prefix system with effects, gradient colors, emoji icons and per-letter coloring --- src/api/utils/PrefixUtils.ts | 32 ++ .../layout/CatalogLayoutCustomPrefixView.tsx | 299 +++++++++++------- .../views/prefix/InventoryPrefixView.tsx | 28 +- .../widgets/chat/ChatWidgetMessageView.tsx | 26 +- src/css/index.css | 21 ++ src/layout/PrefixPreview.tsx | 48 +++ src/layout/index.ts | 1 + 7 files changed, 294 insertions(+), 161 deletions(-) create mode 100644 src/layout/PrefixPreview.tsx diff --git a/src/api/utils/PrefixUtils.ts b/src/api/utils/PrefixUtils.ts index 5da5133..d010084 100644 --- a/src/api/utils/PrefixUtils.ts +++ b/src/api/utils/PrefixUtils.ts @@ -6,6 +6,9 @@ export const PRESET_PREFIX_EFFECTS: { id: string; label: string; icon: string }[ { id: 'outline', label: 'Outline', icon: '🔲' }, { id: 'pulse', label: 'Pulse', icon: '💫' }, { id: 'bold-glow', label: 'Neon', icon: '💡' }, + { id: 'rainbow', label: 'Rainbow', icon: '🌈' }, + { id: 'shake', label: 'Shake', icon: '📳' }, + { id: 'wave', label: 'Wave', icon: '🌊' }, ]; export const parsePrefixColors = (text: string, colorStr: string): string[] => @@ -40,11 +43,40 @@ export const getPrefixEffectStyle = (effect: string, color?: string): Record +{ + if(steps <= 1) return [ startColor ]; + + const parseHex = (hex: string) => + { + const h = hex.replace('#', ''); + return { r: parseInt(h.substring(0, 2), 16), g: parseInt(h.substring(2, 4), 16), b: parseInt(h.substring(4, 6), 16) }; + }; + + const start = parseHex(startColor); + const end = parseHex(endColor); + + return Array.from({ length: steps }, (_, i) => + { + const t = i / (steps - 1); + const r = Math.round(start.r + (end.r - start.r) * t); + const g = Math.round(start.g + (end.g - start.g) * t); + const b = Math.round(start.b + (end.b - start.b) * t); + return `#${ r.toString(16).padStart(2, '0') }${ g.toString(16).padStart(2, '0') }${ b.toString(16).padStart(2, '0') }`; + }); +}; + export const PREFIX_EFFECT_KEYFRAMES = ` @keyframes prefix-pulse { 0%, 100% { opacity: 1; } diff --git a/src/components/catalog/views/page/layout/CatalogLayoutCustomPrefixView.tsx b/src/components/catalog/views/page/layout/CatalogLayoutCustomPrefixView.tsx index 0b7f904..c984bf6 100644 --- a/src/components/catalog/views/page/layout/CatalogLayoutCustomPrefixView.tsx +++ b/src/components/catalog/views/page/layout/CatalogLayoutCustomPrefixView.tsx @@ -1,7 +1,7 @@ import { PurchasePrefixComposer } from '@nitrots/nitro-renderer'; -import { createPortal } from 'react-dom'; import { FC, useEffect, useMemo, useRef, useState } from 'react'; -import { SendMessageComposer, PRESET_PREFIX_EFFECTS, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../../api'; +import { LocalizeText, SendMessageComposer, PRESET_PREFIX_EFFECTS, generateGradientColors } from '../../../../../api'; +import { PrefixPreview } from '../../../../../layout'; import { CatalogLayoutProps } from './CatalogLayout.types'; import data from '@emoji-mart/data'; import Picker from '@emoji-mart/react'; @@ -23,11 +23,13 @@ export const CatalogLayoutCustomPrefixView: FC = props => }, [ page, hideNavigation ]); const [ prefixText, setPrefixText ] = useState(''); - const [ colorMode, setColorMode ] = useState<'single' | 'perLetter'>('single'); + const [ colorMode, setColorMode ] = useState<'single' | 'perLetter' | 'gradient'>('single'); const [ singleColor, setSingleColor ] = useState('#FFFFFF'); const [ letterColors, setLetterColors ] = useState>({}); const [ selectedLetterIndex, setSelectedLetterIndex ] = useState(null); const [ customColorInput, setCustomColorInput ] = useState('#FFFFFF'); + const [ gradientStart, setGradientStart ] = useState('#FF0000'); + const [ gradientEnd, setGradientEnd ] = useState('#0066FF'); const [ selectedIcon, setSelectedIcon ] = useState(''); const [ showIconPicker, setShowIconPicker ] = useState(false); const [ selectedEffect, setSelectedEffect ] = useState(''); @@ -63,15 +65,16 @@ export const CatalogLayoutCustomPrefixView: FC = props => { if(colorMode === 'single') return singleColor; + if(colorMode === 'gradient') + { + const steps = Math.max(prefixText.length, 2); + return generateGradientColors(gradientStart, gradientEnd, steps).join(','); + } + if(!prefixText.length) return singleColor; return [ ...prefixText ].map((_, i) => letterColors[i] || singleColor).join(','); - }, [ colorMode, singleColor, letterColors, prefixText ]); - - const previewColors = useMemo(() => - { - return parsePrefixColors(prefixText || '...', colorString || '#FFFFFF'); - }, [ prefixText, colorString ]); + }, [ colorMode, singleColor, letterColors, prefixText, gradientStart, gradientEnd ]); const isValid = useMemo(() => { @@ -79,9 +82,12 @@ export const CatalogLayoutCustomPrefixView: FC = props => if(colorMode === 'single') return /^#[0-9A-Fa-f]{6}$/.test(singleColor); + if(colorMode === 'gradient') + return /^#[0-9A-Fa-f]{6}$/.test(gradientStart) && /^#[0-9A-Fa-f]{6}$/.test(gradientEnd); + const colors = colorString.split(','); return colors.every(c => /^#[0-9A-Fa-f]{6}$/.test(c)); - }, [ prefixText, colorMode, singleColor, colorString ]); + }, [ prefixText, colorMode, singleColor, colorString, gradientStart, gradientEnd ]); const handlePurchase = () => { @@ -99,12 +105,12 @@ export const CatalogLayoutCustomPrefixView: FC = props => setSingleColor(color); setCustomColorInput(color); } - else if(selectedLetterIndex !== null) + else if(colorMode === 'perLetter' && selectedLetterIndex !== null) { setLetterColors(prev => ({ ...prev, [selectedLetterIndex]: color })); setCustomColorInput(color); - // Auto-advance to next letter + // Auto-avanza alla lettera successiva if(selectedLetterIndex < prefixText.length - 1) { const nextIdx = selectedLetterIndex + 1; @@ -123,7 +129,7 @@ export const CatalogLayoutCustomPrefixView: FC = props => { setSingleColor(value); } - else if(selectedLetterIndex !== null) + else if(colorMode === 'perLetter' && selectedLetterIndex !== null) { setLetterColors(prev => ({ ...prev, [selectedLetterIndex]: value })); } @@ -148,18 +154,12 @@ export const CatalogLayoutCustomPrefixView: FC = props => setLetterColors(newColors); }; - const hasMultiColor = colorMode === 'perLetter' && previewColors.length > 1 && new Set(previewColors).size > 1; - const currentActiveColor = colorMode === 'single' ? singleColor : (selectedLetterIndex !== null ? (letterColors[selectedLetterIndex] || singleColor) : singleColor); - const effectStyle = getPrefixEffectStyle(selectedEffect, previewColors[0] || '#FFFFFF'); - return (
- - { /* Header */ } { page.localization.getImage(0) && } @@ -175,31 +175,48 @@ export const CatalogLayoutCustomPrefixView: FC = props => } }>
- - { selectedIcon && { selectedIcon } } - - {'{'} - { hasMultiColor - ? [ ...(prefixText || '...') ].map((char, i) => ( - { char } - )) - : (prefixText || '...') - } - {'}'} - + + + Username - Username +
+ + { /* Chat Bubble Preview */ } +
+
{ LocalizeText('catalog.prefix.chat.preview') }
+
+
+ { (prefixText || '...') && + } + Username: + Hello everyone! +
+
+
{ /* Text + Icon Row */ }
- +
= props =>
- +
@@ -241,14 +258,14 @@ export const CatalogLayoutCustomPrefixView: FC = props =>
- { /* Emoji Picker (emoji-mart) - portaled to body, no backdrop */ } - { showIconPicker && createPortal( + { /* Emoji Picker (emoji-mart) - fixed overlay */ } + { showIconPicker && ( <> -
setShowIconPicker(false) } /> -
+
setShowIconPicker(false) } /> +
{ setSelectedIcon(emoji.native); setShowIconPicker(false); } } theme="dark" previewPosition="none" @@ -261,13 +278,12 @@ export const CatalogLayoutCustomPrefixView: FC = props => set="native" />
- , - document.body + ) } { /* Effect Selector */ }
- +
{ PRESET_PREFIX_EFFECTS.map(fx => ( +
+ { /* Gradient Controls */ } + { colorMode === 'gradient' && ( +
+
+
+ + { const v = e.target.value; setGradientStart(v); } } /> +
+ → +
+ + { const v = e.target.value; setGradientEnd(v); } } /> +
+
+ { /* Gradient preview bar */ } +
+
+ ) } + { /* Per-Letter Selector */ } { colorMode === 'perLetter' && prefixText.length > 0 && (
- Select a letter, then choose a color. Auto-advances. + { LocalizeText('catalog.prefix.color.hint') }
= props =>
) } - { /* Color Palette */ } -
- { colorMode === 'perLetter' && selectedLetterIndex !== null && - - Selected letter: "{ prefixText[selectedLetterIndex] || '' }" - - } -
- { PRESET_COLORS.map((color, idx) => - { - const isActive = currentActiveColor === color; - return ( -
handleColorSelect(color) } /> - ); - }) } -
-
-
diff --git a/src/components/inventory/views/prefix/InventoryPrefixView.tsx b/src/components/inventory/views/prefix/InventoryPrefixView.tsx index d959546..4f3ea6d 100644 --- a/src/components/inventory/views/prefix/InventoryPrefixView.tsx +++ b/src/components/inventory/views/prefix/InventoryPrefixView.tsx @@ -1,32 +1,8 @@ import { FC, useEffect, useState } from 'react'; import { FaTrashAlt } from 'react-icons/fa'; -import { IPrefixItem, LocalizeText, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../api'; +import { IPrefixItem, LocalizeText } from '../../../../api'; import { useInventoryPrefixes, useNotification } from '../../../../hooks'; -import { NitroButton } from '../../../../layout'; - -const PrefixPreview: FC<{ text: string; color: string; icon: string; effect?: string; className?: string; textSize?: string }> = ({ text, color, icon, effect = '', className = '', textSize = 'text-sm' }) => -{ - const colors = parsePrefixColors(text, color); - const hasMultiColor = colors.length > 1 && new Set(colors).size > 1; - const fxStyle = getPrefixEffectStyle(effect, colors[0] || '#FFFFFF'); - - return ( - - { effect === 'pulse' && } - { icon && { icon } } - - {'{'} - { hasMultiColor - ? [ ...text ].map((char, i) => ( - { char } - )) - : text - } - {'}'} - - - ); -}; +import { NitroButton, PrefixPreview } from '../../../../layout'; const PrefixItemView: FC<{ prefix: IPrefixItem; diff --git a/src/components/room/widgets/chat/ChatWidgetMessageView.tsx b/src/components/room/widgets/chat/ChatWidgetMessageView.tsx index 1dba1a9..48aac61 100644 --- a/src/components/room/widgets/chat/ChatWidgetMessageView.tsx +++ b/src/components/room/widgets/chat/ChatWidgetMessageView.tsx @@ -1,7 +1,8 @@ import { GetRoomEngine, RoomChatSettings, RoomObjectCategory } from '@nitrots/nitro-renderer'; import { FC, useEffect, useMemo, useRef, useState } from 'react'; -import { ChatBubbleMessage, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../api'; +import { ChatBubbleMessage } from '../../../../api'; import { useOnClickChat } from '../../../../hooks'; +import { PrefixPreview } from '../../../../layout'; interface ChatWidgetMessageViewProps { @@ -90,27 +91,8 @@ export const ChatWidgetMessageView: FC = ({ ) }
- { chat.prefixEffect === 'pulse' && } - { chat.prefixText && (() => { - const colors = parsePrefixColors(chat.prefixText, chat.prefixColor); - const hasMultiColor = colors.length > 1 && new Set(colors).size > 1; - const fxStyle = getPrefixEffectStyle(chat.prefixEffect, colors[0] || '#FFFFFF'); - return ( - - { chat.prefixIcon && { chat.prefixIcon } } - - {'{'} - { hasMultiColor - ? [ ...chat.prefixText ].map((char, i) => ( - { char } - )) - : chat.prefixText - } - {'}'} - - - ); - })() } + { chat.prefixText && + }
diff --git a/src/css/index.css b/src/css/index.css index 806e0e5..b48faa3 100644 --- a/src/css/index.css +++ b/src/css/index.css @@ -78,6 +78,27 @@ body { } @layer components { + @keyframes prefix-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } + + @keyframes prefix-rainbow { + 0% { filter: hue-rotate(0deg); } + 100% { filter: hue-rotate(360deg); } + } + + @keyframes prefix-shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-1px) rotate(-1deg); } + 75% { transform: translateX(1px) rotate(1deg); } + } + + @keyframes prefix-wave { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-3px); } + } + @keyframes blink { 0%, diff --git a/src/layout/PrefixPreview.tsx b/src/layout/PrefixPreview.tsx new file mode 100644 index 0000000..00779e8 --- /dev/null +++ b/src/layout/PrefixPreview.tsx @@ -0,0 +1,48 @@ +import { FC, useMemo } from 'react'; +import { parsePrefixColors, getPrefixEffectStyle } from '../api'; + +interface PrefixPreviewProps +{ + text: string; + color: string; + icon?: string; + effect?: string; + className?: string; + textSize?: string; +} + +export const PrefixPreview: FC = ({ text, color, icon = '', effect = '', className = '', textSize = 'text-sm' }) => +{ + const colors = useMemo(() => parsePrefixColors(text, color), [ text, color ]); + const hasMultiColor = colors.length > 1 && new Set(colors).size > 1; + const fxStyle = useMemo(() => getPrefixEffectStyle(effect, colors[0] || '#FFFFFF'), [ effect, colors ]); + const isWave = effect === 'wave'; + + return ( + + { icon && { icon } } + + {'{'} + { (hasMultiColor || isWave) + ? [ ...text ].map((char, i) => + { + const charStyle: Record = { + color: colors[i] || colors[colors.length - 1], + ...getPrefixEffectStyle(effect, colors[i]) + }; + + if(isWave) + { + charStyle.display = 'inline-block'; + charStyle.animationDelay = `${ i * 0.08 }s`; + } + + return { char }; + }) + : text + } + {'}'} + + + ); +}; diff --git a/src/layout/index.ts b/src/layout/index.ts index a7041de..41a8969 100644 --- a/src/layout/index.ts +++ b/src/layout/index.ts @@ -3,6 +3,7 @@ export * from './NitroButton'; export * from './NitroCard'; export * from './NitroInput'; export * from './NitroItemCountBadge'; +export * from './PrefixPreview'; export * from './classNames'; export * from './limited-edition'; export * from './styleNames';