From 02d8e5c2dd693504c1780a38f16c673ebc7ed2ba Mon Sep 17 00:00:00 2001 From: Life Date: Sun, 22 Mar 2026 18:43:42 +0100 Subject: [PATCH 1/2] 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/2] 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 }; };