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 { FC, PropsWithChildren, useState } from 'react';
import { UnseenItemCategory } from '../../../../api'; import { GetConfigurationValue, UnseenItemCategory } from '../../../../api';
import { LayoutBadgeImageView } from '../../../../common'; import { LayoutBadgeImageView } from '../../../../common';
import { useInventoryBadges, useInventoryUnseenTracker } from '../../../../hooks'; import { useInventoryBadges, useInventoryUnseenTracker } from '../../../../hooks';
import { InfiniteGrid } from '../../../../layout'; 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 { selectedBadgeCode = null, setSelectedBadgeCode = null, toggleBadge = null, getBadgeId = null } = useInventoryBadges();
const { isUnseen = null } = useInventoryUnseenTracker(); const { isUnseen = null } = useInventoryUnseenTracker();
const unseen = isUnseen(UnseenItemCategory.BADGE, getBadgeId(badgeCode)); const unseen = isUnseen(UnseenItemCategory.BADGE, getBadgeId(badgeCode));
const [ isDragging, setIsDragging ] = useState(false);
const onDragStart = (event: React.DragEvent<HTMLDivElement>) => const onDragStart = (event: React.DragEvent<HTMLDivElement>) =>
{ {
event.dataTransfer.setData('badgeCode', badgeCode); event.dataTransfer.setData('badgeCode', badgeCode);
event.dataTransfer.setData('source', 'inventory'); event.dataTransfer.setData('source', 'inventory');
event.dataTransfer.effectAllowed = 'move'; 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 ( return (
<InfiniteGrid.Item <InfiniteGrid.Item
draggable draggable
className={ `cursor-grab active:cursor-grabbing ${ isDragging ? 'opacity-40 scale-95' : '' }` }
itemActive={ (selectedBadgeCode === badgeCode) } itemActive={ (selectedBadgeCode === badgeCode) }
itemUnseen={ unseen } itemUnseen={ unseen }
onDoubleClick={ event => toggleBadge(selectedBadgeCode) } onDoubleClick={ event => toggleBadge(selectedBadgeCode) }
onDragEnd={ onDragEnd }
onDragStart={ onDragStart } onDragStart={ onDragStart }
onMouseDown={ event => setSelectedBadgeCode(badgeCode) } onMouseDown={ event => setSelectedBadgeCode(badgeCode) }
{ ...rest }> { ...rest }>
@@ -1,7 +1,7 @@
import { DeleteBadgeMessageComposer } from '@nitrots/nitro-renderer'; import { DeleteBadgeMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useState } from 'react'; import { FC, useCallback, useEffect, useState } from 'react';
import { FaTrashAlt } from 'react-icons/fa'; 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 { LayoutBadgeImageView } from '../../../../common';
import { useInventoryBadges, useInventoryUnseenTracker, useNotification } from '../../../../hooks'; import { useInventoryBadges, useInventoryUnseenTracker, useNotification } from '../../../../hooks';
import { InfiniteGrid, NitroButton } from '../../../../layout'; import { InfiniteGrid, NitroButton } from '../../../../layout';
@@ -18,6 +18,8 @@ const ActiveBadgeSlot: FC<{
}> = ({ slotIndex, badgeCode, onDropBadge, onRemoveBadge, onDragStartFromSlot, onSelectBadge, isSelected }) => }> = ({ slotIndex, badgeCode, onDropBadge, onRemoveBadge, onDragStartFromSlot, onSelectBadge, isSelected }) =>
{ {
const [ isDragOver, setIsDragOver ] = useState(false); const [ isDragOver, setIsDragOver ] = useState(false);
const [ isDragging, setIsDragging ] = useState(false);
const [ justDropped, setJustDropped ] = useState(false);
const onDragOver = useCallback((event: React.DragEvent) => const onDragOver = useCallback((event: React.DragEvent) =>
{ {
@@ -35,24 +37,36 @@ const ActiveBadgeSlot: FC<{
const droppedBadgeCode = event.dataTransfer.getData('badgeCode'); const droppedBadgeCode = event.dataTransfer.getData('badgeCode');
const sourceSlotStr = event.dataTransfer.getData('activeSlot'); 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 ]); }, [ slotIndex, onDropBadge ]);
const onDragStart = useCallback((event: React.DragEvent) => const onDragStart = useCallback((event: React.DragEvent) =>
{ {
if(!badgeCode) return; if(!badgeCode) return;
onDragStartFromSlot(event, badgeCode, slotIndex); onDragStartFromSlot(event, badgeCode, slotIndex);
setIsDragging(true);
}, [ badgeCode, slotIndex, onDragStartFromSlot ]); }, [ badgeCode, slotIndex, onDragStartFromSlot ]);
const onDragEnd = useCallback(() => setIsDragging(false), []);
return ( return (
<div <div
className={ `flex items-center justify-center rounded-md border-2 cursor-pointer aspect-square transition-colors className={ `flex items-center justify-center rounded-md border-2 aspect-square transition-all duration-150
${ isDragOver ? 'border-blue-400 bg-blue-400/20' : '' } ${ 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' } ${ 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' : '' }` } ${ !badgeCode ? 'border-dashed opacity-60' : '' }` }
draggable={ !!badgeCode } draggable={ !!badgeCode }
onDragEnd={ onDragEnd }
onDragLeave={ onDragLeave } onDragLeave={ onDragLeave }
onDragOver={ onDragOver } onDragOver={ onDragOver }
onDragStart={ onDragStart } onDragStart={ onDragStart }
@@ -73,6 +87,7 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
const { isUnseen = null, removeUnseen = null } = useInventoryUnseenTracker(); const { isUnseen = null, removeUnseen = null } = useInventoryUnseenTracker();
const { showConfirm = null } = useNotification(); const { showConfirm = null } = useNotification();
const [ isDragOverInventory, setIsDragOverInventory ] = useState(false); const [ isDragOverInventory, setIsDragOverInventory ] = useState(false);
const [ isDraggingFromActive, setIsDraggingFromActive ] = useState(false);
const maxSlots = 5; const maxSlots = 5;
const displayCodes = (filteredBadgeCodes !== null ? filteredBadgeCodes : badgeCodes); const displayCodes = (filteredBadgeCodes !== null ? filteredBadgeCodes : badgeCodes);
@@ -95,12 +110,10 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
{ {
if(sourceSlot !== undefined) if(sourceSlot !== undefined)
{ {
// Reorder within active badges
reorderBadges(sourceSlot, slotIndex); reorderBadges(sourceSlot, slotIndex);
} }
else else
{ {
// Drop from inventory to active slot
setBadgeAtSlot(badgeCode, slotIndex); setBadgeAtSlot(badgeCode, slotIndex);
} }
}, [ setBadgeAtSlot, reorderBadges ]); }, [ setBadgeAtSlot, reorderBadges ]);
@@ -111,6 +124,11 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
event.dataTransfer.setData('activeSlot', slotIndex.toString()); event.dataTransfer.setData('activeSlot', slotIndex.toString());
event.dataTransfer.setData('source', 'active'); event.dataTransfer.setData('source', 'active');
event.dataTransfer.effectAllowed = 'move'; 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) => const handleRemoveBadge = useCallback((badgeCode: string) =>
@@ -121,18 +139,24 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
// Handle drop on inventory area (remove from active) // Handle drop on inventory area (remove from active)
const onInventoryDragOver = useCallback((event: React.DragEvent) => const onInventoryDragOver = useCallback((event: React.DragEvent) =>
{ {
const source = event.dataTransfer.types.includes('activeslot') ? 'active' : '';
event.preventDefault(); event.preventDefault();
event.dataTransfer.dropEffect = 'move'; event.dataTransfer.dropEffect = 'move';
const fromActive = event.dataTransfer.types.includes('activeslot');
setIsDraggingFromActive(fromActive);
setIsDragOverInventory(true); setIsDragOverInventory(true);
}, []); }, []);
const onInventoryDragLeave = useCallback(() => setIsDragOverInventory(false), []); const onInventoryDragLeave = useCallback(() =>
{
setIsDragOverInventory(false);
setIsDraggingFromActive(false);
}, []);
const onInventoryDrop = useCallback((event: React.DragEvent) => const onInventoryDrop = useCallback((event: React.DragEvent) =>
{ {
event.preventDefault(); event.preventDefault();
setIsDragOverInventory(false); setIsDragOverInventory(false);
setIsDraggingFromActive(false);
const badgeCode = event.dataTransfer.getData('badgeCode'); const badgeCode = event.dataTransfer.getData('badgeCode');
const source = event.dataTransfer.getData('source'); const source = event.dataTransfer.getData('source');
@@ -169,10 +193,18 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
return ( return (
<div className="grid h-full grid-cols-12 gap-2"> <div className="grid h-full grid-cols-12 gap-2">
<div <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 } onDragLeave={ onInventoryDragLeave }
onDragOver={ onInventoryDragOver } onDragOver={ onInventoryDragOver }
onDrop={ onInventoryDrop }> 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> <InfiniteGrid<string>
columnCount={ 5 } columnCount={ 5 }
estimateSize={ 50 } estimateSize={ 50 }
@@ -1,6 +1,6 @@
import { FC, useCallback, useEffect, useRef, useState } from 'react'; import { FC, useCallback, useEffect, useRef, useState } from 'react';
import { FaPlus } from 'react-icons/fa'; import { FaPlus } from 'react-icons/fa';
import { LocalizeText } from '../../../../../api'; import { GetConfigurationValue, LocalizeText } from '../../../../../api';
import { LayoutBadgeImageView } from '../../../../../common'; import { LayoutBadgeImageView } from '../../../../../common';
import { useInventoryBadges } from '../../../../../hooks'; import { useInventoryBadges } from '../../../../../hooks';
@@ -14,7 +14,7 @@ interface InfoStandBadgeSlotProps
const BadgeMiniPicker: FC<{ const BadgeMiniPicker: FC<{
onSelect: (badgeCode: string) => void; onSelect: (badgeCode: string) => void;
onClose: () => void; onClose: () => void;
activeBadgeCodes: string[]; activeBadgeCodes: (string | null)[];
}> = ({ onSelect, onClose, activeBadgeCodes }) => }> = ({ onSelect, onClose, activeBadgeCodes }) =>
{ {
const { badgeCodes = [], requestBadges = null } = useInventoryBadges(); const { badgeCodes = [], requestBadges = null } = useInventoryBadges();
@@ -26,7 +26,8 @@ const BadgeMiniPicker: FC<{
if(badgeCodes.length === 0) requestBadges(); 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 const filtered = search.length > 0
? availableBadges.filter(code => code.toLowerCase().includes(search.toLowerCase())) ? availableBadges.filter(code => code.toLowerCase().includes(search.toLowerCase()))
: availableBadges; : availableBadges;
@@ -78,12 +79,24 @@ const BadgeMiniPicker: FC<{
export const InfoStandBadgeSlotView: FC<InfoStandBadgeSlotProps> = ({ slotIndex, badgeCode: badgeCodeFromProps, isOwnUser }) => 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 [ isDragOver, setIsDragOver ] = useState(false);
const [ isDragging, setIsDragging ] = useState(false);
const [ justDropped, setJustDropped ] = useState(false);
const [ showPicker, setShowPicker ] = useState(false); const [ showPicker, setShowPicker ] = useState(false);
const hookBadge = activeBadgeCodes.length > 0 ? (activeBadgeCodes[slotIndex] ?? null) : null; const hookInitialized = activeBadgeCodes.length > 0;
const badgeCode = isOwnUser ? (hookBadge ?? badgeCodeFromProps ?? null) : (badgeCodeFromProps ?? null);
// 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) => const onDragStart = useCallback((event: React.DragEvent) =>
{ {
@@ -91,8 +104,16 @@ export const InfoStandBadgeSlotView: FC<InfoStandBadgeSlotProps> = ({ slotIndex,
event.dataTransfer.setData('badgeCode', badgeCode); event.dataTransfer.setData('badgeCode', badgeCode);
event.dataTransfer.setData('infostandSlot', slotIndex.toString()); event.dataTransfer.setData('infostandSlot', slotIndex.toString());
event.dataTransfer.effectAllowed = 'move'; 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 ]); }, [ badgeCode, slotIndex, isOwnUser ]);
const onDragEnd = useCallback(() => setIsDragging(false), []);
const onDragOver = useCallback((event: React.DragEvent) => const onDragOver = useCallback((event: React.DragEvent) =>
{ {
if(!isOwnUser) return; if(!isOwnUser) return;
@@ -124,6 +145,9 @@ export const InfoStandBadgeSlotView: FC<InfoStandBadgeSlotProps> = ({ slotIndex,
{ {
setBadgeAtSlot(droppedBadgeCode, slotIndex); setBadgeAtSlot(droppedBadgeCode, slotIndex);
} }
setJustDropped(true);
setTimeout(() => setJustDropped(false), 300);
}, [ isOwnUser, slotIndex, swapBadges, setBadgeAtSlot ]); }, [ isOwnUser, slotIndex, swapBadges, setBadgeAtSlot ]);
const handleSlotClick = useCallback(() => 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 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-grab active:cursor-grabbing' : '' }
${ isOwnUser && !badgeCode ? 'cursor-pointer' : '' } ${ isOwnUser && !badgeCode ? 'cursor-pointer' : '' }
${ isOwnUser ? 'hover:scale-110 hover:brightness-125 hover:drop-shadow-[0_0_6px_rgba(255,255,255,0.3)]' : '' } ${ isDragging ? 'opacity-30 scale-90' : '' }
${ isDragOver ? 'scale-115 ring-2 ring-blue-400/60 rounded-sm bg-blue-400/15' : '' } ${ 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' : '' }` } ${ isOwnUser && !badgeCode ? 'opacity-40 hover:opacity-70 border border-dashed border-white/20 rounded-sm' : '' }` }
draggable={ isOwnUser && !!badgeCode } draggable={ isOwnUser && !!badgeCode }
onDragEnd={ onDragEnd }
onDragLeave={ onDragLeave } onDragLeave={ onDragLeave }
onDragOver={ onDragOver } onDragOver={ onDragOver }
onDragStart={ onDragStart } onDragStart={ onDragStart }
@@ -56,15 +56,23 @@ export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props =
useNitroEvent<RoomSessionUserBadgesEvent>(RoomSessionUserBadgesEvent.RSUBE_BADGES, event => { useNitroEvent<RoomSessionUserBadgesEvent>(RoomSessionUserBadgesEvent.RSUBE_BADGES, event => {
if (!avatarInfo || avatarInfo.webID !== event.userId) return; 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(''); const oldBadges = avatarInfo.badges.join('');
if (oldBadges === event.badges.join('')) return; if (oldBadges === dedupedBadges.join('')) return;
setAvatarInfo(prevValue => { setAvatarInfo(prevValue => {
if (!prevValue) return prevValue; if (!prevValue) return prevValue;
const newValue = CloneObject(prevValue); const newValue = CloneObject(prevValue);
newValue.badges = event.badges; newValue.badges = dedupedBadges;
return newValue; return newValue;
}); });
}); });
+50 -44
View File
@@ -11,19 +11,31 @@ const useInventoryBadgesState = () =>
const [ needsUpdate, setNeedsUpdate ] = useState(true); const [ needsUpdate, setNeedsUpdate ] = useState(true);
const [ badgeCodes, setBadgeCodes ] = useState<string[]>([]); const [ badgeCodes, setBadgeCodes ] = useState<string[]>([]);
const [ badgeIds, setBadgeIds ] = useState<Map<string, number>>(new Map<string, number>()); 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 [ selectedBadgeCode, setSelectedBadgeCode ] = useState<string>(null);
const { isVisible = false, activate = null, deactivate = null } = useSharedVisibility(); const { isVisible = false, activate = null, deactivate = null } = useSharedVisibility();
const { isUnseen = null, resetCategory = null } = useInventoryUnseenTracker(); const { isUnseen = null, resetCategory = null } = useInventoryUnseenTracker();
const maxBadgeCount = GetConfigurationValue<number>('user.badges.max.slots', 5); const maxBadgeCount = GetConfigurationValue<number>('user.badges.max.slots', 5);
const localChangeRef = useRef(false); const pendingUpdatesRef = useRef(0);
const isWearingBadge = (badgeCode: string) => (activeBadgeCodes.indexOf(badgeCode) >= 0); const isWearingBadge = (badgeCode: string) => activeBadgeCodes.some(code => code === badgeCode);
const canWearBadges = () => (activeBadgeCodes.length < maxBadgeCount); 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(); const composer = new SetActivatedBadgesComposer();
for(let i = 0; i < maxBadgeCount; i++) composer.addActivatedBadge(badges[i] ?? ''); for(let i = 0; i < maxBadgeCount; i++) composer.addActivatedBadge(badges[i] ?? '');
SendMessageComposer(composer); SendMessageComposer(composer);
@@ -33,24 +45,23 @@ const useInventoryBadgesState = () =>
{ {
setActiveBadgeCodes(prevValue => setActiveBadgeCodes(prevValue =>
{ {
const newValue = [ ...prevValue ]; const slots = toFixedSlots(prevValue);
const index = slots.indexOf(badgeCode);
const index = newValue.indexOf(badgeCode);
if(index === -1) 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 else
{ {
newValue.splice(index, 1); slots[index] = null;
} }
sendActiveBadges(newValue); sendActiveBadges(slots);
return slots;
return newValue;
}); });
}; };
@@ -82,14 +93,15 @@ const useInventoryBadgesState = () =>
return newValue; return newValue;
}); });
// Skip overwriting activeBadgeCodes if we recently made a local change // Skip overwriting activeBadgeCodes if we have pending local changes
if(localChangeRef.current) if(pendingUpdatesRef.current > 0)
{ {
localChangeRef.current = false; pendingUpdatesRef.current--;
} }
else else
{ {
setActiveBadgeCodes(parser.getActiveBadgeCodes()); const serverBadges = parser.getActiveBadgeCodes();
setActiveBadgeCodes(toFixedSlots(serverBadges));
} }
setBadgeCodes(allBadgeCodes); setBadgeCodes(allBadgeCodes);
@@ -159,8 +171,7 @@ const useInventoryBadgesState = () =>
{ {
setActiveBadgeCodes(prevValue => setActiveBadgeCodes(prevValue =>
{ {
// Build a fixed-size array of maxBadgeCount slots const slots = toFixedSlots(prevValue);
const slots: (string | null)[] = Array.from({ length: maxBadgeCount }, (_, i) => prevValue[i] ?? null);
// Remove badge if already in another slot // Remove badge if already in another slot
const existingIndex = slots.indexOf(badgeCode); const existingIndex = slots.indexOf(badgeCode);
@@ -169,11 +180,8 @@ const useInventoryBadgesState = () =>
// Place badge at target slot // Place badge at target slot
slots[slotIndex] = badgeCode; slots[slotIndex] = badgeCode;
// Compact: remove nulls, keep order sendActiveBadges(slots);
const result = slots.filter(Boolean) as string[]; return slots;
sendActiveBadges(result);
return result;
}); });
}; };
@@ -181,10 +189,14 @@ const useInventoryBadgesState = () =>
{ {
setActiveBadgeCodes(prevValue => 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); slots[index] = null;
return result;
sendActiveBadges(slots);
return slots;
}); });
}; };
@@ -193,14 +205,14 @@ const useInventoryBadgesState = () =>
setActiveBadgeCodes(prevValue => setActiveBadgeCodes(prevValue =>
{ {
if(fromIndex === toIndex) return prevValue; if(fromIndex === toIndex) return prevValue;
if(fromIndex >= prevValue.length) return prevValue;
const newValue = [ ...prevValue ]; const slots = toFixedSlots(prevValue);
const [ moved ] = newValue.splice(fromIndex, 1); const temp = slots[fromIndex];
newValue.splice(toIndex, 0, moved); slots[fromIndex] = slots[toIndex];
slots[toIndex] = temp;
sendActiveBadges(newValue); sendActiveBadges(slots);
return newValue; return slots;
}); });
}; };
@@ -210,19 +222,13 @@ const useInventoryBadgesState = () =>
{ {
if(fromIndex === toIndex) return prevValue; if(fromIndex === toIndex) return prevValue;
// Build fixed-size array so swap works even with empty slots const slots = toFixedSlots(prevValue);
const slots: (string | null)[] = Array.from({ length: maxBadgeCount }, (_, i) => prevValue[i] ?? null);
// Swap the two slots
const temp = slots[fromIndex]; const temp = slots[fromIndex];
slots[fromIndex] = slots[toIndex]; slots[fromIndex] = slots[toIndex];
slots[toIndex] = temp; slots[toIndex] = temp;
// Compact: remove nulls, keep order sendActiveBadges(slots);
const result = slots.filter(Boolean) as string[]; return slots;
sendActiveBadges(result);
return result;
}); });
}; };
+23 -1
View File
@@ -102,6 +102,25 @@ module.exports = {
dropShadow: { dropShadow: {
'hover': '2px 2px 0 rgba(0,0,0,0.8)' '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: [ safelist: [
@@ -144,7 +163,10 @@ module.exports = {
'grid-rows-11', 'grid-rows-11',
'grid-rows-12', 'grid-rows-12',
'justify-end', 'justify-end',
'items-end' 'items-end',
'animate-pulse-glow',
'animate-pulse-glow-red',
'animate-drop-settle'
], ],
darkMode: 'class', darkMode: 'class',
variants: { variants: {