Add badge drag & drop system for InfoStand and inventory

- Drag & drop badges between slots in InfoStand (own user only)
- Mini badge picker on empty slot click with search
- Swap badges between occupied slots
- Hover animation (scale, glow) on badge slots
- Configurable group slot (user.badges.group.slot.enabled)
- Support for 6 badge slots when group slot disabled
- Race condition fix with localChangeRef
- Fixed-size array logic to prevent badge disappearing

Co-Authored-By: medievalshell <medievalshell@users.noreply.github.com>
This commit is contained in:
simoleo89
2026-03-15 20:48:05 +01:00
parent 2a29d3d08c
commit 38f38d7209
11 changed files with 1152 additions and 55 deletions
@@ -0,0 +1,172 @@
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<HTMLDivElement>(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 (
<div
ref={ ref }
className="absolute right-[calc(100%+8px)] top-0 z-50 bg-[rgba(28,28,32,0.97)] border border-white/20 rounded-md p-2 shadow-lg min-w-[160px]"
onClick={ e => e.stopPropagation() }>
<input
autoFocus
className="w-full text-xs text-white bg-white/10 border border-white/20 rounded px-2 py-1 mb-2 outline-none focus:border-white/40"
placeholder="Cerca badge..."
type="text"
value={ search }
onChange={ e => setSearch(e.target.value) }
/>
{ badgeCodes.length === 0
? <span className="text-xs text-white/40 text-center py-2 block">Caricamento...</span>
: (
<div className="grid grid-cols-4 gap-1 max-h-[160px] overflow-y-auto">
{ filtered.slice(0, 40).map(code => (
<div
key={ code }
className="flex items-center justify-center w-[36px] h-[36px] cursor-pointer rounded border border-transparent hover:border-white/40 hover:bg-white/10 transition-all"
onClick={ () => onSelect(code) }>
<LayoutBadgeImageView badgeCode={ code } />
</div>
)) }
{ filtered.length === 0 && (
<span className="text-xs text-white/40 col-span-4 text-center py-2">Nessun badge</span>
) }
</div>
) }
</div>
);
};
export const InfoStandBadgeSlotView: FC<InfoStandBadgeSlotProps> = ({ 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 activeBadgeCodes from the hook (updates immediately on drag/drop)
// For other users, use the badge code from props (from server via avatarInfo)
const badgeCode = isOwnUser ? (activeBadgeCodes[slotIndex] ?? null) : badgeCodeFromProps;
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 (
<div className="relative">
<div
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' : '' }
${ isOwnUser && !badgeCode ? 'opacity-40 hover:opacity-70 border border-dashed border-white/20 rounded-sm' : '' }` }
draggable={ isOwnUser && !!badgeCode }
onDragLeave={ onDragLeave }
onDragOver={ onDragOver }
onDragStart={ onDragStart }
onDrop={ onDrop }
onClick={ handleSlotClick }>
{ badgeCode
? <LayoutBadgeImageView badgeCode={ badgeCode } showInfo={ true } />
: isOwnUser && <FaPlus className="text-white/30 text-[10px]" /> }
</div>
{ showPicker && (
<BadgeMiniPicker
activeBadgeCodes={ activeBadgeCodes }
onClose={ () => setShowPicker(false) }
onSelect={ handlePickerSelect }
/>
) }
</div>
);
};
@@ -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<InfoStandWidgetUserViewProps> = props =
/>
)}
<Column grow alignItems="center" gap={0}>
<div className="flex gap-1">
<div className="flex items-center justify-center relative w-[40px] h-[40px] bg-no-repeat bg-center">
{avatarInfo.badges[0] && <LayoutBadgeImageView badgeCode={avatarInfo.badges[0]} showInfo={true} />}
</div>
<Flex center className="relative w-[40px] h-[40px] bg-no-repeat bg-center" pointer={avatarInfo.groupId > 0} onClick={event => GetGroupInformation(avatarInfo.groupId)}>
{avatarInfo.groupId > 0 &&
<LayoutBadgeImageView badgeCode={avatarInfo.groupBadgeId} customTitle={avatarInfo.groupName} isGroup={true} showInfo={true} />}
</Flex>
</div>
<Flex center gap={1}>
<div className="flex items-center justify-center relative w-[40px] h-[40px] bg-no-repeat bg-center">
{avatarInfo.badges[1] && <LayoutBadgeImageView badgeCode={avatarInfo.badges[1]} showInfo={true} />}
</div>
<div className="flex items-center justify-center relative w-[40px] h-[40px] bg-no-repeat bg-center">
{avatarInfo.badges[2] && <LayoutBadgeImageView badgeCode={avatarInfo.badges[2]} showInfo={true} />}
</div>
</Flex>
<Flex center gap={1}>
<div className="flex items-center justify-center relative w-[40px] h-[40px] bg-no-repeat bg-center">
{avatarInfo.badges[3] && <LayoutBadgeImageView badgeCode={avatarInfo.badges[3]} showInfo={true} />}
</div>
<div className="flex items-center justify-center relative w-[40px] h-[40px] bg-no-repeat bg-center">
{avatarInfo.badges[4] && <LayoutBadgeImageView badgeCode={avatarInfo.badges[4]} showInfo={true} />}
</div>
</Flex>
{ GetConfigurationValue<boolean>('user.badges.group.slot.enabled', true)
? (
<>
<div className="flex gap-1">
<InfoStandBadgeSlotView slotIndex={0} badgeCode={avatarInfo.badges[0]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
<Flex center className="relative w-[40px] h-[40px] bg-no-repeat bg-center" pointer={avatarInfo.groupId > 0} onClick={event => GetGroupInformation(avatarInfo.groupId)}>
{avatarInfo.groupId > 0 &&
<LayoutBadgeImageView badgeCode={avatarInfo.groupBadgeId} customTitle={avatarInfo.groupName} isGroup={true} showInfo={true} />}
</Flex>
</div>
<Flex center gap={1}>
<InfoStandBadgeSlotView slotIndex={1} badgeCode={avatarInfo.badges[1]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
<InfoStandBadgeSlotView slotIndex={2} badgeCode={avatarInfo.badges[2]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
</Flex>
<Flex center gap={1}>
<InfoStandBadgeSlotView slotIndex={3} badgeCode={avatarInfo.badges[3]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
<InfoStandBadgeSlotView slotIndex={4} badgeCode={avatarInfo.badges[4]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
</Flex>
</>
)
: (
<>
<Flex center gap={1}>
<InfoStandBadgeSlotView slotIndex={0} badgeCode={avatarInfo.badges[0]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
<InfoStandBadgeSlotView slotIndex={1} badgeCode={avatarInfo.badges[1]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
</Flex>
<Flex center gap={1}>
<InfoStandBadgeSlotView slotIndex={2} badgeCode={avatarInfo.badges[2]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
<InfoStandBadgeSlotView slotIndex={3} badgeCode={avatarInfo.badges[3]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
</Flex>
<Flex center gap={1}>
<InfoStandBadgeSlotView slotIndex={4} badgeCode={avatarInfo.badges[4]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
<InfoStandBadgeSlotView slotIndex={5} badgeCode={avatarInfo.badges[5]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
</Flex>
</>
)
}
</Column>
</div>
<hr className="m-0 bg-[#0003] border-0 opacity-[0.5] h-px" />