refactor(mod-tools): launcher box gets context strip + section grouping

The launcher panel was a flat stack of four buttons (Room Tool, Chatlog
Tool, selected-user + presence dot inline, Report Tool) with no visual
hierarchy. The selected-user row was particularly cramped — name, the
2px dot and the 4×4 close-X all crammed into a single button row, easy
to misclick.

Reorganize into four logical groups, each with a small uppercase
section label:

  Context  — gradient strip (emerald when in a room, zinc when not)
             showing "Room #<id>" or "Enter a room first" with a
             matching door icon. Source of truth for "what is the
             mod observing right now"; both Room Tool and Chatlog
             Tool feed from the same currentRoomId.

  Room     — Room Tool + Chatlog Tool stacked. Both still gate on
             isInRoom; the disabled state now reads from a single
             flag instead of repeating `currentRoomId <= 0`.

  User     — When a user is selected: a card with the presence dot
             (emerald = still in room, zinc = left), the username at
             a real legible size, a bigger close button, plus a
             dedicated "Open Info" button to toggle ModToolsUserView.
             Splitting the click target from the close action removes
             the misclick footgun.
             When no user is selected: a dashed-border empty state
             with a FaUserSlash icon and the "Select a user" hint —
             reads as a clear "no selection" instead of an active
             button you can't press.

  Reports  — Report Tool with the open-ticket badge. Badge gets a 2px
             rose halo box-shadow so a new ticket pulses into view
             instead of competing with the button background.

Locale keys added under modtools.window.section.* and
modtools.window.context.room / modtools.window.user.open_info, in both
the runtime UITexts.json and the versioned UITexts.example template.

