From 02d8e5c2dd693504c1780a38f16c673ebc7ed2ba Mon Sep 17 00:00:00 2001 From: Life Date: Sun, 22 Mar 2026 18:43:42 +0100 Subject: [PATCH 1/4] 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'; From da791cd0d05c210c0980a26b9283b2e4fd431308 Mon Sep 17 00:00:00 2001 From: Life Date: Sun, 22 Mar 2026 20:40:05 +0100 Subject: [PATCH 2/4] 0 --- .../furni-editor/FurniEditorView.tsx | 33 ++- .../views/FurniEditorCreateView.tsx | 148 ++++++---- .../views/FurniEditorEditView.tsx | 255 +++++++++--------- .../views/FurniEditorSearchView.tsx | 173 +++++++----- src/hooks/furni-editor/useFurniEditor.ts | 216 ++++++--------- 5 files changed, 411 insertions(+), 414 deletions(-) diff --git a/src/components/furni-editor/FurniEditorView.tsx b/src/components/furni-editor/FurniEditorView.tsx index a013554..4ad02e6 100644 --- a/src/components/furni-editor/FurniEditorView.tsx +++ b/src/components/furni-editor/FurniEditorView.tsx @@ -1,12 +1,14 @@ -import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; import { FC, useCallback, useEffect, useState } from 'react'; import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common'; import { useFurniEditor } from '../../hooks/furni-editor'; +import { FurniEditorCreateView } from './views/FurniEditorCreateView'; import { FurniEditorEditView } from './views/FurniEditorEditView'; import { FurniEditorSearchView } from './views/FurniEditorSearchView'; const TAB_SEARCH = 0; const TAB_EDIT = 1; +const TAB_CREATE = 2; export const FurniEditorView: FC<{}> = () => { @@ -16,8 +18,8 @@ export const FurniEditorView: FC<{}> = () => const { items, total, page, loading, error, clearError, selectedItem, catalogItems, furniDataEntry, - interactions, - searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions + interactions, lastResult, + searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, createItem, loadInteractions } = useFurniEditor(); useEffect(() => @@ -57,13 +59,14 @@ export const FurniEditorView: FC<{}> = () => useEffect(() => { - const handler = async (e: CustomEvent<{ spriteId: number }>) => + const handler = (e: CustomEvent<{ spriteId: number }>) => { const { spriteId } = e.detail; - const ok = await loadBySpriteId(spriteId); + if(!spriteId || spriteId <= 0) return; - if(ok) setActiveTab(TAB_EDIT); + loadBySpriteId(spriteId); + setActiveTab(TAB_EDIT); }; window.addEventListener('furni-editor:open', handler as EventListener); @@ -71,11 +74,10 @@ export const FurniEditorView: FC<{}> = () => return () => window.removeEventListener('furni-editor:open', handler as EventListener); }, [ loadBySpriteId ]); - const handleSelect = useCallback(async (id: number) => + const handleSelect = useCallback((id: number) => { - const ok = await loadDetail(id); - - if(ok) setActiveTab(TAB_EDIT); + loadDetail(id); + setActiveTab(TAB_EDIT); }, [ loadDetail ]); const handleBack = useCallback(() => @@ -88,10 +90,17 @@ export const FurniEditorView: FC<{}> = () => setIsVisible(false); }, []); + const handleCreated = useCallback((id: number) => + { + loadDetail(id); + setActiveTab(TAB_EDIT); + }, [ loadDetail ]); + + if(!GetSessionDataManager()?.isModerator) return null; if(!isVisible) return null; return ( - + setActiveTab(TAB_SEARCH) }> @@ -127,6 +136,7 @@ export const FurniEditorView: FC<{}> = () => furniDataEntry={ furniDataEntry } interactions={ interactions } loading={ loading } + lastResult={ lastResult } onUpdate={ updateItem } onDelete={ deleteItem } onBack={ handleBack } @@ -134,6 +144,7 @@ export const FurniEditorView: FC<{}> = () => /> } + ); diff --git a/src/components/furni-editor/views/FurniEditorCreateView.tsx b/src/components/furni-editor/views/FurniEditorCreateView.tsx index f47530c..d7461e0 100644 --- a/src/components/furni-editor/views/FurniEditorCreateView.tsx +++ b/src/components/furni-editor/views/FurniEditorCreateView.tsx @@ -1,18 +1,23 @@ -import { FC, useCallback, useState } from 'react'; -import { Button, Column, Flex, Text } from '../../../common'; +import { FC, useCallback, useEffect, useState } from 'react'; +import { FaPlus } from 'react-icons/fa'; +import { Column } from '../../../common'; interface FurniEditorCreateViewProps { interactions: string[]; loading: boolean; - onCreate: (fields: Record) => Promise; + lastResult: { success: boolean; message: string; id: number } | null; + onCreate: (fields: Record) => void; onCreated: (id: number) => void; } +const inputClass = 'text-[11px] border-2 border-card-grid-item-border rounded px-2 py-1 bg-white focus:outline-none focus:border-primary transition-colors w-full'; +const labelClass = 'text-[9px] text-[#666] uppercase font-bold mb-0.5 block'; + export const FurniEditorCreateView: FC = props => { - const { interactions, loading, onCreate, onCreated } = props; - const [ success, setSuccess ] = useState(null); + const { interactions, loading, lastResult, onCreate, onCreated } = props; + const [ toast, setToast ] = useState<{ type: 'success' | 'error'; message: string; id?: number } | null>(null); const [ form, setForm ] = useState({ itemName: '', @@ -36,39 +41,50 @@ export const FurniEditorCreateView: FC = props => customparams: '', }); + useEffect(() => + { + if(!lastResult) return; + + if(lastResult.success && lastResult.id > 0) + { + setToast({ type: 'success', message: `Item created with ID #${ lastResult.id }`, id: lastResult.id }); + setTimeout(() => onCreated(lastResult.id), 1500); + } + else if(!lastResult.success) + { + setToast({ type: 'error', message: lastResult.message }); + } + + const timer = setTimeout(() => setToast(null), 3000); + return () => clearTimeout(timer); + }, [ lastResult ]); + const setField = useCallback((key: string, value: unknown) => { setForm(prev => ({ ...prev, [key]: value })); - setSuccess(null); }, []); - const handleCreate = useCallback(async () => + const handleCreate = useCallback(() => { if(!form.itemName || !form.publicName) return; - - const id = await onCreate(form); - - if(id) - { - setSuccess(id); - setTimeout(() => onCreated(id), 1000); - } - }, [ form, onCreate, onCreated ]); - - const inputClass = 'form-control form-control-sm'; - const labelClass = 'text-[11px] font-bold text-[#333] mb-0'; + onCreate(form); + }, [ form, onCreate ]); return ( - { success && -
- Item created with ID #{ success }! + { /* Toast */ } + { toast && +
+ { toast.message }
} -
- Basic Info -
+ { /* Basic Info */ } +
+
+ Basic Info +
+
setField('itemName', e.target.value) } placeholder="my_custom_furni" /> @@ -83,7 +99,7 @@ export const FurniEditorCreateView: FC = props =>
- setField('type', e.target.value) }> @@ -91,9 +107,12 @@ export const FurniEditorCreateView: FC = props =>
-
- Dimensions -
+ { /* Dimensions */ } +
+
+ Dimensions +
+
setField('width', Number(e.target.value)) } /> @@ -109,14 +128,17 @@ export const FurniEditorCreateView: FC = props =>
-
- Permissions -
+ { /* Permissions */ } +
+
+ Permissions +
+
{ [ 'allowStack', 'allowWalk', 'allowSit', 'allowLay', 'allowGift', 'allowTrade', 'allowRecycle', 'allowMarketplaceSell', 'allowInventoryStack' ].map(key => ( -
-
- Interaction -
-
- - -
-
- - setField('interactionModesCount', Number(e.target.value)) } /> -
+ { /* Interaction */ } +
+
+ Interaction
-
- - setField('customparams', e.target.value) } /> +
+
+
+ + +
+
+ + setField('interactionModesCount', Number(e.target.value)) } /> +
+
+
+ + setField('customparams', e.target.value) } /> +
- - - + { /* Create Button */ } +
+ +
); }; diff --git a/src/components/furni-editor/views/FurniEditorEditView.tsx b/src/components/furni-editor/views/FurniEditorEditView.tsx index dc648ef..bb50765 100644 --- a/src/components/furni-editor/views/FurniEditorEditView.tsx +++ b/src/components/furni-editor/views/FurniEditorEditView.tsx @@ -1,5 +1,7 @@ import { FC, useCallback, useEffect, useState } from 'react'; -import { Button, Column, Flex, Text } from '../../../common'; +import { FaSave, FaSync, FaTrash, FaArrowLeft } from 'react-icons/fa'; +import { Column } from '../../../common'; +import { LayoutFurniIconImageView } from '../../../common/layout/LayoutFurniIconImageView'; import { CatalogRef, FurniDetail } from '../../../hooks/furni-editor'; interface FurniEditorEditViewProps @@ -9,20 +11,24 @@ interface FurniEditorEditViewProps furniDataEntry: Record | null; interactions: string[]; loading: boolean; - onUpdate: (id: number, fields: Record) => Promise; - onDelete: (id: number) => Promise; + lastResult: { success: boolean; message: string; id: number } | null; + onUpdate: (id: number, fields: Record) => void; + onDelete: (id: number) => void; onBack: () => void; onRefresh: (id: number) => void; } +const ic = 'text-[13px] border border-[#c5cdd6] rounded px-2 py-1 bg-white focus:outline-none focus:border-[#1e7295] focus:shadow-[0_0_0_1px_rgba(30,114,149,0.15)] transition-all w-full'; +const ro = 'text-[13px] border border-[#d5dbe0] rounded px-2 py-1 bg-[#f0f2f4] text-[#777] w-full cursor-not-allowed'; +const lb = 'text-[11px] text-[#1e7295] uppercase font-bold tracking-wider leading-none'; +const sectionTitle = 'text-[12px] text-[#1e7295] uppercase font-bold tracking-wider'; + export const FurniEditorEditView: FC = props => { - const { item, catalogItems, furniDataEntry, interactions, loading, onUpdate, onDelete, onBack, onRefresh } = props; + const { item, catalogItems, furniDataEntry, interactions, loading, lastResult, onUpdate, onDelete, onBack, onRefresh } = props; const [ form, setForm ] = useState({ - itemName: '', publicName: '', - spriteId: 0, type: 's', width: 1, length: 1, @@ -42,15 +48,14 @@ export const FurniEditorEditView: FC = props => }); const [ confirmDelete, setConfirmDelete ] = useState(false); + const [ toast, setToast ] = useState<{ type: 'success' | 'error'; message: string } | null>(null); useEffect(() => { if(!item) return; setForm({ - itemName: item.itemName || '', publicName: item.publicName || '', - spriteId: item.spriteId || 0, type: item.type || 's', width: item.width || 1, length: item.length || 1, @@ -72,105 +77,119 @@ export const FurniEditorEditView: FC = props => setConfirmDelete(false); }, [ item ]); + useEffect(() => + { + if(!lastResult) return; + + setToast({ type: lastResult.success ? 'success' : 'error', message: lastResult.message }); + if(lastResult.success && lastResult.id > 0) onRefresh(lastResult.id); + + const timer = setTimeout(() => setToast(null), 3000); + return () => clearTimeout(timer); + }, [ lastResult ]); + const setField = useCallback((key: string, value: unknown) => { setForm(prev => ({ ...prev, [key]: value })); }, []); - const handleSave = useCallback(async () => + const handleSave = useCallback(() => { - const ok = await onUpdate(item.id, form); + onUpdate(item.id, form); + }, [ item, form, onUpdate ]); - if(ok) onRefresh(item.id); - }, [ item, form, onUpdate, onRefresh ]); - - const handleDelete = useCallback(async () => + const handleDelete = useCallback(() => { if(!confirmDelete) return setConfirmDelete(true); - - const ok = await onDelete(item.id); - - if(ok) onBack(); - }, [ confirmDelete, item, onDelete, onBack ]); - - const inputClass = 'form-control form-control-sm'; - const labelClass = 'text-[11px] font-bold text-[#333] mb-0'; + onDelete(item.id); + }, [ confirmDelete, item, onDelete ]); return ( - - - - - - - - { item.id } - | - - - - { item.spriteId } - - ({ item.usageCount } in use) - + + { toast && +
+ { toast.message } +
+ } + + { /* Header */ } +
+
+ +
+
+
{ item.publicName }
+
+ { navigator.clipboard.writeText(String(item.id)); setToast({ type: 'success', message: `ID ${item.id} copied!` }); } }>#{item.id} + | + sprite:{ item.spriteId } + | + { item.itemName } + + { item.type === 's' ? 'FLOOR' : 'WALL' } + + { item.usageCount > 0 && { item.usageCount } in use } +
+
+
+ + +
+
{ /* Basic Info */ } -
- Basic Info -
-
- - setField('itemName', e.target.value) } /> -
-
- - setField('publicName', e.target.value) } /> -
-
- - setField('spriteId', Number(e.target.value)) } /> -
-
- - -
+
+
+ + +
+
+ + setField('publicName', e.target.value) } /> +
+
+ + +
+
+ +
{ /* Dimensions */ } -
- Dimensions -
+
+
Dimensions
+
- - setField('width', Number(e.target.value)) } /> + + setField('width', Number(e.target.value)) } />
- - setField('length', Number(e.target.value)) } /> + + setField('length', Number(e.target.value)) } />
- - setField('stackHeight', Number(e.target.value)) } /> + + setField('stackHeight', Number(e.target.value)) } />
{ /* Permissions */ } -
- Permissions -
+
+
Permissions
+
{ [ 'allowStack', 'allowWalk', 'allowSit', 'allowLay', 'allowGift', 'allowTrade', 'allowRecycle', 'allowMarketplaceSell', 'allowInventoryStack' ].map(key => ( -
{ /* Interaction */ } -
- Interaction -
+
+
Interaction
+
- - setField('interactionType', e.target.value) }> - { interactions.map(i => ( - - )) } + { interactions.map(i => ) }
- - setField('interactionModesCount', Number(e.target.value)) } /> + + setField('interactionModesCount', Number(e.target.value)) } /> +
+
+ + setField('customparams', e.target.value) } />
-
-
- - setField('customparams', e.target.value) } />
- { /* Catalog References */ } - { catalogItems.length > 0 && -
- Catalog ({ catalogItems.length }) -
- { catalogItems.map(ci => ( -
- { ci.catalogName } (page: { ci.pageName }) - { ci.costCredits }c + { ci.costPoints }p -
- )) } -
-
- } - - { /* FurniData.json Entry */ } - { furniDataEntry && -
- FurniData.json -
- { Object.entries(furniDataEntry).map(([ key, value ]) => ( -
- { key } - { String(value ?? '') } -
- )) } -
-
- } - { /* Actions */ } - - - + - + { confirmDelete ? 'Confirm' : 'Delete' } + +
); }; diff --git a/src/components/furni-editor/views/FurniEditorSearchView.tsx b/src/components/furni-editor/views/FurniEditorSearchView.tsx index 7b3f8c6..1327b45 100644 --- a/src/components/furni-editor/views/FurniEditorSearchView.tsx +++ b/src/components/furni-editor/views/FurniEditorSearchView.tsx @@ -1,5 +1,7 @@ import { FC, useCallback, useEffect, useState } from 'react'; -import { Button, Column, Flex, Text } from '../../../common'; +import { FaSearch } from 'react-icons/fa'; +import { Column, Text } from '../../../common'; +import { LayoutFurniIconImageView } from '../../../common/layout/LayoutFurniIconImageView'; import { FurniItem } from '../../../hooks/furni-editor'; interface FurniEditorSearchViewProps @@ -12,6 +14,8 @@ interface FurniEditorSearchViewProps onSelect: (id: number) => void; } +const inputClass = 'text-[14px] border border-[#c5cdd6] rounded px-2 py-1.5 bg-white focus:outline-none focus:border-[#1e7295] transition-colors w-full'; + export const FurniEditorSearchView: FC = props => { const { items, total, page, loading, onSearch, onSelect } = props; @@ -37,97 +41,122 @@ export const FurniEditorSearchView: FC = props => return ( - - - Search + { /* Search Bar */ } +
+
+ setQuery(e.target.value) } onKeyDown={ handleKeyDown } /> - - - Type - setTypeFilter(e.target.value) }> - - + + - - - +
+ +
- - - - - - - - - - - - - - { items.map(item => ( - onSelect(item.id) } - > - - - - - - - - )) } - { items.length === 0 && !loading && - - - - } - -
IDSpriteNamePublic NameTypeInteraction
{ item.id }{ item.spriteId }{ item.itemName }{ item.publicName } - - { item.type === 's' ? 'Floor' : 'Wall' } - - { item.interactionType || '-' }
No items found
-
+ { /* Results counter */ } + { total > 0 && +
+ { total } items found { totalPages > 1 && - Page { page }/{ totalPages } } +
+ } + { /* Results Table */ } +
+ { loading && +
+
Loading...
+
+ } + + { !loading && items.length === 0 && +
+ No items found +
+ } + + { !loading && items.length > 0 && + + + + + + + + + + + + + + { items.map(item => ( + onSelect(item.id) } + > + + + + + + + + + )) } + +
IDSpriteNamePublic NameTypeInteraction
+
+ +
+
{ item.id }{ item.spriteId }{ item.itemName }{ item.publicName } + + { item.type === 's' ? 'Floor' : 'Wall' } + + { item.interactionType || '-' }
+ } +
+ + { /* Pagination */ } { totalPages > 1 && - - - { total } items - Page { page }/{ totalPages } - - - - - - + +
+
} ); diff --git a/src/hooks/furni-editor/useFurniEditor.ts b/src/hooks/furni-editor/useFurniEditor.ts index e4258e5..5d2c95a 100644 --- a/src/hooks/furni-editor/useFurniEditor.ts +++ b/src/hooks/furni-editor/useFurniEditor.ts @@ -1,4 +1,7 @@ +import { FurniEditorBySpriteComposer, FurniEditorCreateComposer, FurniEditorDeleteComposer, FurniEditorDetailComposer, FurniEditorDetailEvent as FurniEditorDetailMsgEvent, FurniEditorInteractionsComposer, FurniEditorInteractionsEvent as FurniEditorInteractionsMsgEvent, FurniEditorResultEvent as FurniEditorResultMsgEvent, FurniEditorSearchComposer, FurniEditorSearchEvent as FurniEditorSearchMsgEvent, FurniEditorUpdateComposer } from '@nitrots/nitro-renderer'; import { useCallback, useState } from 'react'; +import { SendMessageComposer } from '../../api'; +import { useMessageEvent } from '../events'; export interface FurniItem { @@ -46,18 +49,6 @@ export interface CatalogRef pageName: string; } -const API_BASE = '/api/admin/furni-editor'; - -async function apiFetch(url: string, options?: RequestInit): Promise -{ - const res = await fetch(url, { credentials: 'include', ...options }); - const data = await res.json(); - - if(!res.ok || data.error) throw new Error(data.error || 'API error'); - - return data; -} - export const useFurniEditor = () => { const [ items, setItems ] = useState([]); @@ -69,171 +60,114 @@ export const useFurniEditor = () => const [ catalogItems, setCatalogItems ] = useState([]); const [ interactions, setInteractions ] = useState([]); const [ furniDataEntry, setFurniDataEntry ] = useState | null>(null); + const [ lastResult, setLastResult ] = useState<{ success: boolean; message: string; id: number } | null>(null); const clearError = useCallback(() => setError(null), []); - const searchItems = useCallback(async (query: string, type: string, pg: number) => + // Listen for search results + useMessageEvent(FurniEditorSearchMsgEvent, (event: any) => + { + const parser = event.getParser(); + + setItems(parser.items); + setTotal(parser.total); + setPage(parser.page); + setLoading(false); + }); + + // Listen for detail results + useMessageEvent(FurniEditorDetailMsgEvent, (event: any) => + { + const parser = event.getParser(); + + setSelectedItem(parser.item as FurniDetail); + setCatalogItems(parser.catalogItems as CatalogRef[]); + + try + { + setFurniDataEntry(parser.furniDataJson ? JSON.parse(parser.furniDataJson) : null); + } + catch + { + setFurniDataEntry(null); + } + + setLoading(false); + }); + + // Listen for interactions results + useMessageEvent(FurniEditorInteractionsMsgEvent, (event: any) => + { + const parser = event.getParser(); + + setInteractions(parser.interactions); + }); + + // Listen for operation results (update/create/delete) + useMessageEvent(FurniEditorResultMsgEvent, (event: any) => + { + const parser = event.getParser(); + + setLastResult({ success: parser.success, message: parser.message, id: parser.id }); + setLoading(false); + + if(!parser.success) + { + setError(parser.message); + } + }); + + const searchItems = useCallback((query: string, type: string, pg: number) => { setLoading(true); setError(null); - - try - { - const params = new URLSearchParams({ q: query, limit: '20', page: String(pg) }); - - if(type) params.set('type', type); - - const data = await apiFetch<{ items: FurniItem[]; total: number; page: number }>(`${ API_BASE }?${ params }`); - - setItems(data.items); - setTotal(data.total); - setPage(data.page); - } - catch(e: any) - { - setError(e.message); - } - finally - { - setLoading(false); - } + SendMessageComposer(new FurniEditorSearchComposer(query, type, pg)); }, []); - const loadDetail = useCallback(async (id: number): Promise => + const loadDetail = useCallback((id: number) => { setLoading(true); setError(null); - - try - { - const data = await apiFetch<{ item: FurniDetail; catalogItems: CatalogRef[]; furniDataEntry: Record | null }>(`${ API_BASE }/detail?id=${ id }`); - - setSelectedItem(data.item); - setCatalogItems(data.catalogItems); - setFurniDataEntry(data.furniDataEntry); - - return true; - } - catch(e: any) - { - setError(e.message); - - return false; - } - finally - { - setLoading(false); - } + SendMessageComposer(new FurniEditorDetailComposer(id)); }, []); - const updateItem = useCallback(async (id: number, fields: Record) => + const loadBySpriteId = useCallback((spriteId: number) => { setLoading(true); setError(null); - - try - { - await apiFetch(`${ API_BASE }/update?id=${ id }`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(fields) - }); - - return true; - } - catch(e: any) - { - setError(e.message); - - return false; - } - finally - { - setLoading(false); - } + SendMessageComposer(new FurniEditorBySpriteComposer(spriteId)); }, []); - const createItem = useCallback(async (fields: Record) => + const updateItem = useCallback((id: number, fields: Record) => { setLoading(true); setError(null); - - try - { - const data = await apiFetch<{ id: number }>(`${ API_BASE }`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(fields) - }); - - return data.id; - } - catch(e: any) - { - setError(e.message); - - return null; - } - finally - { - setLoading(false); - } + SendMessageComposer(new FurniEditorUpdateComposer(id, JSON.stringify(fields))); }, []); - const deleteItem = useCallback(async (id: number) => + const createItem = useCallback((fields: Record) => { setLoading(true); setError(null); - - try - { - await apiFetch(`${ API_BASE }/delete?id=${ id }`, { method: 'POST' }); - - return true; - } - catch(e: any) - { - setError(e.message); - - return false; - } - finally - { - setLoading(false); - } + SendMessageComposer(new FurniEditorCreateComposer(JSON.stringify(fields))); }, []); - const loadInteractions = useCallback(async () => + const deleteItem = useCallback((id: number) => { - try - { - const data = await apiFetch<{ interactions: Array }>(`${ API_BASE }/interactions`); - - setInteractions(data.interactions.map(i => typeof i === 'string' ? i : i.name)); - } - catch {} + setLoading(true); + setError(null); + SendMessageComposer(new FurniEditorDeleteComposer(id)); }, []); - const loadBySpriteId = useCallback(async (spriteId: number): Promise => + const loadInteractions = useCallback(() => { - try - { - const data = await apiFetch<{ id: number }>(`${ API_BASE }/by-sprite?spriteId=${ spriteId }`); - - return await loadDetail(data.id); - } - catch(e: any) - { - setError(e.message); - - return false; - } - }, [ loadDetail ]); + SendMessageComposer(new FurniEditorInteractionsComposer()); + }, []); return { items, total, page, loading, error, clearError, selectedItem, setSelectedItem, catalogItems, furniDataEntry, - interactions, + interactions, lastResult, searchItems, loadDetail, loadBySpriteId, updateItem, createItem, deleteItem, loadInteractions }; }; From bf05948e8650188653049728fc5d105c049183cb Mon Sep 17 00:00:00 2001 From: Lorenzune Date: Tue, 24 Mar 2026 02:11:54 +0100 Subject: [PATCH 3/4] Polish wired extra and trigger editors --- public/UITexts.example | 2 + src/api/wired/WiredActionLayoutCode.ts | 2 + .../views/actions/WiredActionLayoutView.tsx | 6 ++ .../extras/WiredExtraAnimationTimeView.tsx | 6 +- .../extras/WiredExtraMoveNoAnimationView.tsx | 12 +--- .../views/extras/WiredExtraRandomView.tsx | 71 +++++++++++++++++++ .../views/extras/WiredExtraUnseenView.tsx | 17 +++++ .../WiredTriggerAvatarSaysSomethingView.tsx | 9 ++- 8 files changed, 109 insertions(+), 16 deletions(-) create mode 100644 src/components/wired/views/extras/WiredExtraRandomView.tsx create mode 100644 src/components/wired/views/extras/WiredExtraUnseenView.tsx diff --git a/public/UITexts.example b/public/UITexts.example index 539f71a..887390c 100644 --- a/public/UITexts.example +++ b/public/UITexts.example @@ -29,6 +29,8 @@ "wiredfurni.params.anim_time.title": "Durata animazione movimento", "wiredfurni.params.anim_time.description": "Regola la velocita dello slide per i Wired che spostano utenti e furni.", "wiredfurni.params.anim_time.value": "%ms% ms", + "wiredfurni.params.pickamount": "Seleziona %picks% effetti", + "wiredfurni.params.skipactions": "Evita gli effetti delle ultime %skips% esecuzioni.", "wiredfurni.params.mov_no_animation.title": "Animazione movimento", "wiredfurni.params.mov_no_animation.description": "Questo extra disattiva lo slide per i Wired che spostano utenti e furni.", "wiredfurni.params.select_options": "Seleziona opzioni:", diff --git a/src/api/wired/WiredActionLayoutCode.ts b/src/api/wired/WiredActionLayoutCode.ts index 7508b70..0e38692 100644 --- a/src/api/wired/WiredActionLayoutCode.ts +++ b/src/api/wired/WiredActionLayoutCode.ts @@ -60,4 +60,6 @@ export class WiredActionLayoutCode public static MOVE_NO_ANIMATION_EXTRA: number = 59; public static ANIMATION_TIME_EXTRA: number = 60; public static MOVE_PHYSICS_EXTRA: number = 61; + public static UNSEEN_EXTRA: number = 62; + public static RANDOM_EXTRA: number = 63; } diff --git a/src/components/wired/views/actions/WiredActionLayoutView.tsx b/src/components/wired/views/actions/WiredActionLayoutView.tsx index d217f61..e218f6d 100644 --- a/src/components/wired/views/actions/WiredActionLayoutView.tsx +++ b/src/components/wired/views/actions/WiredActionLayoutView.tsx @@ -56,6 +56,8 @@ import { WiredExtraAnimationTimeView } from '../extras/WiredExtraAnimationTimeVi import { WiredExtraMoveCarryUsersView } from '../extras/WiredExtraMoveCarryUsersView'; import { WiredExtraMoveNoAnimationView } from '../extras/WiredExtraMoveNoAnimationView'; import { WiredExtraMovePhysicsView } from '../extras/WiredExtraMovePhysicsView'; +import { WiredExtraRandomView } from '../extras/WiredExtraRandomView'; +import { WiredExtraUnseenView } from '../extras/WiredExtraUnseenView'; export const WiredActionLayoutView = (code: number) => { @@ -177,6 +179,10 @@ export const WiredActionLayoutView = (code: number) => return ; case WiredActionLayoutCode.MOVE_PHYSICS_EXTRA: return ; + case WiredActionLayoutCode.UNSEEN_EXTRA: + return ; + case WiredActionLayoutCode.RANDOM_EXTRA: + return ; case WiredActionLayoutCode.SEND_SIGNAL: return ; } diff --git a/src/components/wired/views/extras/WiredExtraAnimationTimeView.tsx b/src/components/wired/views/extras/WiredExtraAnimationTimeView.tsx index 6fee1a2..48de6d3 100644 --- a/src/components/wired/views/extras/WiredExtraAnimationTimeView.tsx +++ b/src/components/wired/views/extras/WiredExtraAnimationTimeView.tsx @@ -1,5 +1,5 @@ import { FC, useEffect, useState } from 'react'; -import { LocalizeText, WiredFurniType } from '../../../../api'; +import { WiredFurniType } from '../../../../api'; import { Slider, Text } from '../../../../common'; import { useWired } from '../../../../hooks'; import { WiredExtraBaseView } from './WiredExtraBaseView'; @@ -37,9 +37,7 @@ export const WiredExtraAnimationTimeView: FC<{}> = () => return (
- { LocalizeText('wiredfurni.params.anim_time.title') } - { LocalizeText('wiredfurni.params.anim_time.description') } - { LocalizeText('wiredfurni.params.anim_time.value', [ 'ms' ], [ duration.toString() ]) } + { duration } ms setDuration(normalizeDuration(Array.isArray(value) ? value[0] : Number(value))) } />
diff --git a/src/components/wired/views/extras/WiredExtraMoveNoAnimationView.tsx b/src/components/wired/views/extras/WiredExtraMoveNoAnimationView.tsx index 0e13527..3a67fa5 100644 --- a/src/components/wired/views/extras/WiredExtraMoveNoAnimationView.tsx +++ b/src/components/wired/views/extras/WiredExtraMoveNoAnimationView.tsx @@ -1,6 +1,5 @@ import { FC } from 'react'; -import { LocalizeText, WiredFurniType } from '../../../../api'; -import { Text } from '../../../../common'; +import { WiredFurniType } from '../../../../api'; import { useWired } from '../../../../hooks'; import { WiredExtraBaseView } from './WiredExtraBaseView'; @@ -14,12 +13,5 @@ export const WiredExtraMoveNoAnimationView: FC<{}> = () => setStringParam(''); }; - return ( - -
- { LocalizeText('wiredfurni.params.mov_no_animation.title') } - { LocalizeText('wiredfurni.params.mov_no_animation.description') } -
-
- ); + return ; }; diff --git a/src/components/wired/views/extras/WiredExtraRandomView.tsx b/src/components/wired/views/extras/WiredExtraRandomView.tsx new file mode 100644 index 0000000..ef30d06 --- /dev/null +++ b/src/components/wired/views/extras/WiredExtraRandomView.tsx @@ -0,0 +1,71 @@ +import { FC, useEffect, useState } from 'react'; +import { LocalizeText, WiredFurniType } from '../../../../api'; +import { Slider, Text } from '../../../../common'; +import { useWired } from '../../../../hooks'; +import { WiredExtraBaseView } from './WiredExtraBaseView'; + +const MIN_PICK_AMOUNT = 1; +const MIN_SKIP_EXECUTIONS = 0; +const MAX_RANDOM_VALUE = 1000; + +const normalizePickAmount = (value: number) => +{ + if(isNaN(value)) return MIN_PICK_AMOUNT; + + return Math.max(MIN_PICK_AMOUNT, Math.min(MAX_RANDOM_VALUE, Math.floor(value))); +}; + +const normalizeSkipExecutions = (value: number) => +{ + if(isNaN(value)) return MIN_SKIP_EXECUTIONS; + + return Math.max(MIN_SKIP_EXECUTIONS, Math.min(MAX_RANDOM_VALUE, Math.floor(value))); +}; + +export const WiredExtraRandomView: FC<{}> = () => +{ + const { trigger = null, setIntParams = null, setStringParam = null } = useWired(); + const [ pickAmount, setPickAmount ] = useState(MIN_PICK_AMOUNT); + const [ skipExecutions, setSkipExecutions ] = useState(MIN_SKIP_EXECUTIONS); + + useEffect(() => + { + if(!trigger) return; + + setPickAmount(normalizePickAmount((trigger.intData.length > 0) ? trigger.intData[0] : MIN_PICK_AMOUNT)); + setSkipExecutions(normalizeSkipExecutions((trigger.intData.length > 1) ? trigger.intData[1] : MIN_SKIP_EXECUTIONS)); + }, [ trigger ]); + + const save = () => + { + setIntParams([ normalizePickAmount(pickAmount), normalizeSkipExecutions(skipExecutions) ]); + setStringParam(''); + }; + + return ( + +
+
+ { LocalizeText('wiredfurni.params.pickamount', [ 'picks' ], [ pickAmount.toString() ]) } + setPickAmount(normalizePickAmount(Array.isArray(value) ? value[0] : Number(value))) } /> + { pickAmount } +
+
+ { LocalizeText('wiredfurni.params.skipactions', [ 'skips' ], [ skipExecutions.toString() ]) } + setSkipExecutions(normalizeSkipExecutions(Array.isArray(value) ? value[0] : Number(value))) } /> + { skipExecutions } +
+
+
+ ); +}; diff --git a/src/components/wired/views/extras/WiredExtraUnseenView.tsx b/src/components/wired/views/extras/WiredExtraUnseenView.tsx new file mode 100644 index 0000000..4fc091a --- /dev/null +++ b/src/components/wired/views/extras/WiredExtraUnseenView.tsx @@ -0,0 +1,17 @@ +import { FC } from 'react'; +import { WiredFurniType } from '../../../../api'; +import { useWired } from '../../../../hooks'; +import { WiredExtraBaseView } from './WiredExtraBaseView'; + +export const WiredExtraUnseenView: FC<{}> = () => +{ + const { setIntParams = null, setStringParam = null } = useWired(); + + const save = () => + { + setIntParams([]); + setStringParam(''); + }; + + return ; +}; diff --git a/src/components/wired/views/triggers/WiredTriggerAvatarSaysSomethingView.tsx b/src/components/wired/views/triggers/WiredTriggerAvatarSaysSomethingView.tsx index 048c20c..531fa2b 100644 --- a/src/components/wired/views/triggers/WiredTriggerAvatarSaysSomethingView.tsx +++ b/src/components/wired/views/triggers/WiredTriggerAvatarSaysSomethingView.tsx @@ -16,10 +16,11 @@ export const WiredTriggerAvatarSaysSomethingView: FC<{}> = () => const [ hideMessage, setHideMessage ] = useState(false); const [ ownerOnly, setOwnerOnly ] = useState(false); const { trigger = null, setStringParam = null, setIntParams = null } = useWired(); + const isAnyTextMode = (matchMode === MATCH_ALL); const save = () => { - setStringParam(message); + setStringParam(isAnyTextMode ? '' : message); setIntParams([ matchMode, hideMessage ? 1 : 0, @@ -39,7 +40,11 @@ export const WiredTriggerAvatarSaysSomethingView: FC<{}> = () =>
{ LocalizeText('wiredfurni.params.whatissaid') } - setMessage(event.target.value) } /> + setMessage(event.target.value) } />
From df1437c488e867d32403a0fbd5066b9fa50aa14e Mon Sep 17 00:00:00 2001 From: duckietm Date: Tue, 24 Mar 2026 11:56:51 +0100 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=86=99=20Cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/utils/PrefixUtils.ts | 32 --- .../layout/CatalogLayoutCustomPrefixView.tsx | 215 ++++----------- .../furni-editor/FurniEditorView.tsx | 33 +-- .../views/FurniEditorCreateView.tsx | 148 ++++------ .../views/FurniEditorEditView.tsx | 255 +++++++++--------- .../views/FurniEditorSearchView.tsx | 171 +++++------- .../views/prefix/InventoryPrefixView.tsx | 28 +- .../widgets/chat/ChatWidgetMessageView.tsx | 26 +- .../WiredTriggerAvatarSaysSomethingView.tsx | 9 +- src/css/index.css | 21 -- src/hooks/furni-editor/useFurniEditor.ts | 204 +++++++++----- src/layout/PrefixPreview.tsx | 48 ---- src/layout/index.ts | 1 - 13 files changed, 506 insertions(+), 685 deletions(-) delete mode 100644 src/layout/PrefixPreview.tsx diff --git a/src/api/utils/PrefixUtils.ts b/src/api/utils/PrefixUtils.ts index d010084..5da5133 100644 --- a/src/api/utils/PrefixUtils.ts +++ b/src/api/utils/PrefixUtils.ts @@ -6,9 +6,6 @@ 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[] => @@ -43,40 +40,11 @@ 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 f0158f2..e723833 100644 --- a/src/components/catalog/views/page/layout/CatalogLayoutCustomPrefixView.tsx +++ b/src/components/catalog/views/page/layout/CatalogLayoutCustomPrefixView.tsx @@ -1,7 +1,5 @@ import { PurchasePrefixComposer } from '@nitrots/nitro-renderer'; import { FC, useEffect, useMemo, useRef, useState } from 'react'; -import { LocalizeText, SendMessageComposer, PRESET_PREFIX_EFFECTS, generateGradientColors } from '../../../../../api'; -import { PrefixPreview } from '../../../../../layout'; import { LocalizeText, SanitizeHtml, SendMessageComposer, PRESET_PREFIX_EFFECTS, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../../api'; import { CatalogLayoutProps } from './CatalogLayout.types'; import data from '@emoji-mart/data'; @@ -24,13 +22,11 @@ export const CatalogLayoutCustomPrefixView: FC = props => }, [ page, hideNavigation ]); const [ prefixText, setPrefixText ] = useState(''); - const [ colorMode, setColorMode ] = useState<'single' | 'perLetter' | 'gradient'>('single'); + const [ colorMode, setColorMode ] = useState<'single' | 'perLetter'>('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(''); @@ -40,16 +36,15 @@ 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, gradientStart, gradientEnd ]); + }, [ colorMode, singleColor, letterColors, prefixText ]); + + const previewColors = useMemo(() => + { + return parsePrefixColors(prefixText || '...', colorString || '#FFFFFF'); + }, [ prefixText, colorString ]); const isValid = useMemo(() => { @@ -57,12 +52,9 @@ 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, gradientStart, gradientEnd ]); + }, [ prefixText, colorMode, singleColor, colorString ]); const handlePurchase = () => { @@ -80,7 +72,7 @@ export const CatalogLayoutCustomPrefixView: FC = props => setSingleColor(color); setCustomColorInput(color); } - else if(colorMode === 'perLetter' && selectedLetterIndex !== null) + else if(selectedLetterIndex !== null) { setLetterColors(prev => ({ ...prev, [selectedLetterIndex]: color })); setCustomColorInput(color); @@ -104,7 +96,7 @@ export const CatalogLayoutCustomPrefixView: FC = props => { setSingleColor(value); } - else if(colorMode === 'perLetter' && selectedLetterIndex !== null) + else if(selectedLetterIndex !== null) { setLetterColors(prev => ({ ...prev, [selectedLetterIndex]: value })); } @@ -129,12 +121,18 @@ 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) && } @@ -150,37 +148,20 @@ export const CatalogLayoutCustomPrefixView: FC = props => } }>
- - - Username + + { selectedIcon && { selectedIcon } } + + {'{'} + { hasMultiColor + ? [ ...(prefixText || '...') ].map((char, i) => ( + { char } + )) + : (prefixText || '...') + } + {'}'} + -
- - { /* Chat Bubble Preview */ } -
-
{ LocalizeText('catalog.prefix.chat.preview') }
-
-
- { (prefixText || '...') && - } - Username: - Hello everyone! -
-
-
+ Username
{ /* Text + Icon Row */ } @@ -237,7 +218,6 @@ export const CatalogLayoutCustomPrefixView: FC = props => { showIconPicker && ( <>
setShowIconPicker(false) } /> -
= props => 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)', - borderRight: '1px solid rgba(0,0,0,0.1)', opacity: colorMode === 'perLetter' ? 1 : 0.6 } } onClick={ () => { setColorMode('perLetter'); if(prefixText.length > 0) setSelectedLetterIndex(0); } }> { LocalizeText('catalog.prefix.color.per.letter') } -
- { /* 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 && (
@@ -427,49 +347,6 @@ export const CatalogLayoutCustomPrefixView: FC = props =>
) } - { /* Color Palette (single & perLetter modes) */ } - { colorMode !== 'gradient' && ( -
- { colorMode === 'perLetter' && selectedLetterIndex !== null && - - { LocalizeText('catalog.prefix.color.selected') } "{ prefixText[selectedLetterIndex] || '' }" - - } -
- { PRESET_COLORS.map((color, idx) => - { - const isActive = currentActiveColor === color; - return ( -
handleColorSelect(color) } /> - ); - }) } -
-
- { /* Color Palette */ }
{ colorMode === 'perLetter' && selectedLetterIndex !== null && @@ -506,22 +383,28 @@ export const CatalogLayoutCustomPrefixView: FC = props => boxShadow: `0 0 6px ${ customColorInput }40, inset 0 1px 0 rgba(255,255,255,0.3)` } }> handleCustomColorChange(e.target.value) } /> -
+ onChange={ e => handleColorSelect(e.target.value) } /> + + handleCustomColorChange(e.target.value) } />
- ) } +
{ /* Purchase Footer */ }
= () => { @@ -18,8 +16,8 @@ export const FurniEditorView: FC<{}> = () => const { items, total, page, loading, error, clearError, selectedItem, catalogItems, furniDataEntry, - interactions, lastResult, - searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, createItem, loadInteractions + interactions, + searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions } = useFurniEditor(); useEffect(() => @@ -59,14 +57,13 @@ export const FurniEditorView: FC<{}> = () => useEffect(() => { - const handler = (e: CustomEvent<{ spriteId: number }>) => + const handler = async (e: CustomEvent<{ spriteId: number }>) => { const { spriteId } = e.detail; - if(!spriteId || spriteId <= 0) return; + const ok = await loadBySpriteId(spriteId); - loadBySpriteId(spriteId); - setActiveTab(TAB_EDIT); + if(ok) setActiveTab(TAB_EDIT); }; window.addEventListener('furni-editor:open', handler as EventListener); @@ -74,10 +71,11 @@ export const FurniEditorView: FC<{}> = () => return () => window.removeEventListener('furni-editor:open', handler as EventListener); }, [ loadBySpriteId ]); - const handleSelect = useCallback((id: number) => + const handleSelect = useCallback(async (id: number) => { - loadDetail(id); - setActiveTab(TAB_EDIT); + const ok = await loadDetail(id); + + if(ok) setActiveTab(TAB_EDIT); }, [ loadDetail ]); const handleBack = useCallback(() => @@ -90,17 +88,10 @@ export const FurniEditorView: FC<{}> = () => setIsVisible(false); }, []); - const handleCreated = useCallback((id: number) => - { - loadDetail(id); - setActiveTab(TAB_EDIT); - }, [ loadDetail ]); - - if(!GetSessionDataManager()?.isModerator) return null; if(!isVisible) return null; return ( - + setActiveTab(TAB_SEARCH) }> @@ -136,7 +127,6 @@ export const FurniEditorView: FC<{}> = () => furniDataEntry={ furniDataEntry } interactions={ interactions } loading={ loading } - lastResult={ lastResult } onUpdate={ updateItem } onDelete={ deleteItem } onBack={ handleBack } @@ -144,7 +134,6 @@ export const FurniEditorView: FC<{}> = () => /> } - ); diff --git a/src/components/furni-editor/views/FurniEditorCreateView.tsx b/src/components/furni-editor/views/FurniEditorCreateView.tsx index d7461e0..f47530c 100644 --- a/src/components/furni-editor/views/FurniEditorCreateView.tsx +++ b/src/components/furni-editor/views/FurniEditorCreateView.tsx @@ -1,23 +1,18 @@ -import { FC, useCallback, useEffect, useState } from 'react'; -import { FaPlus } from 'react-icons/fa'; -import { Column } from '../../../common'; +import { FC, useCallback, useState } from 'react'; +import { Button, Column, Flex, Text } from '../../../common'; interface FurniEditorCreateViewProps { interactions: string[]; loading: boolean; - lastResult: { success: boolean; message: string; id: number } | null; - onCreate: (fields: Record) => void; + onCreate: (fields: Record) => Promise; onCreated: (id: number) => void; } -const inputClass = 'text-[11px] border-2 border-card-grid-item-border rounded px-2 py-1 bg-white focus:outline-none focus:border-primary transition-colors w-full'; -const labelClass = 'text-[9px] text-[#666] uppercase font-bold mb-0.5 block'; - export const FurniEditorCreateView: FC = props => { - const { interactions, loading, lastResult, onCreate, onCreated } = props; - const [ toast, setToast ] = useState<{ type: 'success' | 'error'; message: string; id?: number } | null>(null); + const { interactions, loading, onCreate, onCreated } = props; + const [ success, setSuccess ] = useState(null); const [ form, setForm ] = useState({ itemName: '', @@ -41,50 +36,39 @@ export const FurniEditorCreateView: FC = props => customparams: '', }); - useEffect(() => - { - if(!lastResult) return; - - if(lastResult.success && lastResult.id > 0) - { - setToast({ type: 'success', message: `Item created with ID #${ lastResult.id }`, id: lastResult.id }); - setTimeout(() => onCreated(lastResult.id), 1500); - } - else if(!lastResult.success) - { - setToast({ type: 'error', message: lastResult.message }); - } - - const timer = setTimeout(() => setToast(null), 3000); - return () => clearTimeout(timer); - }, [ lastResult ]); - const setField = useCallback((key: string, value: unknown) => { setForm(prev => ({ ...prev, [key]: value })); + setSuccess(null); }, []); - const handleCreate = useCallback(() => + const handleCreate = useCallback(async () => { if(!form.itemName || !form.publicName) return; - onCreate(form); - }, [ form, onCreate ]); + + const id = await onCreate(form); + + if(id) + { + setSuccess(id); + setTimeout(() => onCreated(id), 1000); + } + }, [ form, onCreate, onCreated ]); + + const inputClass = 'form-control form-control-sm'; + const labelClass = 'text-[11px] font-bold text-[#333] mb-0'; return ( - { /* Toast */ } - { toast && -
- { toast.message } + { success && +
+ Item created with ID #{ success }!
} - { /* Basic Info */ } -
-
- Basic Info -
-
+
+ Basic Info +
setField('itemName', e.target.value) } placeholder="my_custom_furni" /> @@ -99,7 +83,7 @@ export const FurniEditorCreateView: FC = props =>
- setField('type', e.target.value) }> @@ -107,12 +91,9 @@ export const FurniEditorCreateView: FC = props =>
- { /* Dimensions */ } -
-
- Dimensions -
-
+
+ Dimensions +
setField('width', Number(e.target.value)) } /> @@ -128,17 +109,14 @@ export const FurniEditorCreateView: FC = props =>
- { /* Permissions */ } -
-
- Permissions -
-
+
+ Permissions +
{ [ 'allowStack', 'allowWalk', 'allowSit', 'allowLay', 'allowGift', 'allowTrade', 'allowRecycle', 'allowMarketplaceSell', 'allowInventoryStack' ].map(key => ( -
- { /* Interaction */ } -
-
- Interaction +
+ Interaction +
+
+ + +
+
+ + setField('interactionModesCount', Number(e.target.value)) } /> +
-
-
-
- - -
-
- - setField('interactionModesCount', Number(e.target.value)) } /> -
-
-
- - setField('customparams', e.target.value) } /> -
+
+ + setField('customparams', e.target.value) } />
- { /* Create Button */ } -
- -
+ + + ); }; diff --git a/src/components/furni-editor/views/FurniEditorEditView.tsx b/src/components/furni-editor/views/FurniEditorEditView.tsx index bb50765..dc648ef 100644 --- a/src/components/furni-editor/views/FurniEditorEditView.tsx +++ b/src/components/furni-editor/views/FurniEditorEditView.tsx @@ -1,7 +1,5 @@ import { FC, useCallback, useEffect, useState } from 'react'; -import { FaSave, FaSync, FaTrash, FaArrowLeft } from 'react-icons/fa'; -import { Column } from '../../../common'; -import { LayoutFurniIconImageView } from '../../../common/layout/LayoutFurniIconImageView'; +import { Button, Column, Flex, Text } from '../../../common'; import { CatalogRef, FurniDetail } from '../../../hooks/furni-editor'; interface FurniEditorEditViewProps @@ -11,24 +9,20 @@ interface FurniEditorEditViewProps furniDataEntry: Record | null; interactions: string[]; loading: boolean; - lastResult: { success: boolean; message: string; id: number } | null; - onUpdate: (id: number, fields: Record) => void; - onDelete: (id: number) => void; + onUpdate: (id: number, fields: Record) => Promise; + onDelete: (id: number) => Promise; onBack: () => void; onRefresh: (id: number) => void; } -const ic = 'text-[13px] border border-[#c5cdd6] rounded px-2 py-1 bg-white focus:outline-none focus:border-[#1e7295] focus:shadow-[0_0_0_1px_rgba(30,114,149,0.15)] transition-all w-full'; -const ro = 'text-[13px] border border-[#d5dbe0] rounded px-2 py-1 bg-[#f0f2f4] text-[#777] w-full cursor-not-allowed'; -const lb = 'text-[11px] text-[#1e7295] uppercase font-bold tracking-wider leading-none'; -const sectionTitle = 'text-[12px] text-[#1e7295] uppercase font-bold tracking-wider'; - export const FurniEditorEditView: FC = props => { - const { item, catalogItems, furniDataEntry, interactions, loading, lastResult, onUpdate, onDelete, onBack, onRefresh } = props; + const { item, catalogItems, furniDataEntry, interactions, loading, onUpdate, onDelete, onBack, onRefresh } = props; const [ form, setForm ] = useState({ + itemName: '', publicName: '', + spriteId: 0, type: 's', width: 1, length: 1, @@ -48,14 +42,15 @@ export const FurniEditorEditView: FC = props => }); const [ confirmDelete, setConfirmDelete ] = useState(false); - const [ toast, setToast ] = useState<{ type: 'success' | 'error'; message: string } | null>(null); useEffect(() => { if(!item) return; setForm({ + itemName: item.itemName || '', publicName: item.publicName || '', + spriteId: item.spriteId || 0, type: item.type || 's', width: item.width || 1, length: item.length || 1, @@ -77,119 +72,105 @@ export const FurniEditorEditView: FC = props => setConfirmDelete(false); }, [ item ]); - useEffect(() => - { - if(!lastResult) return; - - setToast({ type: lastResult.success ? 'success' : 'error', message: lastResult.message }); - if(lastResult.success && lastResult.id > 0) onRefresh(lastResult.id); - - const timer = setTimeout(() => setToast(null), 3000); - return () => clearTimeout(timer); - }, [ lastResult ]); - const setField = useCallback((key: string, value: unknown) => { setForm(prev => ({ ...prev, [key]: value })); }, []); - const handleSave = useCallback(() => + const handleSave = useCallback(async () => { - onUpdate(item.id, form); - }, [ item, form, onUpdate ]); + const ok = await onUpdate(item.id, form); - const handleDelete = useCallback(() => + if(ok) onRefresh(item.id); + }, [ item, form, onUpdate, onRefresh ]); + + const handleDelete = useCallback(async () => { if(!confirmDelete) return setConfirmDelete(true); - onDelete(item.id); - }, [ confirmDelete, item, onDelete ]); + + const ok = await onDelete(item.id); + + if(ok) onBack(); + }, [ confirmDelete, item, onDelete, onBack ]); + + const inputClass = 'form-control form-control-sm'; + const labelClass = 'text-[11px] font-bold text-[#333] mb-0'; return ( - - { toast && -
- { toast.message } -
- } - - { /* Header */ } -
-
- -
-
-
{ item.publicName }
-
- { navigator.clipboard.writeText(String(item.id)); setToast({ type: 'success', message: `ID ${item.id} copied!` }); } }>#{item.id} - | - sprite:{ item.spriteId } - | - { item.itemName } - - { item.type === 's' ? 'FLOOR' : 'WALL' } - - { item.usageCount > 0 && { item.usageCount } in use } -
-
-
- - -
-
+ + + + + + + + { item.id } + | + + + + { item.spriteId } + + ({ item.usageCount } in use) + { /* Basic Info */ } -
-
- - -
-
- - setField('publicName', e.target.value) } /> -
-
- - -
-
- - +
+ Basic Info +
+
+ + setField('itemName', e.target.value) } /> +
+
+ + setField('publicName', e.target.value) } /> +
+
+ + setField('spriteId', Number(e.target.value)) } /> +
+
+ + +
{ /* Dimensions */ } -
-
Dimensions
-
+
+ Dimensions +
- - setField('width', Number(e.target.value)) } /> + + setField('width', Number(e.target.value)) } />
- - setField('length', Number(e.target.value)) } /> + + setField('length', Number(e.target.value)) } />
- - setField('stackHeight', Number(e.target.value)) } /> + + setField('stackHeight', Number(e.target.value)) } />
{ /* Permissions */ } -
-
Permissions
-
+
+ Permissions +
{ [ 'allowStack', 'allowWalk', 'allowSit', 'allowLay', 'allowGift', 'allowTrade', 'allowRecycle', 'allowMarketplaceSell', 'allowInventoryStack' ].map(key => ( -
{ /* Interaction */ } -
-
Interaction
-
+
+ Interaction +
- - setField('interactionType', e.target.value) }> - { interactions.map(i => ) } + { interactions.map(i => ( + + )) }
- - setField('interactionModesCount', Number(e.target.value)) } /> -
-
- - setField('customparams', e.target.value) } /> + + setField('interactionModesCount', Number(e.target.value)) } />
+
+ + setField('customparams', e.target.value) } /> +
+ { /* Catalog References */ } + { catalogItems.length > 0 && +
+ Catalog ({ catalogItems.length }) +
+ { catalogItems.map(ci => ( +
+ { ci.catalogName } (page: { ci.pageName }) + { ci.costCredits }c + { ci.costPoints }p +
+ )) } +
+
+ } + + { /* FurniData.json Entry */ } + { furniDataEntry && +
+ FurniData.json +
+ { Object.entries(furniDataEntry).map(([ key, value ]) => ( +
+ { key } + { String(value ?? '') } +
+ )) } +
+
+ } + { /* Actions */ } -
- - + -
+ { confirmDelete ? 'Confirm Delete' : 'Delete' } + + ); }; diff --git a/src/components/furni-editor/views/FurniEditorSearchView.tsx b/src/components/furni-editor/views/FurniEditorSearchView.tsx index 1327b45..7b3f8c6 100644 --- a/src/components/furni-editor/views/FurniEditorSearchView.tsx +++ b/src/components/furni-editor/views/FurniEditorSearchView.tsx @@ -1,7 +1,5 @@ import { FC, useCallback, useEffect, useState } from 'react'; -import { FaSearch } from 'react-icons/fa'; -import { Column, Text } from '../../../common'; -import { LayoutFurniIconImageView } from '../../../common/layout/LayoutFurniIconImageView'; +import { Button, Column, Flex, Text } from '../../../common'; import { FurniItem } from '../../../hooks/furni-editor'; interface FurniEditorSearchViewProps @@ -14,8 +12,6 @@ interface FurniEditorSearchViewProps onSelect: (id: number) => void; } -const inputClass = 'text-[14px] border border-[#c5cdd6] rounded px-2 py-1.5 bg-white focus:outline-none focus:border-[#1e7295] transition-colors w-full'; - export const FurniEditorSearchView: FC = props => { const { items, total, page, loading, onSearch, onSelect } = props; @@ -41,122 +37,97 @@ export const FurniEditorSearchView: FC = props => return ( - { /* Search Bar */ } -
-
- + + + Search setQuery(e.target.value) } onKeyDown={ handleKeyDown } /> -
-
- - setTypeFilter(e.target.value) } + > - - + + -
- -
+
+ + - { /* Results counter */ } - { total > 0 && -
- { total } items found { totalPages > 1 && - Page { page }/{ totalPages } } -
- } - - { /* Results Table */ } -
- { loading && -
-
Loading...
-
- } - - { !loading && items.length === 0 && -
- No items found -
- } - - { !loading && items.length > 0 && - - - - - - - - - - + +
IDSpriteNamePublic NameTypeInteraction
+ + + + + + + + + + + + { items.map(item => ( + onSelect(item.id) } + > + + + + + + - - - { items.map(item => ( - onSelect(item.id) } - > - - - - - - - - - )) } - -
IDSpriteNamePublic NameTypeInteraction
{ item.id }{ item.spriteId }{ item.itemName }{ item.publicName } + + { item.type === 's' ? 'Floor' : 'Wall' } + + { item.interactionType || '-' }
-
- -
-
{ item.id }{ item.spriteId }{ item.itemName }{ item.publicName } - - { item.type === 's' ? 'Floor' : 'Wall' } - - { item.interactionType || '-' }
- } -
+ )) } + { items.length === 0 && !loading && + + No items found + + } + + + - { /* Pagination */ } { totalPages > 1 && -
-
- Page { page } of { totalPages } -
-
- - -
-
+ + + } ); diff --git a/src/components/inventory/views/prefix/InventoryPrefixView.tsx b/src/components/inventory/views/prefix/InventoryPrefixView.tsx index 4f3ea6d..d959546 100644 --- a/src/components/inventory/views/prefix/InventoryPrefixView.tsx +++ b/src/components/inventory/views/prefix/InventoryPrefixView.tsx @@ -1,8 +1,32 @@ import { FC, useEffect, useState } from 'react'; import { FaTrashAlt } from 'react-icons/fa'; -import { IPrefixItem, LocalizeText } from '../../../../api'; +import { IPrefixItem, LocalizeText, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../api'; import { useInventoryPrefixes, useNotification } from '../../../../hooks'; -import { NitroButton, PrefixPreview } from '../../../../layout'; +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 + } + {'}'} + + + ); +}; const PrefixItemView: FC<{ prefix: IPrefixItem; diff --git a/src/components/room/widgets/chat/ChatWidgetMessageView.tsx b/src/components/room/widgets/chat/ChatWidgetMessageView.tsx index 48aac61..1dba1a9 100644 --- a/src/components/room/widgets/chat/ChatWidgetMessageView.tsx +++ b/src/components/room/widgets/chat/ChatWidgetMessageView.tsx @@ -1,8 +1,7 @@ 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'; -import { PrefixPreview } from '../../../../layout'; interface ChatWidgetMessageViewProps { @@ -91,8 +90,27 @@ export const ChatWidgetMessageView: FC = ({ ) }
- { chat.prefixText && - } + { 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 + } + {'}'} + + + ); + })() }
diff --git a/src/components/wired/views/triggers/WiredTriggerAvatarSaysSomethingView.tsx b/src/components/wired/views/triggers/WiredTriggerAvatarSaysSomethingView.tsx index 531fa2b..048c20c 100644 --- a/src/components/wired/views/triggers/WiredTriggerAvatarSaysSomethingView.tsx +++ b/src/components/wired/views/triggers/WiredTriggerAvatarSaysSomethingView.tsx @@ -16,11 +16,10 @@ export const WiredTriggerAvatarSaysSomethingView: FC<{}> = () => const [ hideMessage, setHideMessage ] = useState(false); const [ ownerOnly, setOwnerOnly ] = useState(false); const { trigger = null, setStringParam = null, setIntParams = null } = useWired(); - const isAnyTextMode = (matchMode === MATCH_ALL); const save = () => { - setStringParam(isAnyTextMode ? '' : message); + setStringParam(message); setIntParams([ matchMode, hideMessage ? 1 : 0, @@ -40,11 +39,7 @@ export const WiredTriggerAvatarSaysSomethingView: FC<{}> = () =>
{ LocalizeText('wiredfurni.params.whatissaid') } - setMessage(event.target.value) } /> + setMessage(event.target.value) } />
diff --git a/src/css/index.css b/src/css/index.css index b48faa3..806e0e5 100644 --- a/src/css/index.css +++ b/src/css/index.css @@ -78,27 +78,6 @@ 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/hooks/furni-editor/useFurniEditor.ts b/src/hooks/furni-editor/useFurniEditor.ts index 5d2c95a..e4258e5 100644 --- a/src/hooks/furni-editor/useFurniEditor.ts +++ b/src/hooks/furni-editor/useFurniEditor.ts @@ -1,7 +1,4 @@ -import { FurniEditorBySpriteComposer, FurniEditorCreateComposer, FurniEditorDeleteComposer, FurniEditorDetailComposer, FurniEditorDetailEvent as FurniEditorDetailMsgEvent, FurniEditorInteractionsComposer, FurniEditorInteractionsEvent as FurniEditorInteractionsMsgEvent, FurniEditorResultEvent as FurniEditorResultMsgEvent, FurniEditorSearchComposer, FurniEditorSearchEvent as FurniEditorSearchMsgEvent, FurniEditorUpdateComposer } from '@nitrots/nitro-renderer'; import { useCallback, useState } from 'react'; -import { SendMessageComposer } from '../../api'; -import { useMessageEvent } from '../events'; export interface FurniItem { @@ -49,6 +46,18 @@ export interface CatalogRef pageName: string; } +const API_BASE = '/api/admin/furni-editor'; + +async function apiFetch(url: string, options?: RequestInit): Promise +{ + const res = await fetch(url, { credentials: 'include', ...options }); + const data = await res.json(); + + if(!res.ok || data.error) throw new Error(data.error || 'API error'); + + return data; +} + export const useFurniEditor = () => { const [ items, setItems ] = useState([]); @@ -60,114 +69,171 @@ export const useFurniEditor = () => const [ catalogItems, setCatalogItems ] = useState([]); const [ interactions, setInteractions ] = useState([]); const [ furniDataEntry, setFurniDataEntry ] = useState | null>(null); - const [ lastResult, setLastResult ] = useState<{ success: boolean; message: string; id: number } | null>(null); const clearError = useCallback(() => setError(null), []); - // Listen for search results - useMessageEvent(FurniEditorSearchMsgEvent, (event: any) => + const searchItems = useCallback(async (query: string, type: string, pg: number) => { - const parser = event.getParser(); - - setItems(parser.items); - setTotal(parser.total); - setPage(parser.page); - setLoading(false); - }); - - // Listen for detail results - useMessageEvent(FurniEditorDetailMsgEvent, (event: any) => - { - const parser = event.getParser(); - - setSelectedItem(parser.item as FurniDetail); - setCatalogItems(parser.catalogItems as CatalogRef[]); + setLoading(true); + setError(null); try { - setFurniDataEntry(parser.furniDataJson ? JSON.parse(parser.furniDataJson) : null); + const params = new URLSearchParams({ q: query, limit: '20', page: String(pg) }); + + if(type) params.set('type', type); + + const data = await apiFetch<{ items: FurniItem[]; total: number; page: number }>(`${ API_BASE }?${ params }`); + + setItems(data.items); + setTotal(data.total); + setPage(data.page); } - catch + catch(e: any) { - setFurniDataEntry(null); + setError(e.message); } - - setLoading(false); - }); - - // Listen for interactions results - useMessageEvent(FurniEditorInteractionsMsgEvent, (event: any) => - { - const parser = event.getParser(); - - setInteractions(parser.interactions); - }); - - // Listen for operation results (update/create/delete) - useMessageEvent(FurniEditorResultMsgEvent, (event: any) => - { - const parser = event.getParser(); - - setLastResult({ success: parser.success, message: parser.message, id: parser.id }); - setLoading(false); - - if(!parser.success) + finally { - setError(parser.message); + setLoading(false); } - }); + }, []); - const searchItems = useCallback((query: string, type: string, pg: number) => + const loadDetail = useCallback(async (id: number): Promise => { setLoading(true); setError(null); - SendMessageComposer(new FurniEditorSearchComposer(query, type, pg)); + + try + { + const data = await apiFetch<{ item: FurniDetail; catalogItems: CatalogRef[]; furniDataEntry: Record | null }>(`${ API_BASE }/detail?id=${ id }`); + + setSelectedItem(data.item); + setCatalogItems(data.catalogItems); + setFurniDataEntry(data.furniDataEntry); + + return true; + } + catch(e: any) + { + setError(e.message); + + return false; + } + finally + { + setLoading(false); + } }, []); - const loadDetail = useCallback((id: number) => + const updateItem = useCallback(async (id: number, fields: Record) => { setLoading(true); setError(null); - SendMessageComposer(new FurniEditorDetailComposer(id)); + + try + { + await apiFetch(`${ API_BASE }/update?id=${ id }`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(fields) + }); + + return true; + } + catch(e: any) + { + setError(e.message); + + return false; + } + finally + { + setLoading(false); + } }, []); - const loadBySpriteId = useCallback((spriteId: number) => + const createItem = useCallback(async (fields: Record) => { setLoading(true); setError(null); - SendMessageComposer(new FurniEditorBySpriteComposer(spriteId)); + + try + { + const data = await apiFetch<{ id: number }>(`${ API_BASE }`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(fields) + }); + + return data.id; + } + catch(e: any) + { + setError(e.message); + + return null; + } + finally + { + setLoading(false); + } }, []); - const updateItem = useCallback((id: number, fields: Record) => + const deleteItem = useCallback(async (id: number) => { setLoading(true); setError(null); - SendMessageComposer(new FurniEditorUpdateComposer(id, JSON.stringify(fields))); + + try + { + await apiFetch(`${ API_BASE }/delete?id=${ id }`, { method: 'POST' }); + + return true; + } + catch(e: any) + { + setError(e.message); + + return false; + } + finally + { + setLoading(false); + } }, []); - const createItem = useCallback((fields: Record) => + const loadInteractions = useCallback(async () => { - setLoading(true); - setError(null); - SendMessageComposer(new FurniEditorCreateComposer(JSON.stringify(fields))); + try + { + const data = await apiFetch<{ interactions: Array }>(`${ API_BASE }/interactions`); + + setInteractions(data.interactions.map(i => typeof i === 'string' ? i : i.name)); + } + catch {} }, []); - const deleteItem = useCallback((id: number) => + const loadBySpriteId = useCallback(async (spriteId: number): Promise => { - setLoading(true); - setError(null); - SendMessageComposer(new FurniEditorDeleteComposer(id)); - }, []); + try + { + const data = await apiFetch<{ id: number }>(`${ API_BASE }/by-sprite?spriteId=${ spriteId }`); - const loadInteractions = useCallback(() => - { - SendMessageComposer(new FurniEditorInteractionsComposer()); - }, []); + return await loadDetail(data.id); + } + catch(e: any) + { + setError(e.message); + + return false; + } + }, [ loadDetail ]); return { items, total, page, loading, error, clearError, selectedItem, setSelectedItem, catalogItems, furniDataEntry, - interactions, lastResult, + interactions, searchItems, loadDetail, loadBySpriteId, updateItem, createItem, deleteItem, loadInteractions }; }; diff --git a/src/layout/PrefixPreview.tsx b/src/layout/PrefixPreview.tsx deleted file mode 100644 index 00779e8..0000000 --- a/src/layout/PrefixPreview.tsx +++ /dev/null @@ -1,48 +0,0 @@ -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 41a8969..a7041de 100644 --- a/src/layout/index.ts +++ b/src/layout/index.ts @@ -3,7 +3,6 @@ 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';