diff --git a/src/components/inventory/views/badge/InventoryBadgeItemView.tsx b/src/components/inventory/views/badge/InventoryBadgeItemView.tsx index 4553621..4bf666b 100644 --- a/src/components/inventory/views/badge/InventoryBadgeItemView.tsx +++ b/src/components/inventory/views/badge/InventoryBadgeItemView.tsx @@ -11,8 +11,22 @@ export const InventoryBadgeItemView: FC const { isUnseen = null } = useInventoryUnseenTracker(); const unseen = isUnseen(UnseenItemCategory.BADGE, getBadgeId(badgeCode)); + const onDragStart = (event: React.DragEvent) => + { + event.dataTransfer.setData('badgeCode', badgeCode); + event.dataTransfer.setData('source', 'inventory'); + event.dataTransfer.effectAllowed = 'move'; + }; + return ( - toggleBadge(selectedBadgeCode) } onMouseDown={ event => setSelectedBadgeCode(badgeCode) } { ...rest }> + toggleBadge(selectedBadgeCode) } + onDragStart={ onDragStart } + onMouseDown={ event => setSelectedBadgeCode(badgeCode) } + { ...rest }> { children } diff --git a/src/components/inventory/views/badge/InventoryBadgeView.tsx b/src/components/inventory/views/badge/InventoryBadgeView.tsx index 8a60522..1bf6d13 100644 --- a/src/components/inventory/views/badge/InventoryBadgeView.tsx +++ b/src/components/inventory/views/badge/InventoryBadgeView.tsx @@ -1,5 +1,5 @@ import { DeleteBadgeMessageComposer } from '@nitrots/nitro-renderer'; -import { FC, useEffect, useState } from 'react'; +import { FC, useCallback, useEffect, useState } from 'react'; import { FaTrashAlt } from 'react-icons/fa'; import { LocalizeBadgeName, LocalizeText, SendMessageComposer, UnseenItemCategory } from '../../../../api'; import { LayoutBadgeImageView } from '../../../../common'; @@ -7,14 +7,74 @@ import { useInventoryBadges, useInventoryUnseenTracker, useNotification } from ' import { InfiniteGrid, NitroButton } from '../../../../layout'; import { InventoryBadgeItemView } from './InventoryBadgeItemView'; +const ActiveBadgeSlot: FC<{ + slotIndex: number; + badgeCode?: string; + onDropBadge: (badgeCode: string, slotIndex: number, sourceSlot?: number) => void; + onRemoveBadge: (badgeCode: string) => void; + onDragStartFromSlot: (event: React.DragEvent, badgeCode: string, slotIndex: number) => void; + onSelectBadge: (badgeCode: string) => void; + isSelected: boolean; +}> = ({ slotIndex, badgeCode, onDropBadge, onRemoveBadge, onDragStartFromSlot, onSelectBadge, isSelected }) => +{ + const [ isDragOver, setIsDragOver ] = useState(false); + + const onDragOver = useCallback((event: React.DragEvent) => + { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + setIsDragOver(true); + }, []); + + const onDragLeave = useCallback(() => setIsDragOver(false), []); + + const onDrop = useCallback((event: React.DragEvent) => + { + event.preventDefault(); + setIsDragOver(false); + + const droppedBadgeCode = event.dataTransfer.getData('badgeCode'); + const sourceSlotStr = event.dataTransfer.getData('activeSlot'); + const sourceSlot = sourceSlotStr ? parseInt(sourceSlotStr) : undefined; + + if(droppedBadgeCode) onDropBadge(droppedBadgeCode, slotIndex, sourceSlot); + }, [ slotIndex, onDropBadge ]); + + const onDragStart = useCallback((event: React.DragEvent) => + { + if(!badgeCode) return; + onDragStartFromSlot(event, badgeCode, slotIndex); + }, [ badgeCode, slotIndex, onDragStartFromSlot ]); + + return ( +
badgeCode && onSelectBadge(badgeCode) }> + { badgeCode + ? + : { slotIndex + 1 } } +
+ ); +}; + export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props => { const { filteredBadgeCodes = null } = props; const [ isVisible, setIsVisible ] = useState(false); - const { badgeCodes = [], activeBadgeCodes = [], selectedBadgeCode = null, isWearingBadge = null, canWearBadges = null, toggleBadge = null, getBadgeId = null, activate = null, deactivate = null } = useInventoryBadges(); + const { badgeCodes = [], activeBadgeCodes = [], selectedBadgeCode = null, isWearingBadge = null, canWearBadges = null, toggleBadge = null, getBadgeId = null, setBadgeAtSlot = null, removeBadge = null, reorderBadges = null, setSelectedBadgeCode = null, activate = null, deactivate = null } = useInventoryBadges(); const { isUnseen = null, removeUnseen = null } = useInventoryUnseenTracker(); const { showConfirm = null } = useNotification(); + const [ isDragOverInventory, setIsDragOverInventory ] = useState(false); + const maxSlots = 5; const displayCodes = (filteredBadgeCodes !== null ? filteredBadgeCodes : badgeCodes); const attemptDeleteBadge = () => @@ -31,6 +91,58 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props = ); }; + const handleDropOnSlot = useCallback((badgeCode: string, slotIndex: number, sourceSlot?: number) => + { + if(sourceSlot !== undefined) + { + // Reorder within active badges + reorderBadges(sourceSlot, slotIndex); + } + else + { + // Drop from inventory to active slot + setBadgeAtSlot(badgeCode, slotIndex); + } + }, [ setBadgeAtSlot, reorderBadges ]); + + const handleDragStartFromSlot = useCallback((event: React.DragEvent, badgeCode: string, slotIndex: number) => + { + event.dataTransfer.setData('badgeCode', badgeCode); + event.dataTransfer.setData('activeSlot', slotIndex.toString()); + event.dataTransfer.setData('source', 'active'); + event.dataTransfer.effectAllowed = 'move'; + }, []); + + const handleRemoveBadge = useCallback((badgeCode: string) => + { + removeBadge(badgeCode); + }, [ removeBadge ]); + + // Handle drop on inventory area (remove from active) + const onInventoryDragOver = useCallback((event: React.DragEvent) => + { + const source = event.dataTransfer.types.includes('activeslot') ? 'active' : ''; + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + setIsDragOverInventory(true); + }, []); + + const onInventoryDragLeave = useCallback(() => setIsDragOverInventory(false), []); + + const onInventoryDrop = useCallback((event: React.DragEvent) => + { + event.preventDefault(); + setIsDragOverInventory(false); + + const badgeCode = event.dataTransfer.getData('badgeCode'); + const source = event.dataTransfer.getData('source'); + + if(source === 'active' && badgeCode) + { + removeBadge(badgeCode); + } + }, [ removeBadge ]); + useEffect(() => { if(!selectedBadgeCode || !isUnseen(UnseenItemCategory.BADGE, getBadgeId(selectedBadgeCode))) return; @@ -56,7 +168,11 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props = return (
-
+
columnCount={ 5 } estimateSize={ 50 } @@ -66,11 +182,20 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
{ LocalizeText('inventory.badges.activebadges') } - - columnCount={ 3 } - estimateSize={ 50 } - itemRender={ item => } - items={ activeBadgeCodes } /> +
+ { Array.from({ length: maxSlots }).map((_, index) => ( + + )) } +
{ !!selectedBadgeCode &&
diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx new file mode 100644 index 0000000..951df53 --- /dev/null +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx @@ -0,0 +1,173 @@ +import { FC, useCallback, useEffect, useRef, useState } from 'react'; +import { FaPlus } from 'react-icons/fa'; +import { LayoutBadgeImageView } from '../../../../../common'; +import { useInventoryBadges } from '../../../../../hooks'; + +interface InfoStandBadgeSlotProps +{ + slotIndex: number; + badgeCode?: string; + isOwnUser: boolean; +} + +const BadgeMiniPicker: FC<{ + onSelect: (badgeCode: string) => void; + onClose: () => void; + activeBadgeCodes: string[]; +}> = ({ onSelect, onClose, activeBadgeCodes }) => +{ + const { badgeCodes = [], requestBadges = null } = useInventoryBadges(); + const ref = useRef(null); + const [ search, setSearch ] = useState(''); + + useEffect(() => + { + if(badgeCodes.length === 0) requestBadges(); + }, []); + + const availableBadges = badgeCodes.filter(code => !activeBadgeCodes.includes(code)); + const filtered = search.length > 0 + ? availableBadges.filter(code => code.toLowerCase().includes(search.toLowerCase())) + : availableBadges; + + useEffect(() => + { + const handleClickOutside = (event: MouseEvent) => + { + if(ref.current && !ref.current.contains(event.target as Node)) onClose(); + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [ onClose ]); + + return ( +
e.stopPropagation() }> + setSearch(e.target.value) } + /> + { badgeCodes.length === 0 + ? Caricamento... + : ( +
+ { filtered.slice(0, 40).map(code => ( +
onSelect(code) }> + +
+ )) } + { filtered.length === 0 && ( + Nessun badge + ) } +
+ ) } +
+ ); +}; + +export const InfoStandBadgeSlotView: FC = ({ slotIndex, badgeCode: badgeCodeFromProps, isOwnUser }) => +{ + const { activeBadgeCodes = [], setBadgeAtSlot = null, swapBadges = null } = useInventoryBadges(); + const [ isDragOver, setIsDragOver ] = useState(false); + const [ showPicker, setShowPicker ] = useState(false); + + // For own user: use hook data if loaded, otherwise fall back to props (avatarInfo) + // For other users: always use props + const hookBadge = activeBadgeCodes.length > 0 ? (activeBadgeCodes[slotIndex] ?? null) : null; + const badgeCode = isOwnUser ? (hookBadge ?? badgeCodeFromProps ?? null) : (badgeCodeFromProps ?? null); + + const onDragStart = useCallback((event: React.DragEvent) => + { + if(!badgeCode || !isOwnUser) return; + event.dataTransfer.setData('badgeCode', badgeCode); + event.dataTransfer.setData('infostandSlot', slotIndex.toString()); + event.dataTransfer.effectAllowed = 'move'; + }, [ badgeCode, slotIndex, isOwnUser ]); + + const onDragOver = useCallback((event: React.DragEvent) => + { + if(!isOwnUser) return; + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + setIsDragOver(true); + }, [ isOwnUser ]); + + const onDragLeave = useCallback(() => setIsDragOver(false), []); + + const onDrop = useCallback((event: React.DragEvent) => + { + event.preventDefault(); + setIsDragOver(false); + if(!isOwnUser) return; + + const droppedBadgeCode = event.dataTransfer.getData('badgeCode'); + const sourceSlotStr = event.dataTransfer.getData('infostandSlot'); + + if(!droppedBadgeCode) return; + + if(sourceSlotStr !== '') + { + // Dragged from another infostand slot -> always swap (works with empty slots too) + const sourceSlot = parseInt(sourceSlotStr); + + if(sourceSlot !== slotIndex) swapBadges(sourceSlot, slotIndex); + } + else + { + // Dragged from inventory or external -> place at this slot + setBadgeAtSlot(droppedBadgeCode, slotIndex); + } + }, [ isOwnUser, slotIndex, swapBadges, setBadgeAtSlot ]); + + const handleSlotClick = useCallback(() => + { + if(!isOwnUser || badgeCode) return; + + setShowPicker(true); + }, [ isOwnUser, badgeCode ]); + + const handlePickerSelect = useCallback((code: string) => + { + setBadgeAtSlot(code, slotIndex); + setShowPicker(false); + }, [ setBadgeAtSlot, slotIndex ]); + + return ( +
+
+ { badgeCode + ? + : isOwnUser && } +
+ { showPicker && ( + setShowPicker(false) } + onSelect={ handlePickerSelect } + /> + ) } +
+ ); +}; diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx index a53e35c..1791979 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx @@ -4,6 +4,7 @@ import { FaPencilAlt, FaTimes } from 'react-icons/fa'; import { AvatarInfoUser, CloneObject, GetConfigurationValue, GetGroupInformation, GetUserProfile, LocalizeText, SendMessageComposer } from '../../../../../api'; import { Base, Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, Text, UserProfileIconView } from '../../../../../common'; import { useMessageEvent, useNitroEvent, useRoom } from '../../../../../hooks'; +import { InfoStandBadgeSlotView } from './InfoStandBadgeSlotView'; import { InfoStandWidgetUserRelationshipsView } from './InfoStandWidgetUserRelationshipsView'; import { InfoStandWidgetUserTagsView } from './InfoStandWidgetUserTagsView'; import { BackgroundsView } from '../../../../backgrounds/BackgroundsView'; @@ -158,31 +159,43 @@ export const InfoStandWidgetUserView: FC = props = /> )} -
-
- {avatarInfo.badges[0] && } -
- 0} onClick={event => GetGroupInformation(avatarInfo.groupId)}> - {avatarInfo.groupId > 0 && - } - -
- -
- {avatarInfo.badges[1] && } -
-
- {avatarInfo.badges[2] && } -
-
- -
- {avatarInfo.badges[3] && } -
-
- {avatarInfo.badges[4] && } -
-
+ { GetConfigurationValue('user.badges.group.slot.enabled', true) + ? ( + <> +
+ + 0} onClick={event => GetGroupInformation(avatarInfo.groupId)}> + {avatarInfo.groupId > 0 && + } + +
+ + + + + + + + + + ) + : ( + <> + + + + + + + + + + + + + + ) + }

