mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
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:
@@ -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;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user