feat: custom prefix system with effects, emoji picker and per-letter colors

- Catalog page for creating custom prefixes with text, per-letter colors, emoji icon and visual effects
- Emoji picker via @emoji-mart/react with createPortal + Shadow DOM blur fix
- Inventory prefix management (activate/deactivate/delete)
- Chat bubble rendering with multi-color prefix and effect support
- Prefix utilities (getPrefixEffectStyle, parsePrefixColors, PREFIX_EFFECT_KEYFRAMES)
- All UI text in English
This commit is contained in:
simoleo89
2026-03-20 17:07:33 +01:00
parent 0b4a5de8df
commit 11543bb64c
16 changed files with 841 additions and 4 deletions
+9
View File
@@ -0,0 +1,9 @@
export interface IPrefixItem
{
id: number;
text: string;
color: string;
icon: string;
effect: string;
active: boolean;
}
+1
View File
@@ -6,4 +6,5 @@ export class UnseenItemCategory
public static BADGE: number = 4;
public static BOT: number = 5;
public static GAMES: number = 6;
public static PREFIX: number = 7;
}
+1
View File
@@ -5,6 +5,7 @@ export * from './GroupItem';
export * from './IBotItem';
export * from './IFurnitureItem';
export * from './IPetItem';
export * from './IPrefixItem';
export * from './IUnseenItemTracker';
export * from './InventoryUtilities';
export * from './PetUtilities';
@@ -7,6 +7,10 @@ export class ChatBubbleMessage
public height: number = 0;
public elementRef: HTMLDivElement = null;
public skipMovement: boolean = false;
public prefixText: string = '';
public prefixColor: string = '';
public prefixIcon: string = '';
public prefixEffect: string = '';
private _top: number = 0;
private _left: number = 0;
+1
View File
@@ -7,6 +7,7 @@ export * from './AvatarInfoUser';
export * from './AvatarInfoUtilities';
export * from './BotSkillsEnum';
export * from './ChatBubbleMessage';
export * from './CommandDefinition';
export * from './ChatBubbleUtilities';
export * from './ChatMessageTypeEnum';
export * from './DimmerFurnitureWidgetPresetItem';
+53
View File
@@ -0,0 +1,53 @@
export const PRESET_PREFIX_EFFECTS: { id: string; label: string; icon: string }[] = [
{ id: '', label: 'None', icon: '—' },
{ id: 'glow', label: 'Glow', icon: '✨' },
{ id: 'shadow', label: 'Shadow', icon: '🌑' },
{ id: 'italic', label: 'Italic', icon: '𝑰' },
{ id: 'outline', label: 'Outline', icon: '🔲' },
{ id: 'pulse', label: 'Pulse', icon: '💫' },
{ id: 'bold-glow', label: 'Neon', icon: '💡' },
];
export const parsePrefixColors = (text: string, colorStr: string): string[] =>
{
if(!colorStr || !text) return [];
const colors = colorStr.split(',');
return [ ...text ].map((_, i) => colors[Math.min(i, colors.length - 1)]);
};
export const getPrefixEffectStyle = (effect: string, color?: string): Record<string, string | number> =>
{
const baseColor = color || '#FFFFFF';
switch(effect)
{
case 'glow':
return { textShadow: `0 0 6px ${ baseColor }, 0 0 12px ${ baseColor }80` };
case 'shadow':
return { textShadow: '2px 2px 4px rgba(0,0,0,0.7), 1px 1px 2px rgba(0,0,0,0.5)' };
case 'italic':
return { fontStyle: 'italic' };
case 'outline':
return {
WebkitTextStroke: '0.5px rgba(0,0,0,0.6)',
textShadow: '1px 1px 0 rgba(0,0,0,0.3), -1px -1px 0 rgba(0,0,0,0.3), 1px -1px 0 rgba(0,0,0,0.3), -1px 1px 0 rgba(0,0,0,0.3)'
};
case 'pulse':
return { animation: 'prefix-pulse 1.5s ease-in-out infinite' };
case 'bold-glow':
return {
textShadow: `0 0 4px ${ baseColor }, 0 0 8px ${ baseColor }, 0 0 16px ${ baseColor }60`,
fontWeight: 900
};
default:
return {};
}
};
export const PREFIX_EFFECT_KEYFRAMES = `
@keyframes prefix-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
`;
+1
View File
@@ -11,6 +11,7 @@ export * from './LocalizeFormattedNumber';
export * from './LocalizeShortNumber';
export * from './LocalizeText';
export * from './PlaySound';
export * from './PrefixUtils';
export * from './ProductImageUtility';
export * from './Randomizer';
export * from './RoomChatFormatter';
@@ -0,0 +1,470 @@
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 { CatalogLayoutProps } from './CatalogLayout.types';
import data from '@emoji-mart/data';
import Picker from '@emoji-mart/react';
const PRESET_COLORS: string[] = [
'#FF0000', '#FF6600', '#FFCC00', '#33CC00', '#00CCFF',
'#0066FF', '#9933FF', '#FF33CC', '#FFFFFF', '#CCCCCC',
'#999999', '#333333', '#FF9999', '#99FF99', '#9999FF',
'#FFD700', '#FF4500', '#00CED1', '#8A2BE2', '#DC143C'
];
export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
{
const { page = null, hideNavigation = null } = props;
useEffect(() =>
{
hideNavigation();
}, [ page, hideNavigation ]);
const [ prefixText, setPrefixText ] = useState('');
const [ colorMode, setColorMode ] = useState<'single' | 'perLetter'>('single');
const [ singleColor, setSingleColor ] = useState('#FFFFFF');
const [ letterColors, setLetterColors ] = useState<Record<number, string>>({});
const [ selectedLetterIndex, setSelectedLetterIndex ] = useState<number | null>(null);
const [ customColorInput, setCustomColorInput ] = useState('#FFFFFF');
const [ selectedIcon, setSelectedIcon ] = useState('');
const [ showIconPicker, setShowIconPicker ] = useState(false);
const [ selectedEffect, setSelectedEffect ] = useState('');
const [ purchased, setPurchased ] = useState(false);
const pickerContainerRef = useRef<HTMLDivElement>(null);
// Inject style into emoji-mart Shadow DOM to remove backdrop-filter blur
useEffect(() =>
{
if(!showIconPicker) return;
const timer = setTimeout(() =>
{
const container = pickerContainerRef.current;
if(!container) return;
const emPicker = container.querySelector('em-emoji-picker');
if(!emPicker?.shadowRoot) return;
const existing = emPicker.shadowRoot.querySelector('#no-blur-fix');
if(existing) return;
const style = document.createElement('style');
style.id = 'no-blur-fix';
style.textContent = `.sticky { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; background-color: rgb(var(--em-rgb-background)) !important; } .menu { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; background-color: rgb(var(--em-rgb-background)) !important; }`;
emPicker.shadowRoot.appendChild(style);
}, 50);
return () => clearTimeout(timer);
}, [ showIconPicker ]);
const colorString = useMemo(() =>
{
if(colorMode === 'single') return singleColor;
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 ]);
const isValid = useMemo(() =>
{
if(!prefixText.trim().length || prefixText.trim().length > 15) return false;
if(colorMode === 'single') return /^#[0-9A-Fa-f]{6}$/.test(singleColor);
const colors = colorString.split(',');
return colors.every(c => /^#[0-9A-Fa-f]{6}$/.test(c));
}, [ prefixText, colorMode, singleColor, colorString ]);
const handlePurchase = () =>
{
if(!isValid) return;
SendMessageComposer(new PurchasePrefixComposer(prefixText.trim(), colorString, selectedIcon, selectedEffect));
setPurchased(true);
setTimeout(() => setPurchased(false), 2000);
};
const handleColorSelect = (color: string) =>
{
if(colorMode === 'single')
{
setSingleColor(color);
setCustomColorInput(color);
}
else if(selectedLetterIndex !== null)
{
setLetterColors(prev => ({ ...prev, [selectedLetterIndex]: color }));
setCustomColorInput(color);
// Auto-advance to next letter
if(selectedLetterIndex < prefixText.length - 1)
{
const nextIdx = selectedLetterIndex + 1;
setSelectedLetterIndex(nextIdx);
setCustomColorInput(letterColors[nextIdx] || singleColor);
}
}
};
const handleCustomColorChange = (value: string) =>
{
setCustomColorInput(value);
if(/^#[0-9A-Fa-f]{6}$/.test(value))
{
if(colorMode === 'single')
{
setSingleColor(value);
}
else if(selectedLetterIndex !== null)
{
setLetterColors(prev => ({ ...prev, [selectedLetterIndex]: value }));
}
}
};
const handleTextChange = (newText: string) =>
{
setPrefixText(newText);
if(selectedLetterIndex !== null && selectedLetterIndex >= newText.length)
{
setSelectedLetterIndex(newText.length > 0 ? newText.length - 1 : null);
}
};
const applyColorToAll = () =>
{
if(!prefixText.length) return;
const newColors: Record<number, string> = {};
[ ...prefixText ].forEach((_, i) => { newColors[i] = customColorInput; });
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 (
<div className="flex flex-col gap-2 h-full overflow-auto p-1">
<style>{ PREFIX_EFFECT_KEYFRAMES }</style>
{ /* Header */ }
{ page.localization.getImage(0) &&
<img alt="" className="w-full rounded" src={ page.localization.getImage(0) } /> }
{ page.localization.getText(0) &&
<div className="text-sm mb-1" dangerouslySetInnerHTML={ { __html: page.localization.getText(0) } } /> }
{ /* Live Preview */ }
<div className="relative flex items-center justify-center p-4 rounded-lg min-h-[56px]"
style={ {
background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)',
border: '1px solid rgba(255,255,255,0.08)',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.05), 0 2px 8px rgba(0,0,0,0.3)'
} }>
<div className="absolute inset-0 rounded-lg opacity-20"
style={ { background: 'radial-gradient(ellipse at center, rgba(100,149,237,0.3) 0%, transparent 70%)' } } />
<span className="relative text-xl font-bold tracking-wide" style={ effectStyle }>
{ selectedIcon && <span className="mr-1">{ selectedIcon }</span> }
<span style={ hasMultiColor ? effectStyle : { ...effectStyle, color: previewColors[0] || '#FFFFFF' } }>
{'{'}
{ hasMultiColor
? [ ...(prefixText || '...') ].map((char, i) => (
<span key={ i } style={ { color: previewColors[i] || previewColors[previewColors.length - 1], ...getPrefixEffectStyle(selectedEffect, previewColors[i]) } }>{ char }</span>
))
: (prefixText || '...')
}
{'}'}
</span>
</span>
<span className="relative ml-2 text-white/80 text-lg font-medium">Username</span>
</div>
{ /* Text + Icon Row */ }
<div className="flex gap-2">
<div className="flex flex-col gap-0.5 flex-1">
<label className="text-[11px] font-bold uppercase tracking-wider opacity-60">Text</label>
<div className="relative">
<input
className="w-full px-3 py-1.5 rounded-md text-sm focus:outline-none transition-all"
maxLength={ 15 }
placeholder="Enter text..."
style={ {
background: 'rgba(0,0,0,0.15)',
border: '1px solid rgba(0,0,0,0.15)',
color: 'inherit'
} }
type="text"
value={ prefixText }
onChange={ e => handleTextChange(e.target.value) } />
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] opacity-30 font-mono">
{ prefixText.length }/15
</span>
</div>
</div>
<div className="flex flex-col gap-0.5 relative">
<label className="text-[11px] font-bold uppercase tracking-wider opacity-60">Icon</label>
<div className="flex gap-1">
<button
className="flex items-center justify-center gap-1 px-3 py-1.5 rounded-md text-sm transition-all min-w-[70px]"
style={ {
background: selectedIcon ? 'rgba(59,130,246,0.15)' : 'rgba(0,0,0,0.15)',
border: selectedIcon ? '1px solid rgba(59,130,246,0.3)' : '1px solid rgba(0,0,0,0.15)'
} }
onClick={ () => setShowIconPicker(!showIconPicker) }>
{ selectedIcon
? <><span className="text-base">{ selectedIcon }</span><span className="text-[10px] opacity-40"></span></>
: <span className="opacity-40 text-xs">Emoji </span>
}
</button>
{ selectedIcon &&
<button
className="flex items-center justify-center px-1.5 rounded-md text-xs transition-all"
style={ { background: 'rgba(239,68,68,0.15)', border: '1px solid rgba(239,68,68,0.3)' } }
title="Remove icon"
onClick={ () => setSelectedIcon('') }>
</button>
}
</div>
</div>
</div>
{ /* Emoji Picker (emoji-mart) - portaled to body, no backdrop */ }
{ showIconPicker && createPortal(
<>
<div className="fixed inset-0" style={ { zIndex: 9998 } } onClick={ () => setShowIconPicker(false) } />
<div ref={ pickerContainerRef } className="fixed rounded-xl overflow-hidden" style={ { zIndex: 9999, top: '50%', left: '50%', transform: 'translate(-50%, -50%)', background: '#2b2f35' } }>
<Picker
data={ data }
locale="en"
onEmojiSelect={ (emoji: { native: string }) => { setSelectedIcon(emoji.native); setShowIconPicker(false); } }
theme="dark"
previewPosition="none"
skinTonePosition="search"
perLine={ 8 }
maxFrequentRows={ 2 }
emojiSize={ 22 }
emojiButtonSize={ 30 }
dynamicWidth={ false }
set="native"
/>
</div>
</>,
document.body
) }
{ /* Effect Selector */ }
<div className="flex flex-col gap-1">
<label className="text-[11px] font-bold uppercase tracking-wider opacity-60">Effect</label>
<div className="flex flex-wrap gap-1">
{ PRESET_PREFIX_EFFECTS.map(fx => (
<button
key={ fx.id }
className="px-2 py-1 rounded-md text-[11px] font-semibold transition-all"
style={ {
background: selectedEffect === fx.id ? 'rgba(59,130,246,0.25)' : 'rgba(0,0,0,0.1)',
border: selectedEffect === fx.id ? '1px solid rgba(59,130,246,0.4)' : '1px solid rgba(0,0,0,0.1)',
opacity: selectedEffect === fx.id ? 1 : 0.7
} }
onClick={ () => setSelectedEffect(fx.id) }>
<span className="mr-0.5">{ fx.icon }</span> { fx.label }
</button>
)) }
</div>
</div>
{ /* Color Mode Toggle */ }
<div className="flex flex-col gap-1">
<label className="text-[11px] font-bold uppercase tracking-wider opacity-60">Color</label>
<div className="flex rounded-md overflow-hidden" style={ { border: '1px solid rgba(0,0,0,0.15)' } }>
<button
className="flex-1 px-2 py-1.5 text-xs font-bold transition-all"
style={ {
background: colorMode === 'single' ? 'rgba(59,130,246,0.25)' : 'rgba(0,0,0,0.1)',
borderRight: '1px solid rgba(0,0,0,0.1)',
opacity: colorMode === 'single' ? 1 : 0.6
} }
onClick={ () => { setColorMode('single'); setSelectedLetterIndex(null); } }>
🎨 Single
</button>
<button
className="flex-1 px-2 py-1.5 text-xs font-bold transition-all"
style={ {
background: colorMode === 'perLetter' ? 'rgba(59,130,246,0.25)' : 'rgba(0,0,0,0.1)',
opacity: colorMode === 'perLetter' ? 1 : 0.6
} }
onClick={ () => { setColorMode('perLetter'); if(prefixText.length > 0) setSelectedLetterIndex(0); } }>
🌈 Per Letter
</button>
</div>
</div>
{ /* Per-Letter Selector */ }
{ colorMode === 'perLetter' && prefixText.length > 0 && (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<span className="text-[10px] opacity-50">
Select a letter, then choose a color. Auto-advances.
</span>
<button
className="text-[10px] px-1.5 py-0.5 rounded transition-all"
style={ {
background: 'rgba(0,0,0,0.1)',
border: '1px solid rgba(0,0,0,0.1)'
} }
title="Apply current color to all letters"
onClick={ applyColorToAll }>
Apply to all
</button>
</div>
<div className="flex flex-wrap gap-1 p-2 rounded-lg"
style={ {
background: 'rgba(0,0,0,0.12)',
border: '1px solid rgba(0,0,0,0.1)'
} }>
{ [ ...prefixText ].map((char, i) =>
{
const charColor = letterColors[i] || singleColor;
const isSelected = selectedLetterIndex === i;
return (
<div
key={ i }
className="relative flex items-center justify-center cursor-pointer transition-all"
style={ {
width: '28px',
height: '34px',
borderRadius: '6px',
background: isSelected
? 'rgba(59,130,246,0.2)'
: 'rgba(0,0,0,0.12)',
border: isSelected
? '2px solid rgba(59,130,246,0.6)'
: '1px solid rgba(0,0,0,0.08)',
transform: isSelected ? 'scale(1.15)' : 'scale(1)',
zIndex: isSelected ? 10 : 1,
boxShadow: isSelected ? '0 0 8px rgba(59,130,246,0.3)' : 'none'
} }
onClick={ () => { setSelectedLetterIndex(i); setCustomColorInput(charColor); } }>
<span className="text-sm font-black" style={ { color: charColor } }>
{ char }
</span>
<div
className="absolute bottom-0.5 left-1/2 -translate-x-1/2 rounded-full"
style={ {
width: '14px',
height: '3px',
backgroundColor: charColor,
boxShadow: `0 0 4px ${ charColor }`
} } />
</div>
);
}) }
</div>
</div>
) }
{ /* Color Palette */ }
<div className="flex flex-col gap-1">
{ colorMode === 'perLetter' && selectedLetterIndex !== null &&
<span className="text-[10px] opacity-50 italic">
Selected letter: &quot;{ prefixText[selectedLetterIndex] || '' }&quot;
</span>
}
<div className="grid grid-cols-10 gap-[3px]">
{ PRESET_COLORS.map((color, idx) =>
{
const isActive = currentActiveColor === color;
return (
<div
key={ idx }
className="cursor-pointer transition-all"
style={ {
width: '100%',
aspectRatio: '1',
borderRadius: '5px',
backgroundColor: color,
border: isActive ? '2px solid #fff' : '1px solid rgba(0,0,0,0.15)',
boxShadow: isActive ? `0 0 6px ${ color }, 0 0 0 1px rgba(0,0,0,0.2)` : 'inset 0 1px 0 rgba(255,255,255,0.2)',
transform: isActive ? 'scale(1.2)' : 'scale(1)',
zIndex: isActive ? 5 : 1
} }
onClick={ () => handleColorSelect(color) } />
);
}) }
</div>
<div className="flex items-center gap-2 mt-0.5">
<label
className="relative cursor-pointer"
style={ {
width: '24px',
height: '24px',
borderRadius: '6px',
backgroundColor: customColorInput,
border: '2px solid rgba(0,0,0,0.2)',
boxShadow: `0 0 6px ${ customColorInput }40, inset 0 1px 0 rgba(255,255,255,0.3)`
} }>
<input
className="absolute inset-0 opacity-0 cursor-pointer"
style={ { width: '100%', height: '100%' } }
type="color"
value={ customColorInput }
onChange={ e => handleColorSelect(e.target.value) } />
</label>
<input
className="flex-1 px-2 py-0.5 text-xs font-mono focus:outline-none transition-all"
maxLength={ 7 }
placeholder="#FFFFFF"
style={ {
background: 'rgba(0,0,0,0.15)',
border: '1px solid rgba(0,0,0,0.1)',
color: 'inherit',
maxWidth: '80px',
borderRadius: '5px'
} }
type="text"
value={ customColorInput }
onChange={ e => handleCustomColorChange(e.target.value) } />
</div>
</div>
{ /* Purchase Footer */ }
<div className="flex items-center justify-between mt-auto pt-2"
style={ { borderTop: '1px solid rgba(0,0,0,0.1)' } }>
<div className="flex items-center gap-1">
<span className="text-xs opacity-60">Price:</span>
<span className="text-sm font-bold">5 Credits</span>
</div>
<button
className="px-5 py-1.5 rounded-md text-sm font-bold transition-all"
disabled={ !isValid || purchased }
style={ {
background: !isValid
? 'rgba(0,0,0,0.1)'
: purchased
? 'linear-gradient(135deg, #22c55e, #16a34a)'
: 'linear-gradient(135deg, #3b82f6, #2563eb)',
color: !isValid ? 'rgba(0,0,0,0.3)' : '#fff',
cursor: !isValid ? 'not-allowed' : 'pointer',
border: !isValid ? '1px solid rgba(0,0,0,0.1)' : 'none',
boxShadow: isValid && !purchased ? '0 2px 8px rgba(59,130,246,0.3)' : 'none',
borderRadius: '6px'
} }
onClick={ handlePurchase }>
{ purchased ? '✓ Purchased!' : 'Purchase' }
</button>
</div>
</div>
);
};
@@ -2,6 +2,7 @@ import { ICatalogPage } from '../../../../../api';
import { CatalogLayoutProps } from './CatalogLayout.types';
import { CatalogLayoutBadgeDisplayView } from './CatalogLayoutBadgeDisplayView';
import { CatalogLayoutColorGroupingView } from './CatalogLayoutColorGroupingView';
import { CatalogLayoutCustomPrefixView } from './CatalogLayoutCustomPrefixView';
import { CatalogLayoutDefaultView } from './CatalogLayoutDefaultView';
import { CatalogLayouGuildCustomFurniView } from './CatalogLayoutGuildCustomFurniView';
import { CatalogLayouGuildForumView } from './CatalogLayoutGuildForumView';
@@ -72,6 +73,8 @@ export const GetCatalogLayout = (page: ICatalogPage, hideNavigation: () => void)
return <CatalogLayoutColorGroupingView { ...layoutProps } />;
case 'soundmachine':
return <CatalogLayoutSoundMachineView { ...layoutProps } />;
case 'custom_prefix':
return <CatalogLayoutCustomPrefixView { ...layoutProps } />;
case 'bots':
case 'default_3x3':
default:
+7 -3
View File
@@ -2,7 +2,7 @@ import { AddLinkEventTracker, BadgePointLimitsEvent, GetLocalizationManager, Get
import { FC, useEffect, useState } from 'react';
import { GroupItem, LocalizeText, UnseenItemCategory, isObjectMoverRequested, setObjectMoverRequested } from '../../api';
import { NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
import { useInventoryBadges, useInventoryFurni, useInventoryTrade, useInventoryUnseenTracker, useMessageEvent, useNitroEvent } from '../../hooks';
import { useInventoryBadges, useInventoryFurni, useInventoryPrefixes, useInventoryTrade, useInventoryUnseenTracker, useMessageEvent, useNitroEvent } from '../../hooks';
import { InventoryCategoryFilterView } from './views/InventoryCategoryFilterView';
import { InventoryBadgeView } from './views/badge/InventoryBadgeView';
import { InventoryBotView } from './views/bot/InventoryBotView';
@@ -10,13 +10,15 @@ import { InventoryFurnitureDeleteView } from './views/furniture/InventoryFurnitu
import { InventoryFurnitureView } from './views/furniture/InventoryFurnitureView';
import { InventoryTradeView } from './views/furniture/InventoryTradeView';
import { InventoryPetView } from './views/pet/InventoryPetView';
import { InventoryPrefixView } from './views/prefix/InventoryPrefixView';
const TAB_FURNITURE: string = 'inventory.furni';
const TAB_BOTS: string = 'inventory.bots';
const TAB_PETS: string = 'inventory.furni.tab.pets';
const TAB_BADGES: string = 'inventory.badges';
const TABS = [ TAB_FURNITURE, TAB_PETS, TAB_BADGES, TAB_BOTS ];
const UNSEEN_CATEGORIES = [ UnseenItemCategory.FURNI, UnseenItemCategory.PET, UnseenItemCategory.BADGE, UnseenItemCategory.BOT ];
const TAB_PREFIXES: string = 'inventory.prefixes';
const TABS = [ TAB_FURNITURE, TAB_PETS, TAB_BADGES, TAB_PREFIXES, TAB_BOTS ];
const UNSEEN_CATEGORIES = [ UnseenItemCategory.FURNI, UnseenItemCategory.PET, UnseenItemCategory.BADGE, UnseenItemCategory.PREFIX, UnseenItemCategory.BOT ];
export const InventoryView: FC<{}> = props =>
{
@@ -165,6 +167,8 @@ export const InventoryView: FC<{}> = props =>
<InventoryPetView roomPreviewer={ roomPreviewer } roomSession={ roomSession } /> }
{ (currentTab === TAB_BADGES) &&
<InventoryBadgeView filteredBadgeCodes={ filteredBadgeCodes } /> }
{ (currentTab === TAB_PREFIXES) &&
<InventoryPrefixView /> }
{ (currentTab === TAB_BOTS) &&
<InventoryBotView roomPreviewer={ roomPreviewer } roomSession={ roomSession } /> }
</div>
@@ -0,0 +1,136 @@
import { FC, useEffect, useState } from 'react';
import { FaTrashAlt } from 'react-icons/fa';
import { IPrefixItem, LocalizeText, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } 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 (
<span className={ `font-bold ${ textSize } ${ className }` } style={ fxStyle }>
{ effect === 'pulse' && <style>{ PREFIX_EFFECT_KEYFRAMES }</style> }
{ icon && <span className="mr-0.5">{ icon }</span> }
<span style={ hasMultiColor ? fxStyle : { ...fxStyle, color: colors[0] || '#FFFFFF' } }>
{'{'}
{ hasMultiColor
? [ ...text ].map((char, i) => (
<span key={ i } style={ { color: colors[i] || colors[colors.length - 1], ...getPrefixEffectStyle(effect, colors[i]) } }>{ char }</span>
))
: text
}
{'}'}
</span>
</span>
);
};
const PrefixItemView: FC<{
prefix: IPrefixItem;
isSelected: boolean;
onClick: () => void;
}> = ({ prefix, isSelected, onClick }) =>
{
return (
<div
className={ `flex items-center justify-center rounded-md border-2 cursor-pointer p-2 transition-colors
${ isSelected ? 'border-card-grid-item-active bg-card-grid-item-active' : 'border-card-grid-item-border bg-card-grid-item' }
${ prefix.active ? 'ring-2 ring-green-400' : '' }` }
onClick={ onClick }>
<PrefixPreview className="truncate" color={ prefix.color } effect={ prefix.effect } icon={ prefix.icon } text={ prefix.text } />
</div>
);
};
export const InventoryPrefixView: FC<{}> = () =>
{
const [ isVisible, setIsVisible ] = useState(false);
const { prefixes = [], activePrefix = null, selectedPrefix = null, setSelectedPrefix = null, activatePrefix = null, deactivatePrefix = null, deletePrefix = null, activate = null, deactivate = null } = useInventoryPrefixes();
const { showConfirm = null } = useNotification();
const attemptDeletePrefix = () =>
{
if(!selectedPrefix) return;
showConfirm(
`Are you sure you want to delete the prefix {${selectedPrefix.text}}?`,
() => deletePrefix(selectedPrefix.id),
null,
null,
null,
LocalizeText('inventory.delete.confirm_delete.title')
);
};
useEffect(() =>
{
if(!isVisible) return;
const id = activate();
return () => deactivate(id);
}, [ isVisible, activate, deactivate ]);
useEffect(() =>
{
setIsVisible(true);
return () => setIsVisible(false);
}, []);
return (
<div className="grid h-full grid-cols-12 gap-2">
<div className="flex flex-col col-span-7 gap-1 overflow-auto">
<div className="grid grid-cols-3 gap-1">
{ prefixes.map(prefix => (
<PrefixItemView
key={ prefix.id }
isSelected={ selectedPrefix?.id === prefix.id }
prefix={ prefix }
onClick={ () => setSelectedPrefix(prefix) } />
)) }
</div>
{ (!prefixes || prefixes.length === 0) &&
<div className="flex items-center justify-center h-full text-sm opacity-50">
{ LocalizeText('inventory.empty.title') }
</div> }
</div>
<div className="flex flex-col justify-between col-span-5 overflow-auto">
{ activePrefix &&
<div className="flex flex-col gap-1">
<span className="text-sm truncate min-h-[1.25rem] leading-5">Active prefix</span>
<div className="flex items-center justify-center p-3 rounded-md border-2 border-green-400 bg-card-grid-item">
<PrefixPreview color={ activePrefix.color } effect={ activePrefix.effect } icon={ activePrefix.icon } text={ activePrefix.text } textSize="text-lg" />
</div>
</div> }
{ !activePrefix &&
<div className="flex flex-col gap-1">
<span className="text-sm truncate min-h-[1.25rem] leading-5">Active prefix</span>
<div className="flex items-center justify-center p-3 rounded-md border-2 border-dashed border-card-grid-item-border bg-card-grid-item opacity-50">
<span className="text-sm">No active prefix</span>
</div>
</div> }
{ !!selectedPrefix &&
<div className="flex flex-col gap-2 mt-2">
<div className="flex items-center justify-center gap-2 p-2 rounded bg-card-grid-item">
<PrefixPreview color={ selectedPrefix.color } effect={ selectedPrefix.effect } icon={ selectedPrefix.icon } text={ selectedPrefix.text } textSize="text-lg" />
</div>
<div className="flex items-center gap-2">
<NitroButton
className="grow"
onClick={ () => selectedPrefix.active ? deactivatePrefix() : activatePrefix(selectedPrefix.id) }>
{ selectedPrefix.active ? 'Deactivate' : 'Activate' }
</NitroButton>
{ !selectedPrefix.active &&
<NitroButton className="bg-danger! hover:bg-danger/80! p-1" onClick={ attemptDeletePrefix }>
<FaTrashAlt className="fa-icon" />
</NitroButton> }
</div>
</div> }
</div>
</div>
);
};
@@ -1,6 +1,6 @@
import { GetRoomEngine, RoomChatSettings, RoomObjectCategory } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useRef, useState } from 'react';
import { ChatBubbleMessage } from '../../../../api';
import { ChatBubbleMessage, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../api';
import { useOnClickChat } from '../../../../hooks';
interface ChatWidgetMessageViewProps
@@ -90,6 +90,27 @@ export const ChatWidgetMessageView: FC<ChatWidgetMessageViewProps> = ({
) }
</div>
<div className="chat-content py-[5px] px-[6px] ml-[27px] leading-none min-h-[25px]">
{ chat.prefixEffect === 'pulse' && <style>{ PREFIX_EFFECT_KEYFRAMES }</style> }
{ 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 (
<span className="prefix font-bold mr-1" style={ fxStyle }>
{ chat.prefixIcon && <span className="mr-0.5 text-[13px]">{ chat.prefixIcon }</span> }
<span style={ hasMultiColor ? fxStyle : { ...fxStyle, color: colors[0] || '#FFFFFF' } }>
{'{'}
{ hasMultiColor
? [ ...chat.prefixText ].map((char, i) => (
<span key={ i } style={ { color: colors[i] || colors[colors.length - 1], ...getPrefixEffectStyle(chat.prefixEffect, colors[i]) } }>{ char }</span>
))
: chat.prefixText
}
{'}'}
</span>
</span>
);
})() }
<b className="username" dangerouslySetInnerHTML={ { __html: `${ chat.username }: ` } } />
<span className={ `message${ chat.type === 1 ? ' italic text-[#595959]' : '' }` } dangerouslySetInnerHTML={ { __html: `${ chat.formattedText }` } } onClick={ onClickChat } />
</div>
+1
View File
@@ -2,5 +2,6 @@ export * from './useInventoryBadges';
export * from './useInventoryBots';
export * from './useInventoryFurni';
export * from './useInventoryPets';
export * from './useInventoryPrefixes';
export * from './useInventoryTrade';
export * from './useInventoryUnseenTracker';
+126
View File
@@ -0,0 +1,126 @@
import { ActivePrefixUpdatedEvent, PrefixReceivedEvent, RequestPrefixesComposer, SetActivePrefixComposer, DeletePrefixComposer, UserPrefixesEvent } from '@nitrots/nitro-renderer';
import { useEffect, useState } from 'react';
import { useBetween } from 'use-between';
import { IPrefixItem, SendMessageComposer, UnseenItemCategory } from '../../api';
import { useMessageEvent } from '../events';
import { useSharedVisibility } from '../useSharedVisibility';
import { useInventoryUnseenTracker } from './useInventoryUnseenTracker';
const useInventoryPrefixesState = () =>
{
const [ needsUpdate, setNeedsUpdate ] = useState(true);
const [ prefixes, setPrefixes ] = useState<IPrefixItem[]>([]);
const [ activePrefix, setActivePrefix ] = useState<IPrefixItem | null>(null);
const [ selectedPrefix, setSelectedPrefix ] = useState<IPrefixItem | null>(null);
const { isVisible = false, activate = null, deactivate = null } = useSharedVisibility();
const { isUnseen = null, resetCategory = null } = useInventoryUnseenTracker();
useMessageEvent<UserPrefixesEvent>(UserPrefixesEvent, event =>
{
const parser = event.getParser();
const newPrefixes: IPrefixItem[] = parser.prefixes.map(p => ({
id: p.id,
text: p.text,
color: p.color,
icon: p.icon || '',
effect: p.effect || '',
active: p.active
}));
setPrefixes(newPrefixes);
const active = newPrefixes.find(p => p.active) || null;
setActivePrefix(active);
});
useMessageEvent<PrefixReceivedEvent>(PrefixReceivedEvent, event =>
{
const parser = event.getParser();
const newPrefix: IPrefixItem = {
id: parser.id,
text: parser.text,
color: parser.color,
icon: parser.icon || '',
effect: parser.effect || '',
active: false
};
setPrefixes(prevValue => [ newPrefix, ...prevValue ]);
});
useMessageEvent<ActivePrefixUpdatedEvent>(ActivePrefixUpdatedEvent, event =>
{
const parser = event.getParser();
setPrefixes(prevValue =>
{
return prevValue.map(p => ({
...p,
active: p.id === parser.prefixId
}));
});
if(parser.prefixId === 0)
{
setActivePrefix(null);
}
else
{
setActivePrefix(prev =>
{
const found = prefixes.find(p => p.id === parser.prefixId);
if(found) return { ...found, active: true };
return { id: parser.prefixId, text: parser.text, color: parser.color, icon: parser.icon || '', effect: parser.effect || '', active: true };
});
}
});
const activatePrefix = (prefixId: number) =>
{
SendMessageComposer(new SetActivePrefixComposer(prefixId));
};
const deactivatePrefix = () =>
{
SendMessageComposer(new SetActivePrefixComposer(0));
};
const deletePrefix = (prefixId: number) =>
{
SendMessageComposer(new DeletePrefixComposer(prefixId));
};
useEffect(() =>
{
if(!prefixes || !prefixes.length) return;
setSelectedPrefix(prevValue =>
{
if(prevValue && prefixes.find(p => p.id === prevValue.id)) return prevValue;
return prefixes[0];
});
}, [ prefixes ]);
useEffect(() =>
{
if(!isVisible) return;
return () =>
{
resetCategory(UnseenItemCategory.PREFIX);
};
}, [ isVisible, resetCategory ]);
useEffect(() =>
{
if(!isVisible || !needsUpdate) return;
SendMessageComposer(new RequestPrefixesComposer());
setNeedsUpdate(false);
}, [ isVisible, needsUpdate ]);
return { prefixes, activePrefix, selectedPrefix, setSelectedPrefix, activatePrefix, deactivatePrefix, deletePrefix, activate, deactivate };
};
export const useInventoryPrefixes = () => useBetween(useInventoryPrefixesState);
+1
View File
@@ -1,5 +1,6 @@
export * from './furniture';
export * from './useAvatarInfoWidget';
export * from './useChatCommandSelector';
export * from './useChatInputWidget';
export * from './useChatWidget';
export * from './useDoorbellWidget';
+5
View File
@@ -149,6 +149,11 @@ const useChatWidgetState = () =>
imageUrl,
color);
chatMessage.prefixText = event.prefixText || '';
chatMessage.prefixColor = event.prefixColor || '';
chatMessage.prefixIcon = event.prefixIcon || '';
chatMessage.prefixEffect = event.prefixEffect || '';
setChatMessages(prevValue =>
{
const newValue = [ ...prevValue, chatMessage ];