diff --git a/src/hooks/inventory/useInventoryBadges.ts b/src/hooks/inventory/useInventoryBadges.ts index aebe155..39e0667 100644 --- a/src/hooks/inventory/useInventoryBadges.ts +++ b/src/hooks/inventory/useInventoryBadges.ts @@ -1,5 +1,5 @@ import { BadgeReceivedEvent, BadgesEvent, RequestBadgesComposer, SetActivatedBadgesComposer } from '@nitrots/nitro-renderer'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useBetween } from 'use-between'; import { GetConfigurationValue, SendMessageComposer, UnseenItemCategory } from '../../api'; import { useMessageEvent } from '../events'; @@ -17,9 +17,18 @@ const useInventoryBadgesState = () => const { isUnseen = null, resetCategory = null } = useInventoryUnseenTracker(); const maxBadgeCount = GetConfigurationValue('user.badges.max.slots', 5); + const localChangeRef = useRef(false); const isWearingBadge = (badgeCode: string) => (activeBadgeCodes.indexOf(badgeCode) >= 0); const canWearBadges = () => (activeBadgeCodes.length < maxBadgeCount); + const sendActiveBadges = (badges: string[]) => + { + localChangeRef.current = true; + const composer = new SetActivatedBadgesComposer(); + for(let i = 0; i < maxBadgeCount; i++) composer.addActivatedBadge(badges[i] ?? ''); + SendMessageComposer(composer); + }; + const toggleBadge = (badgeCode: string) => { setActiveBadgeCodes(prevValue => @@ -30,7 +39,7 @@ const useInventoryBadgesState = () => if(index === -1) { - if(!canWearBadges()) return prevValue; + if(newValue.length >= maxBadgeCount) return prevValue; newValue.push(badgeCode); } @@ -39,11 +48,7 @@ const useInventoryBadgesState = () => newValue.splice(index, 1); } - const composer = new SetActivatedBadgesComposer(); - - for(let i = 0; i < maxBadgeCount; i++) composer.addActivatedBadge(newValue[i] ?? ''); - - SendMessageComposer(composer); + sendActiveBadges(newValue); return newValue; }); @@ -77,7 +82,16 @@ const useInventoryBadgesState = () => return newValue; }); - setActiveBadgeCodes(parser.getActiveBadgeCodes()); + // Skip overwriting activeBadgeCodes if we recently made a local change + if(localChangeRef.current) + { + localChangeRef.current = false; + } + else + { + setActiveBadgeCodes(parser.getActiveBadgeCodes()); + } + setBadgeCodes(allBadgeCodes); }); @@ -141,7 +155,83 @@ const useInventoryBadgesState = () => setNeedsUpdate(false); }, [ isVisible, needsUpdate ]); - return { badgeCodes, activeBadgeCodes, selectedBadgeCode, setSelectedBadgeCode, isWearingBadge, canWearBadges, toggleBadge, getBadgeId, activate, deactivate }; + const setBadgeAtSlot = (badgeCode: string, slotIndex: number) => + { + setActiveBadgeCodes(prevValue => + { + // Build a fixed-size array of maxBadgeCount slots + const slots: (string | null)[] = Array.from({ length: maxBadgeCount }, (_, i) => prevValue[i] ?? null); + + // Remove badge if already in another slot + const existingIndex = slots.indexOf(badgeCode); + if(existingIndex >= 0) slots[existingIndex] = null; + + // Place badge at target slot + slots[slotIndex] = badgeCode; + + // Compact: remove nulls, keep order + const result = slots.filter(Boolean) as string[]; + + sendActiveBadges(result); + return result; + }); + }; + + const removeBadge = (badgeCode: string) => + { + setActiveBadgeCodes(prevValue => + { + const result = prevValue.filter(code => code !== badgeCode); + + sendActiveBadges(result); + return result; + }); + }; + + const reorderBadges = (fromIndex: number, toIndex: number) => + { + setActiveBadgeCodes(prevValue => + { + if(fromIndex === toIndex) return prevValue; + if(fromIndex >= prevValue.length) return prevValue; + + const newValue = [ ...prevValue ]; + const [ moved ] = newValue.splice(fromIndex, 1); + newValue.splice(toIndex, 0, moved); + + sendActiveBadges(newValue); + return newValue; + }); + }; + + const swapBadges = (fromIndex: number, toIndex: number) => + { + setActiveBadgeCodes(prevValue => + { + if(fromIndex === toIndex) return prevValue; + + // Build fixed-size array so swap works even with empty slots + const slots: (string | null)[] = Array.from({ length: maxBadgeCount }, (_, i) => prevValue[i] ?? null); + + // Swap the two slots + const temp = slots[fromIndex]; + slots[fromIndex] = slots[toIndex]; + slots[toIndex] = temp; + + // Compact: remove nulls, keep order + const result = slots.filter(Boolean) as string[]; + + sendActiveBadges(result); + return result; + }); + }; + + const requestBadges = () => + { + SendMessageComposer(new RequestBadgesComposer()); + }; + + return { badgeCodes, activeBadgeCodes, selectedBadgeCode, setSelectedBadgeCode, isWearingBadge, canWearBadges, toggleBadge, getBadgeId, setBadgeAtSlot, removeBadge, reorderBadges, swapBadges, requestBadges, activate, deactivate }; }; export const useInventoryBadges = () => useBetween(useInventoryBadgesState);