Badge DnD rework: fix duplicate/disappearing badges, add visual feedback

Fix slot 0 drag bug ('0' is falsy), prevent badge duplication from stale
props fallback in InfoStand, add sparse slot support, fix race condition
with pending server updates. Add drag preview, glow animations, drop
settle effect, and remove-badge indicator overlay.
This commit is contained in:
Life
2026-04-04 13:34:53 +02:00
parent 203a754ce1
commit 73ee9c7603
6 changed files with 173 additions and 67 deletions
@@ -1,5 +1,5 @@
import { FC, PropsWithChildren } from 'react';
import { UnseenItemCategory } from '../../../../api';
import { FC, PropsWithChildren, useState } from 'react';
import { GetConfigurationValue, UnseenItemCategory } from '../../../../api';
import { LayoutBadgeImageView } from '../../../../common';
import { useInventoryBadges, useInventoryUnseenTracker } from '../../../../hooks';
import { InfiniteGrid } from '../../../../layout';
@@ -10,20 +10,31 @@ export const InventoryBadgeItemView: FC<PropsWithChildren<{ badgeCode: string }>
const { selectedBadgeCode = null, setSelectedBadgeCode = null, toggleBadge = null, getBadgeId = null } = useInventoryBadges();
const { isUnseen = null } = useInventoryUnseenTracker();
const unseen = isUnseen(UnseenItemCategory.BADGE, getBadgeId(badgeCode));
const [ isDragging, setIsDragging ] = useState(false);
const onDragStart = (event: React.DragEvent<HTMLDivElement>) =>
{
event.dataTransfer.setData('badgeCode', badgeCode);
event.dataTransfer.setData('source', 'inventory');
event.dataTransfer.effectAllowed = 'move';
setIsDragging(true);
const badgeUrl = GetConfigurationValue<string>('badge.asset.url').replace('%badgename%', badgeCode);
const img = new Image();
img.src = badgeUrl;
event.dataTransfer.setDragImage(img, 20, 20);
};
const onDragEnd = () => setIsDragging(false);
return (
<InfiniteGrid.Item
draggable
className={ `cursor-grab active:cursor-grabbing ${ isDragging ? 'opacity-40 scale-95' : '' }` }
itemActive={ (selectedBadgeCode === badgeCode) }
itemUnseen={ unseen }
onDoubleClick={ event => toggleBadge(selectedBadgeCode) }
onDragEnd={ onDragEnd }
onDragStart={ onDragStart }
onMouseDown={ event => setSelectedBadgeCode(badgeCode) }
{ ...rest }>
@@ -1,7 +1,7 @@
import { DeleteBadgeMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useState } from 'react';
import { FaTrashAlt } from 'react-icons/fa';
import { LocalizeBadgeName, LocalizeText, SendMessageComposer, UnseenItemCategory } from '../../../../api';
import { GetConfigurationValue, LocalizeBadgeName, LocalizeText, SendMessageComposer, UnseenItemCategory } from '../../../../api';
import { LayoutBadgeImageView } from '../../../../common';
import { useInventoryBadges, useInventoryUnseenTracker, useNotification } from '../../../../hooks';
import { InfiniteGrid, NitroButton } from '../../../../layout';
@@ -18,6 +18,8 @@ const ActiveBadgeSlot: FC<{
}> = ({ slotIndex, badgeCode, onDropBadge, onRemoveBadge, onDragStartFromSlot, onSelectBadge, isSelected }) =>
{
const [ isDragOver, setIsDragOver ] = useState(false);
const [ isDragging, setIsDragging ] = useState(false);
const [ justDropped, setJustDropped ] = useState(false);
const onDragOver = useCallback((event: React.DragEvent) =>
{
@@ -35,24 +37,36 @@ const ActiveBadgeSlot: FC<{
const droppedBadgeCode = event.dataTransfer.getData('badgeCode');
const sourceSlotStr = event.dataTransfer.getData('activeSlot');
const sourceSlot = sourceSlotStr ? parseInt(sourceSlotStr) : undefined;
const sourceSlot = sourceSlotStr !== '' ? parseInt(sourceSlotStr) : undefined;
if(droppedBadgeCode) onDropBadge(droppedBadgeCode, slotIndex, sourceSlot);
if(droppedBadgeCode)
{
onDropBadge(droppedBadgeCode, slotIndex, sourceSlot);
setJustDropped(true);
setTimeout(() => setJustDropped(false), 300);
}
}, [ slotIndex, onDropBadge ]);
const onDragStart = useCallback((event: React.DragEvent) =>
{
if(!badgeCode) return;
onDragStartFromSlot(event, badgeCode, slotIndex);
setIsDragging(true);
}, [ badgeCode, slotIndex, onDragStartFromSlot ]);
const onDragEnd = useCallback(() => setIsDragging(false), []);
return (
<div
className={ `flex items-center justify-center rounded-md border-2 cursor-pointer aspect-square transition-colors
${ isDragOver ? 'border-blue-400 bg-blue-400/20' : '' }
className={ `flex items-center justify-center rounded-md border-2 aspect-square transition-all duration-150
${ badgeCode ? 'cursor-grab active:cursor-grabbing' : 'cursor-default' }
${ isDragging ? 'opacity-30 scale-95' : '' }
${ isDragOver ? 'border-blue-400 bg-blue-400/20 animate-pulse-glow scale-105' : '' }
${ justDropped ? 'animate-drop-settle' : '' }
${ isSelected && badgeCode ? 'border-card-grid-item-active bg-card-grid-item-active' : 'border-card-grid-item-border bg-card-grid-item' }
${ !badgeCode ? 'border-dashed opacity-60' : '' }` }
draggable={ !!badgeCode }
onDragEnd={ onDragEnd }
onDragLeave={ onDragLeave }
onDragOver={ onDragOver }
onDragStart={ onDragStart }
@@ -73,6 +87,7 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
const { isUnseen = null, removeUnseen = null } = useInventoryUnseenTracker();
const { showConfirm = null } = useNotification();
const [ isDragOverInventory, setIsDragOverInventory ] = useState(false);
const [ isDraggingFromActive, setIsDraggingFromActive ] = useState(false);
const maxSlots = 5;
const displayCodes = (filteredBadgeCodes !== null ? filteredBadgeCodes : badgeCodes);
@@ -95,12 +110,10 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
{
if(sourceSlot !== undefined)
{
// Reorder within active badges
reorderBadges(sourceSlot, slotIndex);
}
else
{
// Drop from inventory to active slot
setBadgeAtSlot(badgeCode, slotIndex);
}
}, [ setBadgeAtSlot, reorderBadges ]);
@@ -111,6 +124,11 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
event.dataTransfer.setData('activeSlot', slotIndex.toString());
event.dataTransfer.setData('source', 'active');
event.dataTransfer.effectAllowed = 'move';
const badgeUrl = GetConfigurationValue<string>('badge.asset.url').replace('%badgename%', badgeCode);
const img = new Image();
img.src = badgeUrl;
event.dataTransfer.setDragImage(img, 20, 20);
}, []);
const handleRemoveBadge = useCallback((badgeCode: string) =>
@@ -121,18 +139,24 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
// 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';
const fromActive = event.dataTransfer.types.includes('activeslot');
setIsDraggingFromActive(fromActive);
setIsDragOverInventory(true);
}, []);
const onInventoryDragLeave = useCallback(() => setIsDragOverInventory(false), []);
const onInventoryDragLeave = useCallback(() =>
{
setIsDragOverInventory(false);
setIsDraggingFromActive(false);
}, []);
const onInventoryDrop = useCallback((event: React.DragEvent) =>
{
event.preventDefault();
setIsDragOverInventory(false);
setIsDraggingFromActive(false);
const badgeCode = event.dataTransfer.getData('badgeCode');
const source = event.dataTransfer.getData('source');
@@ -169,10 +193,18 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
return (
<div className="grid h-full grid-cols-12 gap-2">
<div
className={ `flex flex-col col-span-7 gap-1 overflow-hidden rounded transition-colors ${ isDragOverInventory ? 'bg-blue-400/10' : '' }` }
className={ `relative flex flex-col col-span-7 gap-1 overflow-hidden rounded transition-all duration-200
${ isDragOverInventory && isDraggingFromActive ? 'bg-red-500/10 ring-2 ring-inset ring-red-400/30 animate-pulse-glow-red' : '' }
${ isDragOverInventory && !isDraggingFromActive ? 'bg-blue-400/10' : '' }` }
onDragLeave={ onInventoryDragLeave }
onDragOver={ onInventoryDragOver }
onDrop={ onInventoryDrop }>
{ isDragOverInventory && isDraggingFromActive && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center pointer-events-none">
<FaTrashAlt className="text-red-400/60 text-2xl mb-1" />
<span className="text-red-400/60 text-xs font-medium">{ LocalizeText('inventory.badges.clearbadge') }</span>
</div>
) }
<InfiniteGrid<string>
columnCount={ 5 }
estimateSize={ 50 }
@@ -1,6 +1,6 @@
import { FC, useCallback, useEffect, useRef, useState } from 'react';
import { FaPlus } from 'react-icons/fa';
import { LocalizeText } from '../../../../../api';
import { GetConfigurationValue, LocalizeText } from '../../../../../api';
import { LayoutBadgeImageView } from '../../../../../common';
import { useInventoryBadges } from '../../../../../hooks';
@@ -14,7 +14,7 @@ interface InfoStandBadgeSlotProps
const BadgeMiniPicker: FC<{
onSelect: (badgeCode: string) => void;
onClose: () => void;
activeBadgeCodes: string[];
activeBadgeCodes: (string | null)[];
}> = ({ onSelect, onClose, activeBadgeCodes }) =>
{
const { badgeCodes = [], requestBadges = null } = useInventoryBadges();
@@ -26,7 +26,8 @@ const BadgeMiniPicker: FC<{
if(badgeCodes.length === 0) requestBadges();
}, []);
const availableBadges = badgeCodes.filter(code => !activeBadgeCodes.includes(code));
const activeSet = new Set(activeBadgeCodes.filter(Boolean));
const availableBadges = badgeCodes.filter(code => !activeSet.has(code));
const filtered = search.length > 0
? availableBadges.filter(code => code.toLowerCase().includes(search.toLowerCase()))
: availableBadges;
@@ -78,12 +79,24 @@ const BadgeMiniPicker: FC<{
export const InfoStandBadgeSlotView: FC<InfoStandBadgeSlotProps> = ({ slotIndex, badgeCode: badgeCodeFromProps, isOwnUser }) =>
{
const { activeBadgeCodes = [], setBadgeAtSlot = null, swapBadges = null } = useInventoryBadges();
const { activeBadgeCodes = [], setBadgeAtSlot = null, swapBadges = null, requestBadges = null } = useInventoryBadges();
const [ isDragOver, setIsDragOver ] = useState(false);
const [ isDragging, setIsDragging ] = useState(false);
const [ justDropped, setJustDropped ] = useState(false);
const [ showPicker, setShowPicker ] = useState(false);
const hookBadge = activeBadgeCodes.length > 0 ? (activeBadgeCodes[slotIndex] ?? null) : null;
const badgeCode = isOwnUser ? (hookBadge ?? badgeCodeFromProps ?? null) : (badgeCodeFromProps ?? null);
const hookInitialized = activeBadgeCodes.length > 0;
// Load badge data for own user so hook is initialized before any DnD
useEffect(() =>
{
if(isOwnUser && !hookInitialized) requestBadges();
}, [ isOwnUser, hookInitialized, requestBadges ]);
const hookBadge = hookInitialized ? (activeBadgeCodes[slotIndex] ?? null) : null;
// Once hook has data, use ONLY hook data for own user (no stale props fallback)
const badgeCode = isOwnUser
? (hookInitialized ? hookBadge : (badgeCodeFromProps ?? null))
: (badgeCodeFromProps ?? null);
const onDragStart = useCallback((event: React.DragEvent) =>
{
@@ -91,8 +104,16 @@ export const InfoStandBadgeSlotView: FC<InfoStandBadgeSlotProps> = ({ slotIndex,
event.dataTransfer.setData('badgeCode', badgeCode);
event.dataTransfer.setData('infostandSlot', slotIndex.toString());
event.dataTransfer.effectAllowed = 'move';
setIsDragging(true);
const badgeUrl = GetConfigurationValue<string>('badge.asset.url').replace('%badgename%', badgeCode);
const img = new Image();
img.src = badgeUrl;
event.dataTransfer.setDragImage(img, 20, 20);
}, [ badgeCode, slotIndex, isOwnUser ]);
const onDragEnd = useCallback(() => setIsDragging(false), []);
const onDragOver = useCallback((event: React.DragEvent) =>
{
if(!isOwnUser) return;
@@ -124,6 +145,9 @@ export const InfoStandBadgeSlotView: FC<InfoStandBadgeSlotProps> = ({ slotIndex,
{
setBadgeAtSlot(droppedBadgeCode, slotIndex);
}
setJustDropped(true);
setTimeout(() => setJustDropped(false), 300);
}, [ isOwnUser, slotIndex, swapBadges, setBadgeAtSlot ]);
const handleSlotClick = useCallback(() =>
@@ -145,10 +169,13 @@ export const InfoStandBadgeSlotView: FC<InfoStandBadgeSlotProps> = ({ slotIndex,
className={ `flex items-center justify-center relative w-[40px] h-[40px] bg-no-repeat bg-center transition-all duration-150
${ isOwnUser && badgeCode ? 'cursor-grab active:cursor-grabbing' : '' }
${ isOwnUser && !badgeCode ? 'cursor-pointer' : '' }
${ isOwnUser ? 'hover:scale-110 hover:brightness-125 hover:drop-shadow-[0_0_6px_rgba(255,255,255,0.3)]' : '' }
${ isDragOver ? 'scale-115 ring-2 ring-blue-400/60 rounded-sm bg-blue-400/15' : '' }
${ isDragging ? 'opacity-30 scale-90' : '' }
${ isOwnUser && !isDragging ? 'hover:scale-110 hover:brightness-125 hover:drop-shadow-[0_0_6px_rgba(255,255,255,0.3)]' : '' }
${ isDragOver ? 'scale-110 ring-2 ring-blue-400/60 rounded-sm bg-blue-400/15 animate-pulse-glow' : '' }
${ justDropped ? 'animate-drop-settle' : '' }
${ isOwnUser && !badgeCode ? 'opacity-40 hover:opacity-70 border border-dashed border-white/20 rounded-sm' : '' }` }
draggable={ isOwnUser && !!badgeCode }
onDragEnd={ onDragEnd }
onDragLeave={ onDragLeave }
onDragOver={ onDragOver }
onDragStart={ onDragStart }
@@ -56,15 +56,23 @@ export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props =
useNitroEvent<RoomSessionUserBadgesEvent>(RoomSessionUserBadgesEvent.RSUBE_BADGES, event => {
if (!avatarInfo || avatarInfo.webID !== event.userId) return;
// Deduplicate badges from server
const seen = new Set<string>();
const dedupedBadges = event.badges.map(code => {
if (!code || seen.has(code)) return '';
seen.add(code);
return code;
});
const oldBadges = avatarInfo.badges.join('');
if (oldBadges === event.badges.join('')) return;
if (oldBadges === dedupedBadges.join('')) return;
setAvatarInfo(prevValue => {
if (!prevValue) return prevValue;
const newValue = CloneObject(prevValue);
newValue.badges = event.badges;
newValue.badges = dedupedBadges;
return newValue;
});
});
+50 -44
View File
@@ -11,19 +11,31 @@ const useInventoryBadgesState = () =>
const [ needsUpdate, setNeedsUpdate ] = useState(true);
const [ badgeCodes, setBadgeCodes ] = useState<string[]>([]);
const [ badgeIds, setBadgeIds ] = useState<Map<string, number>>(new Map<string, number>());
const [ activeBadgeCodes, setActiveBadgeCodes ] = useState<string[]>([]);
const [ activeBadgeCodes, setActiveBadgeCodes ] = useState<(string | null)[]>([]);
const [ selectedBadgeCode, setSelectedBadgeCode ] = useState<string>(null);
const { isVisible = false, activate = null, deactivate = null } = useSharedVisibility();
const { isUnseen = null, resetCategory = null } = useInventoryUnseenTracker();
const maxBadgeCount = GetConfigurationValue<number>('user.badges.max.slots', 5);
const localChangeRef = useRef(false);
const isWearingBadge = (badgeCode: string) => (activeBadgeCodes.indexOf(badgeCode) >= 0);
const canWearBadges = () => (activeBadgeCodes.length < maxBadgeCount);
const pendingUpdatesRef = useRef(0);
const isWearingBadge = (badgeCode: string) => activeBadgeCodes.some(code => code === badgeCode);
const canWearBadges = () => (activeBadgeCodes.filter(Boolean).length < maxBadgeCount);
const sendActiveBadges = (badges: string[]) =>
const toFixedSlots = (arr: (string | null)[]): (string | null)[] =>
{
localChangeRef.current = true;
const seen = new Set<string>();
return Array.from({ length: maxBadgeCount }, (_, i) =>
{
const code = arr[i] || null;
if(!code || seen.has(code)) return null;
seen.add(code);
return code;
});
};
const sendActiveBadges = (badges: (string | null)[]) =>
{
pendingUpdatesRef.current++;
const composer = new SetActivatedBadgesComposer();
for(let i = 0; i < maxBadgeCount; i++) composer.addActivatedBadge(badges[i] ?? '');
SendMessageComposer(composer);
@@ -33,24 +45,23 @@ const useInventoryBadgesState = () =>
{
setActiveBadgeCodes(prevValue =>
{
const newValue = [ ...prevValue ];
const index = newValue.indexOf(badgeCode);
const slots = toFixedSlots(prevValue);
const index = slots.indexOf(badgeCode);
if(index === -1)
{
if(newValue.length >= maxBadgeCount) return prevValue;
const emptySlot = slots.indexOf(null);
if(emptySlot === -1) return prevValue;
newValue.push(badgeCode);
slots[emptySlot] = badgeCode;
}
else
{
newValue.splice(index, 1);
slots[index] = null;
}
sendActiveBadges(newValue);
return newValue;
sendActiveBadges(slots);
return slots;
});
};
@@ -82,14 +93,15 @@ const useInventoryBadgesState = () =>
return newValue;
});
// Skip overwriting activeBadgeCodes if we recently made a local change
if(localChangeRef.current)
// Skip overwriting activeBadgeCodes if we have pending local changes
if(pendingUpdatesRef.current > 0)
{
localChangeRef.current = false;
pendingUpdatesRef.current--;
}
else
{
setActiveBadgeCodes(parser.getActiveBadgeCodes());
const serverBadges = parser.getActiveBadgeCodes();
setActiveBadgeCodes(toFixedSlots(serverBadges));
}
setBadgeCodes(allBadgeCodes);
@@ -159,8 +171,7 @@ const useInventoryBadgesState = () =>
{
setActiveBadgeCodes(prevValue =>
{
// Build a fixed-size array of maxBadgeCount slots
const slots: (string | null)[] = Array.from({ length: maxBadgeCount }, (_, i) => prevValue[i] ?? null);
const slots = toFixedSlots(prevValue);
// Remove badge if already in another slot
const existingIndex = slots.indexOf(badgeCode);
@@ -169,11 +180,8 @@ const useInventoryBadgesState = () =>
// Place badge at target slot
slots[slotIndex] = badgeCode;
// Compact: remove nulls, keep order
const result = slots.filter(Boolean) as string[];
sendActiveBadges(result);
return result;
sendActiveBadges(slots);
return slots;
});
};
@@ -181,10 +189,14 @@ const useInventoryBadgesState = () =>
{
setActiveBadgeCodes(prevValue =>
{
const result = prevValue.filter(code => code !== badgeCode);
const slots = toFixedSlots(prevValue);
const index = slots.indexOf(badgeCode);
if(index === -1) return prevValue;
sendActiveBadges(result);
return result;
slots[index] = null;
sendActiveBadges(slots);
return slots;
});
};
@@ -193,14 +205,14 @@ const useInventoryBadgesState = () =>
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);
const slots = toFixedSlots(prevValue);
const temp = slots[fromIndex];
slots[fromIndex] = slots[toIndex];
slots[toIndex] = temp;
sendActiveBadges(newValue);
return newValue;
sendActiveBadges(slots);
return slots;
});
};
@@ -210,19 +222,13 @@ const useInventoryBadgesState = () =>
{
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 slots = toFixedSlots(prevValue);
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;
sendActiveBadges(slots);
return slots;
});
};
+23 -1
View File
@@ -102,6 +102,25 @@ module.exports = {
dropShadow: {
'hover': '2px 2px 0 rgba(0,0,0,0.8)'
},
keyframes: {
pulseGlow: {
'0%, 100%': { boxShadow: '0 0 6px rgba(59,130,246,0.3)' },
'50%': { boxShadow: '0 0 14px rgba(59,130,246,0.6)' },
},
pulseGlowRed: {
'0%, 100%': { boxShadow: '0 0 6px rgba(239,68,68,0.3)' },
'50%': { boxShadow: '0 0 14px rgba(239,68,68,0.6)' },
},
dropSettle: {
'0%': { transform: 'scale(1.15)' },
'100%': { transform: 'scale(1)' },
},
},
animation: {
'pulse-glow': 'pulseGlow 1.2s ease-in-out infinite',
'pulse-glow-red': 'pulseGlowRed 1.2s ease-in-out infinite',
'drop-settle': 'dropSettle 0.3s ease-out',
},
},
},
safelist: [
@@ -144,7 +163,10 @@ module.exports = {
'grid-rows-11',
'grid-rows-12',
'justify-end',
'items-end'
'items-end',
'animate-pulse-glow',
'animate-pulse-glow-red',
'animate-drop-settle'
],
darkMode: 'class',
variants: {