refactor(mod-tools): redesign ModToolsUserView template

Replace the flat striped table with a structured layout that surfaces
the moderation signal at a glance:

Identity header
  Username + ID + classification, presence pill (In room / Online /
  Offline) with colour coding (emerald / sky / zinc) and a matching
  dot, plus a manual refresh button. The pill source-of-truth is
  useRoomUserListSnapshot for the "in room" case (reactive) falling
  back to userInfo.online — tooltip discloses which path produced
  the value.

Stat strip
  Four counter cards in a single row — CFH, Cautions, Bans, Trade
  locks — tinted warn (amber) or danger (rose) when value > 0, neutral
  (zinc) when zero. Big tabular-nums numbers so the moderator sees a
  problem account immediately without parsing rows.

Sectioned body
  Account / Activity / Sanctions / Trading as labelled dl groups
  (grid-cols-[auto_1fr]) replacing the 14-row striped table. Missing
  values render as a dim em-dash instead of an empty cell.

Action bar
  2×2 button grid with react-icons/fa glyphs (FaCommentDots,
  FaEnvelope, FaDoorOpen, FaGavel). Mod Action keeps variant="danger"
  so the destructive action stands out from the three info actions
  (variant="secondary").

No behaviour changes — the same composer / event listeners /
sub-views are wired up; this is a presentation rewrite. Card grows
to min-w-[420px] max-w-[480px] to fit the new layout without
horizontal scroll on mod laptops.
This commit is contained in:
simoleo89
2026-05-20 20:39:55 +02:00
committed by simoleo89
parent ef313adcfa
commit 7ade398610
@@ -1,7 +1,8 @@
import { CreateLinkEvent, GetModeratorUserInfoMessageComposer, ModeratorActionResultMessageEvent, ModeratorUserInfoData, ModeratorUserInfoEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useState } from 'react';
import { FaBan, FaCommentDots, FaDoorOpen, FaEnvelope, FaExchangeAlt, FaExclamationTriangle, FaGavel, FaSync } from 'react-icons/fa';
import { FriendlyTime, LocalizeText, SendMessageComposer } from '../../../../api';
import { Button, Column, DraggableWindowPosition, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
import { useMessageEvent, useRoomUserListSnapshot } from '../../../../hooks';
import { ModToolsUserModActionView } from './ModToolsUserModActionView';
import { ModToolsUserRoomVisitsView } from './ModToolsUserRoomVisitsView';
@@ -13,6 +14,52 @@ interface ModToolsUserViewProps
onCloseClick: () => void;
}
interface StatCardProps
{
icon: React.ReactNode;
label: string;
value: number | string;
tone?: 'neutral' | 'warn' | 'danger';
}
const StatCard: FC<StatCardProps> = ({ icon, label, value, tone = 'neutral' }) =>
{
const numericValue = typeof value === 'number' ? value : parseInt(value as string, 10);
const isElevated = !Number.isNaN(numericValue) && numericValue > 0;
const toneClasses = (() =>
{
if(tone === 'danger' && isElevated) return 'bg-rose-50 border-rose-200 text-rose-700';
if(tone === 'warn' && isElevated) return 'bg-amber-50 border-amber-200 text-amber-700';
return 'bg-zinc-50 border-zinc-200 text-zinc-700';
})();
return (
<div className={ `flex flex-col items-center justify-center px-2 py-1.5 rounded border ${ toneClasses } grow min-w-0` }>
<div className="flex items-center gap-1.5 text-[.7rem] uppercase tracking-wide opacity-70">
<span className="shrink-0">{ icon }</span>
<span className="truncate">{ label }</span>
</div>
<div className="text-lg font-semibold tabular-nums leading-tight">{ value }</div>
</div>
);
};
const Section: FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => (
<div className="flex flex-col gap-1">
<div className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold border-b border-zinc-200 pb-1 mb-0.5">{ title }</div>
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[.8rem] m-0">
{ children }
</dl>
</div>
);
const Field: FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
<>
<dt className="opacity-60 whitespace-nowrap">{ label }</dt>
<dd className="m-0 break-words font-medium">{ value || <span className="opacity-40 italic"></span> }</dd>
</>
);
export const ModToolsUserView: FC<ModToolsUserViewProps> = props =>
{
const { onCloseClick = null, userId = null } = props;
@@ -29,71 +76,19 @@ export const ModToolsUserView: FC<ModToolsUserViewProps> = props =>
[ roomUserList, userId ]
);
const isOnline = isPresentInCurrentRoom || !!(userInfo && userInfo.online);
const presenceLabel = isPresentInCurrentRoom ? 'In room' : (isOnline ? 'Online' : 'Offline');
const presencePillClass = isPresentInCurrentRoom
? 'bg-emerald-100 text-emerald-700 border-emerald-200'
: isOnline
? 'bg-sky-100 text-sky-700 border-sky-200'
: 'bg-zinc-100 text-zinc-600 border-zinc-200';
const presenceDotClass = isPresentInCurrentRoom
? 'bg-emerald-500'
: isOnline
? 'bg-sky-500'
: 'bg-zinc-400';
const userProperties = useMemo(() =>
{
if(!userInfo) return null;
return [
{
localeKey: 'modtools.userinfo.userName',
value: userInfo.userName,
showOnline: true
},
{
localeKey: 'modtools.userinfo.cfhCount',
value: userInfo.cfhCount.toString()
},
{
localeKey: 'modtools.userinfo.abusiveCfhCount',
value: userInfo.abusiveCfhCount.toString()
},
{
localeKey: 'modtools.userinfo.cautionCount',
value: userInfo.cautionCount.toString()
},
{
localeKey: 'modtools.userinfo.banCount',
value: userInfo.banCount.toString()
},
{
localeKey: 'modtools.userinfo.lastSanctionTime',
value: userInfo.lastSanctionTime
},
{
localeKey: 'modtools.userinfo.tradingLockCount',
value: userInfo.tradingLockCount.toString()
},
{
localeKey: 'modtools.userinfo.tradingExpiryDate',
value: userInfo.tradingExpiryDate
},
{
localeKey: 'modtools.userinfo.minutesSinceLastLogin',
value: FriendlyTime.format(userInfo.minutesSinceLastLogin * 60, '.ago', 2)
},
{
localeKey: 'modtools.userinfo.lastPurchaseDate',
value: userInfo.lastPurchaseDate
},
{
localeKey: 'modtools.userinfo.primaryEmailAddress',
value: userInfo.primaryEmailAddress
},
{
localeKey: 'modtools.userinfo.identityRelatedBanCount',
value: userInfo.identityRelatedBanCount.toString()
},
{
localeKey: 'modtools.userinfo.registrationAgeInMinutes',
value: FriendlyTime.format(userInfo.registrationAgeInMinutes * 60, '.ago', 2)
},
{
localeKey: 'modtools.userinfo.userClassification',
value: userInfo.userClassification
}
];
}, [ userInfo ]);
const refresh = () => SendMessageComposer(new GetModeratorUserInfoMessageComposer(userId));
useMessageEvent<ModeratorUserInfoEvent>(ModeratorUserInfoEvent, event =>
{
@@ -114,7 +109,7 @@ export const ModToolsUserView: FC<ModToolsUserViewProps> = props =>
if(!parser || !parser.success || parser.userId !== userId) return;
SendMessageComposer(new GetModeratorUserInfoMessageComposer(userId));
refresh();
});
useEffect(() =>
@@ -126,45 +121,73 @@ export const ModToolsUserView: FC<ModToolsUserViewProps> = props =>
return (
<>
<NitroCardView className="nitro-mod-tools-user" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardView className="nitro-mod-tools-user min-w-[420px] max-w-[480px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ LocalizeText('modtools.userinfo.title', [ 'username' ], [ userInfo.userName ]) } onCloseClick={ () => onCloseClick() } />
<NitroCardContentView className="text-black">
<Grid overflow="hidden">
<Column overflow="auto" size={ 8 }>
<table className="table table-striped table-sm table-text-small text-black m-0">
<tbody>
{ userProperties.map( (property, index) =>
{
<NitroCardContentView className="text-black" gap={ 2 }>
{/* Identity header: name + presence pill + manual refresh */}
<div className="flex items-center gap-2 bg-gradient-to-r from-sky-50 to-transparent rounded p-2 border border-sky-100">
<div className="flex flex-col grow min-w-0">
<Text bold className="truncate text-base leading-tight">{ userInfo.userName }</Text>
<Text className="opacity-60 text-xs truncate">ID #{ userInfo.userId }{ userInfo.userClassification ? ` · ${ userInfo.userClassification }` : '' }</Text>
</div>
<span
className={ `inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium border ${ presencePillClass }` }
title={ isPresentInCurrentRoom ? 'In the room you are observing' : (isOnline ? 'Online on the hotel' : 'Offline at panel open') }>
<span className={ `inline-block w-2 h-2 rounded-full ${ presenceDotClass }` } />
{ presenceLabel }
</span>
<button
className="inline-flex items-center justify-center w-7 h-7 rounded text-zinc-500 hover:text-sky-700 hover:bg-sky-100 transition-colors shrink-0"
onClick={ refresh }
title="Refresh user info">
<FaSync size={ 12 } />
</button>
</div>
return (
<tr key={ index }>
<th scope="row">{ LocalizeText(property.localeKey) }</th>
<td>
{ property.value }
{ property.showOnline &&
<i className={ `icon icon-pf-${ isOnline ? 'online' : 'offline' } ms-2` } title={ isPresentInCurrentRoom ? 'In this room' : (userInfo.online ? 'Online' : 'Offline') } /> }
</td>
</tr>
);
}) }
</tbody>
</table>
</Column>
<Column gap={ 1 } size={ 4 }>
<Button onClick={ event => CreateLinkEvent(`mod-tools/open-user-chatlog/${ userId }`) }>
Room Chat
</Button>
<Button onClick={ event => setSendMessageVisible(!sendMessageVisible) }>
Send Message
</Button>
<Button onClick={ event => setRoomVisitsVisible(!roomVisitsVisible) }>
Room Visits
</Button>
<Button onClick={ event => setModActionVisible(!modActionVisible) }>
Mod Action
</Button>
</Column>
</Grid>
{/* Moderation stat strip */}
<div className="flex gap-1.5">
<StatCard icon={ <FaExclamationTriangle size={ 10 } /> } label="CFH" tone="warn" value={ userInfo.cfhCount } />
<StatCard icon={ <FaGavel size={ 10 } /> } label="Cautions" tone="warn" value={ userInfo.cautionCount } />
<StatCard icon={ <FaBan size={ 10 } /> } label="Bans" tone="danger" value={ userInfo.banCount } />
<StatCard icon={ <FaExchangeAlt size={ 10 } /> } label="Trade locks" tone="danger" value={ userInfo.tradingLockCount } />
</div>
{/* Body sections */}
<div className="flex flex-col gap-2 max-h-[300px] overflow-auto pr-1">
<Section title="Account">
<Field label="Email" value={ userInfo.primaryEmailAddress } />
<Field label="Registered" value={ FriendlyTime.format(userInfo.registrationAgeInMinutes * 60, '.ago', 2) } />
<Field label="Classification" value={ userInfo.userClassification } />
</Section>
<Section title="Activity">
<Field label="Last login" value={ FriendlyTime.format(userInfo.minutesSinceLastLogin * 60, '.ago', 2) } />
<Field label="Last purchase" value={ userInfo.lastPurchaseDate } />
</Section>
<Section title="Sanctions">
<Field label="Abusive CFH" value={ userInfo.abusiveCfhCount } />
<Field label="Last sanction" value={ userInfo.lastSanctionTime } />
<Field label="Identity bans" value={ userInfo.identityRelatedBanCount } />
</Section>
<Section title="Trading">
<Field label="Lock expires" value={ userInfo.tradingExpiryDate } />
</Section>
</div>
{/* Action bar */}
<div className="grid grid-cols-2 gap-1.5 pt-1 border-t border-zinc-200">
<Button gap={ 1 } variant="secondary" onClick={ () => CreateLinkEvent(`mod-tools/open-user-chatlog/${ userId }`) }>
<FaCommentDots size={ 12 } /> Room Chat
</Button>
<Button gap={ 1 } variant="secondary" onClick={ () => setSendMessageVisible(prev => !prev) }>
<FaEnvelope size={ 12 } /> Send Message
</Button>
<Button gap={ 1 } variant="secondary" onClick={ () => setRoomVisitsVisible(prev => !prev) }>
<FaDoorOpen size={ 12 } /> Room Visits
</Button>
<Button gap={ 1 } variant="danger" onClick={ () => setModActionVisible(prev => !prev) }>
<FaGavel size={ 12 } /> Mod Action
</Button>
</div>
</NitroCardContentView>
</NitroCardView>
{ sendMessageVisible &&