The "Open Info" button label is a fix in flight — the old layout
overloaded the username row to also open user info, with no separate
label. The new explicit button gets its own key so the action is
unambiguous (the previous version mislabelled the button as "Mod
Action", which is actually a different sub-panel).

typecheck + vitest 214/214 + JSON validation all clean.
This commit is contained in:
simoleo89
2026-05-20 21:21:40 +02:00
committed by simoleo89
parent a9515cb1a0
commit 91938985a2
2 changed files with 86 additions and 47 deletions
+6
View File
@@ -124,6 +124,12 @@
"modtools.window.user.clear": "Clear selection",
"modtools.window.tickets.open": "%count% open ticket",
"modtools.window.tickets.open.many": "%count% open tickets",
"modtools.window.section.context": "Context",
"modtools.window.section.room": "Room",
"modtools.window.section.user": "User",
"modtools.window.section.reports": "Reports",
"modtools.window.context.room": "Room #%roomId%",
"modtools.window.user.open_info": "Open Info",
"modtools.userinfo.refresh": "Refresh user info",
"modtools.userinfo.presence.in_room": "In room",
"modtools.userinfo.presence.in_room.title": "In the room you are observing",
+80 -47
View File
@@ -1,6 +1,6 @@
import { AddLinkEventTracker, CreateLinkEvent, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomId, RoomObjectCategory, RoomObjectType } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useRef, useState } from 'react';
import { FaTimes } from 'react-icons/fa';
import { FaDoorClosed, FaDoorOpen, FaTimes, FaUserSlash } from 'react-icons/fa';
import { GetRoomSession, ISelectedUser, LocalizeText } from '../../api';
import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
import { useModTools, useNitroEvent, useObjectSelectedEvent, useRoomUserListSnapshot } from '../../hooks';
@@ -134,63 +134,96 @@ export const ModToolsView: FC<{}> = props =>
return () => RemoveLinkEventTracker(linkTracker);
}, [ openRoomInfo, closeRoomInfo, toggleRoomInfo, openRoomChatlog, closeRoomChatlog, toggleRoomChatlog, openUserInfo, closeUserInfo, toggleUserInfo, openUserChatlog, closeUserChatlog, toggleUserChatlog ]);
const isRoomInfoOpen = currentRoomId > 0 && openRooms.includes(currentRoomId);
const isRoomChatlogOpen = currentRoomId > 0 && openRoomChatlogs.includes(currentRoomId);
const isInRoom = currentRoomId > 0;
const isRoomInfoOpen = isInRoom && openRooms.includes(currentRoomId);
const isRoomChatlogOpen = isInRoom && openRoomChatlogs.includes(currentRoomId);
const isUserInfoOpen = selectedUser && openUserInfos.includes(selectedUser.userId);
const noRoomHint = LocalizeText('mod.tools.no.room') || 'Enter a room first';
const noRoomHint = LocalizeText('modtools.window.no.room');
return (
<>
{ isVisible &&
<NitroCardView className="nitro-mod-tools min-w-[220px]" theme="primary-slim" uniqueKey="mod-tools" windowPosition={ DraggableWindowPosition.TOP_LEFT } >
<NitroCardView className="nitro-mod-tools min-w-[240px] max-w-[260px]" theme="primary-slim" uniqueKey="mod-tools" windowPosition={ DraggableWindowPosition.TOP_LEFT } >
<NitroCardHeaderView headerText={ LocalizeText('modtools.window.title') } onCloseClick={ event => setIsVisible(false) } />
<NitroCardContentView className="text-black" gap={ 2 }>
<Button active={ isRoomInfoOpen } disabled={ (currentRoomId <= 0) } gap={ 2 } justifyContent="start" title={ (currentRoomId <= 0) ? noRoomHint : undefined } onClick={ event => CreateLinkEvent(`mod-tools/toggle-room-info/${ currentRoomId }`) }>
<div className="nitro-icon icon-small-room shrink-0" />
<span className="grow text-start">{ LocalizeText('modtools.window.tools.room') }</span>
</Button>
<Button active={ isRoomChatlogOpen } disabled={ (currentRoomId <= 0) } gap={ 2 } innerRef={ elementRef } justifyContent="start" title={ (currentRoomId <= 0) ? noRoomHint : undefined } onClick={ event => CreateLinkEvent(`mod-tools/toggle-room-chatlog/${ currentRoomId }`) }>
<div className="nitro-icon icon-chat-history shrink-0" />
<span className="grow text-start">{ LocalizeText('modtools.window.tools.chatlog') }</span>
</Button>
<Button active={ !!isUserInfoOpen } disabled={ !selectedUser } gap={ 2 } justifyContent="start" onClick={ () => selectedUser && CreateLinkEvent(`mod-tools/toggle-user-info/${ selectedUser.userId }`) }>
<div className="nitro-icon icon-user shrink-0" />
{/* Context strip: which room are we observing? */}
<div className={ `flex items-center gap-2 rounded p-1.5 border ${ isInRoom ? 'bg-gradient-to-r from-emerald-50 to-transparent border-emerald-100' : 'bg-zinc-50 border-zinc-200' }` }
title={ isInRoom ? LocalizeText('modtools.window.context.room', [ 'roomId' ], [ currentRoomId.toString() ]) : noRoomHint }>
{ isInRoom
? <FaDoorOpen className="text-emerald-600 shrink-0" size={ 12 } />
: <FaDoorClosed className="text-zinc-400 shrink-0" size={ 12 } /> }
<div className="flex flex-col grow min-w-0 leading-tight">
<div className="text-[.6rem] uppercase tracking-wide opacity-60 font-semibold">{ LocalizeText('modtools.window.section.context') }</div>
{ isInRoom
? <div className="text-xs font-medium truncate">{ LocalizeText('modtools.window.context.room', [ 'roomId' ], [ currentRoomId.toString() ]) }</div>
: <div className="text-xs italic opacity-60 truncate">{ noRoomHint }</div> }
</div>
</div>
{/* Room tools */}
<div className="flex flex-col gap-1.5">
<div className="text-[.6rem] uppercase tracking-wide opacity-60 font-semibold pl-1">{ LocalizeText('modtools.window.section.room') }</div>
<Button active={ isRoomInfoOpen } disabled={ !isInRoom } gap={ 2 } justifyContent="start" title={ !isInRoom ? noRoomHint : undefined } onClick={ () => CreateLinkEvent(`mod-tools/toggle-room-info/${ currentRoomId }`) }>
<div className="nitro-icon icon-small-room shrink-0" />
<span className="grow text-start">{ LocalizeText('modtools.window.tools.room') }</span>
</Button>
<Button active={ isRoomChatlogOpen } disabled={ !isInRoom } gap={ 2 } innerRef={ elementRef } justifyContent="start" title={ !isInRoom ? noRoomHint : undefined } onClick={ () => CreateLinkEvent(`mod-tools/toggle-room-chatlog/${ currentRoomId }`) }>
<div className="nitro-icon icon-chat-history shrink-0" />
<span className="grow text-start">{ LocalizeText('modtools.window.tools.chatlog') }</span>
</Button>
</div>
{/* Selected user */}
<div className="flex flex-col gap-1.5">
<div className="text-[.6rem] uppercase tracking-wide opacity-60 font-semibold pl-1">{ LocalizeText('modtools.window.section.user') }</div>
{ selectedUser
? (
<>
<span className="truncate grow text-start">{ selectedUser.username }</span>
<span
aria-label={ isSelectedUserPresent ? LocalizeText('modtools.userinfo.presence.in_room') : LocalizeText('modtools.window.user.left_room') }
className={ `inline-block w-2 h-2 rounded-full shrink-0 ${ isSelectedUserPresent ? 'bg-emerald-500' : 'bg-zinc-400' }` }
title={ isSelectedUserPresent ? LocalizeText('modtools.window.user.in_room') : LocalizeText('modtools.window.user.left_room') }
/>
<span
className="inline-flex items-center justify-center w-4 h-4 rounded text-xs text-zinc-500 hover:text-rose-600 hover:bg-rose-100 shrink-0"
onClick={ event =>
{
event.stopPropagation();
setSelectedUser(null);
} }
role="button"
tabIndex={ 0 }
title={ LocalizeText('modtools.window.user.clear') }>
<FaTimes />
</span>
</>
<div className={ `flex flex-col gap-1.5 rounded p-1.5 border ${ isSelectedUserPresent ? 'bg-gradient-to-r from-emerald-50 to-transparent border-emerald-100' : 'bg-gradient-to-r from-zinc-50 to-transparent border-zinc-200' }` }>
<div className="flex items-center gap-1.5">
<span className={ `inline-block w-2 h-2 rounded-full shrink-0 ${ isSelectedUserPresent ? 'bg-emerald-500' : 'bg-zinc-400' }` }
title={ isSelectedUserPresent ? LocalizeText('modtools.window.user.in_room') : LocalizeText('modtools.window.user.left_room') }
aria-label={ isSelectedUserPresent ? LocalizeText('modtools.userinfo.presence.in_room') : LocalizeText('modtools.window.user.left_room') } />
<span className="truncate grow text-start text-sm font-semibold leading-tight">{ selectedUser.username }</span>
<button
className="inline-flex items-center justify-center w-5 h-5 rounded text-zinc-500 hover:text-rose-600 hover:bg-rose-100 shrink-0 transition-colors"
onClick={ event =>
{
event.stopPropagation();
setSelectedUser(null);
} }
title={ LocalizeText('modtools.window.user.clear') }>
<FaTimes size={ 10 } />
</button>
</div>
<Button active={ !!isUserInfoOpen } gap={ 2 } justifyContent="start" onClick={ () => CreateLinkEvent(`mod-tools/toggle-user-info/${ selectedUser.userId }`) }>
<div className="nitro-icon icon-user shrink-0" />
<span className="grow text-start">{ LocalizeText('modtools.window.user.open_info') }</span>
</Button>
</div>
)
: (
<div className="flex items-center gap-2 rounded p-2 border border-dashed border-zinc-300 bg-zinc-50/50 opacity-70">
<FaUserSlash className="text-zinc-400 shrink-0" size={ 14 } />
<span className="text-xs italic">{ LocalizeText('modtools.window.select.user') }</span>
</div>
)
: <span className="opacity-50 italic grow text-start">{ LocalizeText('modtools.window.select.user') }</span>
}
</Button>
<Button active={ isTicketsVisible } gap={ 2 } justifyContent="start" onClick={ () => setIsTicketsVisible(prevValue => !prevValue) }>
<div className="nitro-icon icon-tickets shrink-0" />
<span className="grow text-start">{ LocalizeText('modtools.window.tools.report') }</span>
{ (openTicketsCount > 0) &&
<span
className="inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded-full bg-rose-500 text-white text-xs font-semibold shrink-0"
title={ LocalizeText(openTicketsCount === 1 ? 'modtools.window.tickets.open' : 'modtools.window.tickets.open.many', [ 'count' ], [ openTicketsCount.toString() ]) }>
{ openTicketsCount > 99 ? '99+' : openTicketsCount }
</span> }
</Button>
</div>
{/* Reports */}
<div className="flex flex-col gap-1.5">
<div className="text-[.6rem] uppercase tracking-wide opacity-60 font-semibold pl-1">{ LocalizeText('modtools.window.section.reports') }</div>
<Button active={ isTicketsVisible } gap={ 2 } justifyContent="start" onClick={ () => setIsTicketsVisible(prevValue => !prevValue) }>
<div className="nitro-icon icon-tickets shrink-0" />
<span className="grow text-start">{ LocalizeText('modtools.window.tools.report') }</span>
{ (openTicketsCount > 0) &&
<span
className="inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1.5 rounded-full bg-rose-500 text-white text-xs font-semibold shrink-0 [box-shadow:0_0_0_2px_rgba(244,63,94,.25)]"
title={ LocalizeText(openTicketsCount === 1 ? 'modtools.window.tickets.open' : 'modtools.window.tickets.open.many', [ 'count' ], [ openTicketsCount.toString() ]) }>
{ openTicketsCount > 99 ? '99+' : openTicketsCount }
</span> }
</Button>
</div>
</NitroCardContentView>
</NitroCardView> }
{ (openRooms.length > 0) && openRooms.map(roomId => <ModToolsRoomView key={ roomId } roomId={ roomId } onCloseClick={ () => CreateLinkEvent(`mod-tools/close-room-info/${ roomId }`) } />) }