mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 23:16:21 +00:00
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:
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user