refactor(mod-tools): redesign all related windows with shared visual language

Applies the visual language introduced in ModToolsUserView yesterday
to every other ModTools window. The design tokens used consistently:

  emerald — present in current room / positive state
  sky     — online / informational / current selection
  zinc    — neutral / disabled
  amber   — warn-level (CFH, alerts, cautions)
  rose    — danger (bans, releases, abusive)

Files redesigned:

ModToolsRoomView
  Identity header with FaDoorOpen, room name + ID, owner-present pill
  (emerald/zinc), manual refresh button. Stat strip: user count (sky)
  + clickable owner name (zinc) opening user info. Quick actions
  (Visit / Chatlog) in a 2-col grid. Moderate panel collapsed into an
  amber-tinted card with the 3 toggles + textarea + two CTAs (Send
  Caution=danger, Send Alert=warning). CTAs disabled until a message
  is typed AND the room info has loaded.

ModToolsUserModActionView
  Numbered 3-step form (CFH topic → sanction → optional message).
  Live preview row showing the chosen topic + sanction as tone-coded
  pills (amber/sky/rose/orange/fuchsia/zinc by action type). Primary
  CTA = Default Sanction, success CTA = Apply Sanction, both
  disabled until the required selections are made.

ModToolsUserSendMessageView
  Recipient header with FaEnvelope and the username, autofocused
  textarea, char counter, single full-width Send button gated on
  non-empty message.

ModToolsUserRoomVisitsView
  Header strip with entry count badge, three-column grid (time / room
  name / visit button), monospace timestamps, hover row highlight,
  empty state with FaDoorOpen icon.

ModToolsUserChatlogView / ModToolsChatlogView / CfhChatlogView
  Loading state with spinner instead of returning null. Cards grow to
  min-w-[460px] max-w-[520px] max-h-[500px] for usable chatlog area.

ChatlogView
  Replace Bootstrap-ish striped table with a CSS grid (60px / 120px /
  1fr). Room-info separator rendered as a sky card with Visit/Tools
  pill buttons. Per-row hover + even-row tint; highlighted rows
  (hasHighlighting) get an amber wash. Username is a button opening
  user info via existing link event. Empty state with FaCommentDots.

ModToolsTicketsView
  Tabs get icons (FaListUl / FaUserCheck / FaCheckSquare) and inline
  count badges (amber/sky/zinc) so the moderator sees the queue size
  at a glance. ticket bucket filtering memoized off the tickets array.

ModToolsOpenIssuesTabView / MyIssuesTabView / PickedIssuesTabView
  Same CSS grid table style. Category renders as a tone-coded pill
  (Open=amber, Mine=sky, All picked=zinc). Action buttons get icons
  (FaHandPointer Pick, FaTools Handle, FaSignOutAlt Release). Empty
  state with FaInbox.

ModToolsIssueInfoView
  Card header with category + topic pills. Details rendered as a dl
  grid instead of a striped table. Caller / Reported names as inline
  link buttons with external-link icon. Chatlog toggle is full-width
  secondary. Resolution buttons in a 3-col grid with intent colours
  (success=Resolved, dark=Useless, danger=Abusive) + a separate
  Release-to-queue button on its own row so it isn't confused with
  the resolutions.

No behaviour changes — all composers, message events, parent state
hookups, and sanction validation paths are unchanged. This is purely
a presentation pass. typecheck + vitest 214/214 + lint:hooks all
clean.
This commit is contained in:
simoleo89
2026-05-20 20:48:24 +02:00
committed by simoleo89
parent 7ade398610
commit d3552a0948
13 changed files with 580 additions and 338 deletions
@@ -1,7 +1,8 @@
import { ChatRecordData, CreateLinkEvent } from '@nitrots/nitro-renderer';
import { FC, useMemo } from 'react';
import { FaCommentDots, FaDoorOpen, FaSignInAlt, FaTools } from 'react-icons/fa';
import { TryVisitRoom } from '../../../../api';
import { Button, Column, Flex, Grid, InfiniteScroll, Text } from '../../../../common';
import { Column, InfiniteScroll } from '../../../../common';
import { useModTools } from '../../../../hooks';
import { ChatlogRecord } from './ChatlogRecord';
@@ -43,46 +44,61 @@ export const ChatlogView: FC<ChatlogViewProps> = props =>
return results;
}, [ records ]);
const RoomInfo = (props: { roomId: number, roomName: string }) =>
{
return (
<Flex alignItems="center" className="bg-muted rounded p-2" gap={ 2 } justifyContent="between">
<Text bold truncate>{ props.roomName }</Text>
<div className="flex gap-1 shrink-0">
<Button size="sm" onClick={ event => TryVisitRoom(props.roomId) }>Visit</Button>
<Button size="sm" onClick={ event => openRoomInfo(props.roomId) }>Room Tools</Button>
</div>
</Flex>
);
};
const totalMessages = useMemo(
() => allRecords.filter(r => !r.isRoomInfo).length,
[ allRecords ]
);
const RoomInfo = (props: { roomId: number, roomName: string }) => (
<div className="flex items-center gap-2 bg-gradient-to-r from-sky-50 to-transparent rounded p-2 border border-sky-100 my-1">
<FaDoorOpen className="text-sky-600 shrink-0" size={ 14 } />
<div className="font-semibold leading-tight grow truncate">{ props.roomName }</div>
<div className="flex gap-1 shrink-0">
<button
className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs bg-white border border-sky-200 text-sky-700 hover:bg-sky-100 transition-colors"
onClick={ () => TryVisitRoom(props.roomId) }>
<FaSignInAlt size={ 10 } /> Visit
</button>
<button
className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs bg-white border border-sky-200 text-sky-700 hover:bg-sky-100 transition-colors"
onClick={ () => openRoomInfo(props.roomId) }>
<FaTools size={ 10 } /> Tools
</button>
</div>
</div>
);
const isEmpty = !records || records.length === 0 || totalMessages === 0;
return (
<>
<Column fit gap={ 0 } overflow="hidden">
<Column gap={ 2 }>
<Grid className="text-black font-bold border-bottom pb-1 text-[11px] uppercase opacity-60 tracking-wider" gap={ 1 }>
<div className="col-span-2">Time</div>
<div className="col-span-3">User</div>
<div className="col-span-7">Message</div>
</Grid>
</Column>
{ (records && (records.length > 0)) &&
<InfiniteScroll rowRender={ (row: ChatlogRecord) =>
{
return (
<>
{ row.isRoomInfo &&
<RoomInfo roomId={ row.roomId } roomName={ row.roomName } /> }
{ !row.isRoomInfo &&
<Grid alignItems="center" className="log-entry py-1.5 border-bottom even:bg-black/[0.03]" fullHeight={ false } gap={ 1 }>
<Text className="col-span-2 opacity-60 text-[11px]">{ row.timestamp }</Text>
<Text bold pointer underline className="col-span-3" onClick={ event => CreateLinkEvent(`mod-tools/open-user-info/${ row.habboId }`) }>{ row.username }</Text>
<Text textBreak wrap className="col-span-7">{ row.message }</Text>
</Grid> }
</>
);
} } rows={ allRecords } /> }
</Column>
</>
<Column fit gap={ 0 } overflow="hidden">
{/* Column headers */}
<div className="grid grid-cols-[60px_120px_1fr] gap-2 text-[.7rem] uppercase tracking-wide opacity-60 font-semibold border-b border-zinc-200 pb-1 px-1">
<div>Time</div>
<div>User</div>
<div>Message</div>
</div>
{ isEmpty
? <div className="flex flex-col items-center justify-center gap-1 py-6 opacity-50 text-sm">
<FaCommentDots size={ 22 } />
<span>No messages</span>
</div>
: <InfiniteScroll rowRender={ (row: ChatlogRecord) =>
{
if(row.isRoomInfo) return <RoomInfo roomId={ row.roomId } roomName={ row.roomName } />;
return (
<div className={ `grid grid-cols-[60px_120px_1fr] gap-2 items-start px-1 py-1.5 text-sm border-b border-zinc-100 even:bg-black/[0.02] hover:bg-sky-50/50 transition-colors ${ row.hasHighlighting ? 'bg-amber-50/60' : '' }` }>
<span className="font-mono text-[.7rem] opacity-70 tabular-nums whitespace-nowrap">{ row.timestamp }</span>
<button
className="text-left font-semibold text-sky-700 hover:text-sky-900 hover:underline truncate"
onClick={ () => CreateLinkEvent(`mod-tools/open-user-info/${ row.habboId }`) }>
{ row.username }
</button>
<span className="break-words">{ row.message }</span>
</div>
);
} } rows={ allRecords } /> }
</Column>
);
};
@@ -26,11 +26,10 @@ export const ModToolsChatlogView: FC<ModToolsChatlogViewProps> = props =>
if(!roomChatlog) return null;
return (
<NitroCardView className="nitro-mod-tools-chatlog min-w-[400px] max-h-[500px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardView className="nitro-mod-tools-chatlog min-w-[460px] max-w-[520px] max-h-[500px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ 'Room Chatlog' } onCloseClick={ onCloseClick } />
<NitroCardContentView className="text-black" overflow="auto">
{ roomChatlog &&
<ChatlogView records={ [ roomChatlog ] } /> }
<NitroCardContentView className="text-black" gap={ 1 } overflow="auto">
<ChatlogView records={ [ roomChatlog ] } />
</NitroCardContentView>
</NitroCardView>
);
@@ -1,7 +1,8 @@
import { CreateLinkEvent, GetModeratorRoomInfoMessageComposer, ModerateRoomMessageComposer, ModeratorActionMessageComposer, ModeratorRoomInfoEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FaBullhorn, FaCommentDots, FaDoorOpen, FaExclamationTriangle, FaSignInAlt, FaSync, FaUserShield, FaUsers } from 'react-icons/fa';
import { SendMessageComposer, TryVisitRoom } from '../../../../api';
import { Button, Column, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
import { useMessageEvent } from '../../../../hooks';
interface ModToolsRoomViewProps
@@ -25,7 +26,9 @@ export const ModToolsRoomView: FC<ModToolsRoomViewProps> = props =>
const [ changeRoomName, setChangeRoomName ] = useState(false);
const [ message, setMessage ] = useState('');
const handleClick = (action: string, value?: string) =>
const refresh = () => SendMessageComposer(new GetModeratorRoomInfoMessageComposer(roomId));
const handleClick = (action: string) =>
{
if(!action) return;
@@ -66,55 +69,102 @@ export const ModToolsRoomView: FC<ModToolsRoomViewProps> = props =>
SendMessageComposer(new GetModeratorRoomInfoMessageComposer(roomId));
setInfoRequested(true);
}, [ roomId, infoRequested, setInfoRequested ]);
}, [ roomId, infoRequested ]);
const isLoaded = loadedRoomId !== null;
const hasMessage = message.trim().length > 0;
const ownerPillClass = ownerInRoom
? 'bg-emerald-100 text-emerald-700 border-emerald-200'
: 'bg-zinc-100 text-zinc-600 border-zinc-200';
const ownerDotClass = ownerInRoom ? 'bg-emerald-500' : 'bg-zinc-400';
return (
<NitroCardView className="nitro-mod-tools-room min-w-[280px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ 'Room Info' } onCloseClick={ event => onCloseClick() } />
<NitroCardView className="nitro-mod-tools-room min-w-[400px] max-w-[460px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ 'Room Info' } onCloseClick={ () => onCloseClick() } />
<NitroCardContentView className="text-black" gap={ 2 }>
{ name &&
<div className="bg-muted rounded px-2 py-1.5 text-center">
<Text bold truncate>{ name }</Text>
{/* Identity header */}
<div className="flex items-center gap-2 bg-gradient-to-r from-sky-50 to-transparent rounded p-2 border border-sky-100">
<FaDoorOpen className="text-sky-600 shrink-0" size={ 16 } />
<div className="flex flex-col grow min-w-0">
<Text bold className="truncate text-base leading-tight">{ name || 'Loading…' }</Text>
<Text className="opacity-60 text-xs truncate">Room #{ roomId }</Text>
</div>
}
<div className="flex gap-2">
<Column grow gap={ 1 }>
<div className="flex items-center gap-1">
<Text bold className="opacity-60 shrink-0">Owner:</Text>
<Text bold pointer truncate underline onClick={ () => CreateLinkEvent(`mod-tools/open-user-info/${ ownerId }`) }>{ ownerName }</Text>
<span
className={ `inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium border ${ ownerPillClass }` }
title={ ownerInRoom ? 'The room owner is currently inside' : 'The room owner is NOT inside' }>
<span className={ `inline-block w-2 h-2 rounded-full ${ ownerDotClass }` } />
{ ownerInRoom ? 'Owner here' : 'Owner away' }
</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 room info">
<FaSync size={ 12 } />
</button>
</div>
{/* Stat strip */}
<div className="flex gap-1.5">
<div className="flex flex-col items-center justify-center px-2 py-1.5 rounded border bg-sky-50 border-sky-200 text-sky-700 grow min-w-0">
<div className="flex items-center gap-1.5 text-[.7rem] uppercase tracking-wide opacity-70">
<FaUsers size={ 10 } /><span>Users</span>
</div>
<div className="flex items-center gap-1">
<Text bold className="opacity-60 shrink-0">Users in room:</Text>
<Text>{ usersInRoom }</Text>
<div className="text-lg font-semibold tabular-nums leading-tight">{ usersInRoom }</div>
</div>
<div className="flex flex-col items-center justify-center px-2 py-1.5 rounded border bg-zinc-50 border-zinc-200 text-zinc-700 grow min-w-0">
<div className="flex items-center gap-1.5 text-[.7rem] uppercase tracking-wide opacity-70">
<FaUserShield size={ 10 } /><span>Owner</span>
</div>
<div className="flex items-center gap-1">
<Text bold className="opacity-60 shrink-0">Owner here:</Text>
<Text className={ ownerInRoom ? 'text-green-700' : 'text-red-700' }>{ ownerInRoom ? 'Yes' : 'No' }</Text>
<div
className="text-sm font-semibold leading-tight truncate max-w-full underline cursor-pointer hover:text-sky-700"
onClick={ () => ownerId && CreateLinkEvent(`mod-tools/open-user-info/${ ownerId }`) }
title={ ownerName ? `Open ${ ownerName }'s info` : '' }>
{ ownerName || '—' }
</div>
</Column>
<div className="flex flex-col gap-1 shrink-0">
<Button onClick={ event => TryVisitRoom(roomId) }>Visit Room</Button>
<Button onClick={ event => CreateLinkEvent(`mod-tools/open-room-chatlog/${ roomId }`) }>Chatlog</Button>
</div>
</div>
<Column className="bg-muted rounded p-2" gap={ 1 }>
<div className="flex items-center gap-2">
{/* Quick actions */}
<div className="grid grid-cols-2 gap-1.5">
<Button gap={ 1 } variant="secondary" onClick={ () => TryVisitRoom(roomId) }>
<FaSignInAlt size={ 12 } /> Visit Room
</Button>
<Button gap={ 1 } variant="secondary" onClick={ () => CreateLinkEvent(`mod-tools/open-room-chatlog/${ roomId }`) }>
<FaCommentDots size={ 12 } /> Chatlog
</Button>
</div>
{/* Moderate panel */}
<div className="flex flex-col gap-1.5 bg-amber-50 border border-amber-200 rounded p-2">
<div className="flex items-center gap-1.5 text-[.7rem] uppercase tracking-wide font-semibold text-amber-800">
<FaExclamationTriangle size={ 10 } /> Moderate room
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input checked={ kickUsers } className="form-check-input" type="checkbox" onChange={ event => setKickUsers(event.target.checked) } />
<Text small>Kick everyone out</Text>
</div>
<div className="flex items-center gap-2">
<span>Kick everyone out</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input checked={ lockRoom } className="form-check-input" type="checkbox" onChange={ event => setLockRoom(event.target.checked) } />
<Text small>Enable the doorbell</Text>
</div>
<div className="flex items-center gap-2">
<span>Enable the doorbell</span>
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input checked={ changeRoomName } className="form-check-input" type="checkbox" onChange={ event => setChangeRoomName(event.target.checked) } />
<Text small>Change room name</Text>
<span>Change room name</span>
</label>
<textarea
className="min-h-[60px] px-2 py-1.5 rounded text-sm border border-amber-300 bg-white/70 focus:outline-none focus:ring-2 focus:ring-amber-400"
placeholder="Mandatory message to deliver with the action…"
value={ message }
onChange={ event => setMessage(event.target.value) }
/>
<div className="flex gap-1.5">
<Button className="grow" disabled={ !hasMessage || !isLoaded } gap={ 1 } variant="danger" onClick={ () => handleClick('send_message') }>
<FaBullhorn size={ 12 } /> Send Caution
</Button>
<Button className="grow" disabled={ !hasMessage || !isLoaded } gap={ 1 } variant="warning" onClick={ () => handleClick('alert_only') }>
<FaExclamationTriangle size={ 12 } /> Send Alert
</Button>
</div>
</Column>
<textarea className="min-h-[60px] px-2 py-1.5 rounded text-sm border border-black/10" placeholder="Type a mandatory message..." value={ message } onChange={ event => setMessage(event.target.value) }></textarea>
<div className="flex gap-2">
<Button className="grow" variant="danger" onClick={ event => handleClick('send_message') }>Send Caution</Button>
<Button className="grow" onClick={ event => handleClick('alert_only') }>Send Alert</Button>
</div>
</NitroCardContentView>
</NitroCardView>
@@ -1,7 +1,8 @@
import { CfhChatlogData, CfhChatlogEvent, GetCfhChatlogMessageComposer } from '@nitrots/nitro-renderer';
import { FC } from 'react';
import { FaSpinner } from 'react-icons/fa';
import { useNitroQuery } from '../../../../api/nitro-query';
import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { ChatlogView } from '../chatlog/ChatlogView';
interface CfhChatlogViewProps
@@ -24,10 +25,15 @@ export const CfhChatlogView: FC<CfhChatlogViewProps> = props =>
});
return (
<NitroCardView className="nitro-mod-tools-chatlog" theme="primary-slim">
<NitroCardHeaderView headerText={ 'Issue Chatlog' } onCloseClick={ onCloseClick } />
<NitroCardContentView className="text-black">
{ chatlogData && <ChatlogView records={ [ chatlogData.chatRecord ] } /> }
<NitroCardView className="nitro-mod-tools-chatlog min-w-[460px] max-w-[520px] max-h-[500px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ `Issue #${ issueId } Chatlog` } onCloseClick={ onCloseClick } />
<NitroCardContentView className="text-black" gap={ 1 }>
{ chatlogData
? <ChatlogView records={ [ chatlogData.chatRecord ] } />
: <div className="flex flex-col items-center justify-center gap-2 py-8 opacity-50 text-sm">
<FaSpinner className="animate-spin" size={ 22 } />
<span>Loading chatlog</span>
</div> }
</NitroCardContentView>
</NitroCardView>
);
@@ -1,7 +1,8 @@
import { CloseIssuesMessageComposer, ReleaseIssuesMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useState } from 'react';
import { FaBan, FaCheck, FaCommentDots, FaExternalLinkAlt, FaSignOutAlt, FaTrashAlt } from 'react-icons/fa';
import { GetIssueCategoryName, LocalizeText, SendMessageComposer } from '../../../../api';
import { Button, Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useModTools } from '../../../../hooks';
import { CfhChatlogView } from './CfhChatlogView';
@@ -11,76 +12,102 @@ interface IssueInfoViewProps
onIssueInfoClosed(issueId: number): void;
}
const Field: FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
<>
<dt className="opacity-60 whitespace-nowrap">{ label }</dt>
<dd className="m-0 break-words font-medium">{ children || <span className="opacity-40 italic"></span> }</dd>
</>
);
export const ModToolsIssueInfoView: FC<IssueInfoViewProps> = props =>
{
const { issueId = null, onIssueInfoClosed = null } = props;
const [ cfhChatlogOpen, setcfhChatlogOpen ] = useState(false);
const [ cfhChatlogOpen, setCfhChatlogOpen ] = useState(false);
const { tickets = [], openUserInfo = null } = useModTools();
const ticket = tickets.find(issue => (issue.issueId === issueId));
const releaseIssue = (issueId: number) =>
const releaseIssue = () =>
{
SendMessageComposer(new ReleaseIssuesMessageComposer([ issueId ]));
onIssueInfoClosed(issueId);
};
const closeIssue = (resolutionType: number) =>
{
SendMessageComposer(new CloseIssuesMessageComposer([ issueId ], resolutionType));
onIssueInfoClosed(issueId);
};
if(!ticket) return null;
return (
<>
<NitroCardView className="nitro-mod-tools-handle-issue" theme="primary-slim">
<NitroCardHeaderView headerText={ 'Resolving issue ' + issueId } onCloseClick={ () => onIssueInfoClosed(issueId) } />
<NitroCardContentView className="text-black">
<Text fontSize={ 4 }>Issue Information</Text>
<Grid overflow="auto">
<Column size={ 8 }>
<table className="table table-striped table-sm table-text-small text-black m-0">
<tbody>
<tr>
<th>Source</th>
<td>{ GetIssueCategoryName(ticket.categoryId) }</td>
</tr>
<tr>
<th>Category</th>
<td className="text-break">{ LocalizeText('help.cfh.topic.' + ticket.reportedCategoryId) }</td>
</tr>
<tr>
<th>Description</th>
<td className="text-break">{ ticket.message }</td>
</tr>
<tr>
<th>Caller</th>
<td>
<Text bold pointer underline onClick={ event => openUserInfo(ticket.reporterUserId) }>{ ticket.reporterUserName }</Text>
</td>
</tr>
<tr>
<th>Reported User</th>
<td>
<Text bold pointer underline onClick={ event => openUserInfo(ticket.reportedUserId) }>{ ticket.reportedUserName }</Text>
</td>
</tr>
</tbody>
</table>
</Column>
<Column gap={ 1 } size={ 4 }>
<Button variant="secondary" onClick={ () => setcfhChatlogOpen(!cfhChatlogOpen) }>Chatlog</Button>
<Button onClick={ event => closeIssue(CloseIssuesMessageComposer.RESOLUTION_USELESS) }>Close as useless</Button>
<Button variant="danger" onClick={ event => closeIssue(CloseIssuesMessageComposer.RESOLUTION_ABUSIVE) }>Close as abusive</Button>
<Button variant="success" onClick={ event => closeIssue(CloseIssuesMessageComposer.RESOLUTION_RESOLVED) }>Close as resolved</Button>
<Button variant="secondary" onClick={ event => releaseIssue(issueId) } >Release</Button>
</Column>
</Grid>
<NitroCardView className="nitro-mod-tools-handle-issue min-w-[440px] max-w-[500px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ `Resolving issue #${ issueId }` } onCloseClick={ () => onIssueInfoClosed(issueId) } />
<NitroCardContentView className="text-black" gap={ 2 }>
{/* Issue header */}
<div className="flex items-center gap-2 bg-gradient-to-r from-amber-50 to-transparent rounded p-2 border border-amber-100">
<FaCommentDots className="text-amber-600 shrink-0" size={ 16 } />
<div className="flex flex-col grow min-w-0">
<div className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">Issue #{ issueId }</div>
<div className="font-semibold leading-tight truncate">{ GetIssueCategoryName(ticket.categoryId) }</div>
</div>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border bg-white border-amber-200 text-amber-800">
{ LocalizeText('help.cfh.topic.' + ticket.reportedCategoryId) }
</span>
</div>
{/* Details */}
<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">Details</div>
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[.8rem] m-0">
<Field label="Source">{ GetIssueCategoryName(ticket.categoryId) }</Field>
<Field label="Category">{ LocalizeText('help.cfh.topic.' + ticket.reportedCategoryId) }</Field>
<Field label="Description">{ ticket.message }</Field>
<Field label="Caller">
<button
className="font-semibold text-sky-700 hover:text-sky-900 hover:underline inline-flex items-center gap-1"
onClick={ () => openUserInfo(ticket.reporterUserId) }>
{ ticket.reporterUserName } <FaExternalLinkAlt size={ 8 } className="opacity-60" />
</button>
</Field>
<Field label="Reported">
<button
className="font-semibold text-sky-700 hover:text-sky-900 hover:underline inline-flex items-center gap-1"
onClick={ () => openUserInfo(ticket.reportedUserId) }>
{ ticket.reportedUserName } <FaExternalLinkAlt size={ 8 } className="opacity-60" />
</button>
</Field>
</dl>
</div>
{/* Tools */}
<Button gap={ 1 } variant="secondary" onClick={ () => setCfhChatlogOpen(prev => !prev) }>
<FaCommentDots size={ 12 } /> { cfhChatlogOpen ? 'Close chatlog' : 'View chatlog' }
</Button>
{/* Resolution buttons */}
<div className="flex flex-col gap-1.5 pt-1 border-t border-zinc-200">
<div className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">Resolve as</div>
<div className="grid grid-cols-3 gap-1.5">
<Button gap={ 1 } variant="success" onClick={ () => closeIssue(CloseIssuesMessageComposer.RESOLUTION_RESOLVED) }>
<FaCheck size={ 11 } /> Resolved
</Button>
<Button gap={ 1 } variant="dark" onClick={ () => closeIssue(CloseIssuesMessageComposer.RESOLUTION_USELESS) }>
<FaTrashAlt size={ 11 } /> Useless
</Button>
<Button gap={ 1 } variant="danger" onClick={ () => closeIssue(CloseIssuesMessageComposer.RESOLUTION_ABUSIVE) }>
<FaBan size={ 11 } /> Abusive
</Button>
</div>
<Button gap={ 1 } variant="secondary" onClick={ releaseIssue }>
<FaSignOutAlt size={ 12 } /> Release back to queue
</Button>
</div>
</NitroCardContentView>
</NitroCardView>
{ cfhChatlogOpen &&
<CfhChatlogView issueId={ issueId } onCloseClick={ () => setcfhChatlogOpen(false) }/> }
<CfhChatlogView issueId={ issueId } onCloseClick={ () => setCfhChatlogOpen(false) } /> }
</>
);
};
@@ -1,7 +1,7 @@
import { IssueMessageData, ReleaseIssuesMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useRef } from 'react';
import { SendMessageComposer } from '../../../../api';
import { Button, Column, Grid } from '../../../../common';
import { FaClock, FaInbox, FaSignOutAlt, FaTools, FaUser } from 'react-icons/fa';
import { GetIssueCategoryName, SendMessageComposer } from '../../../../api';
interface ModToolsMyIssuesTabViewProps
{
@@ -24,35 +24,45 @@ export const ModToolsMyIssuesTabView: FC<ModToolsMyIssuesTabViewProps> = props =
setTimeout(() => pendingReleasesRef.current.delete(issueId), 2000);
};
const isEmpty = !myIssues || myIssues.length === 0;
return (
<Column gap={ 0 } overflow="hidden">
<Column gap={ 2 }>
<Grid className="text-black font-bold border-bottom pb-1" gap={ 1 }>
<div className="col-span-2">Type</div>
<div className="col-span-3">Room/Player</div>
<div className="col-span-3">Opened</div>
<div className="col-span-2"></div>
<div className="col-span-2"></div>
</Grid>
</Column>
<Column className="striped-children" gap={ 0 } overflow="auto">
{ myIssues && (myIssues.length > 0) && myIssues.map(issue =>
{
return (
<Grid key={ issue.issueId } alignItems="center" className="text-black py-1 border-bottom" gap={ 1 }>
<div className="col-span-2">{ issue.categoryId }</div>
<div className="col-span-3">{ issue.reportedUserName }</div>
<div className="col-span-3">{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }</div>
<div className="col-span-2">
<Button variant="primary" onClick={ event => handleIssue(issue.issueId) }>Handle</Button>
</div>
<div className="col-span-2">
<Button variant="danger" onClick={ () => releaseIssue(issue.issueId) }>Release</Button>
</div>
</Grid>
);
}) }
</Column>
</Column>
<div className="flex flex-col gap-1 overflow-hidden">
<div className="grid grid-cols-[100px_1fr_100px_90px_90px] gap-2 text-[.7rem] uppercase tracking-wide opacity-60 font-semibold border-b border-zinc-200 pb-1 px-1">
<div>Type</div>
<div className="flex items-center gap-1"><FaUser size={ 10 } /> Reported</div>
<div className="flex items-center gap-1"><FaClock size={ 10 } /> Opened</div>
<div></div>
<div></div>
</div>
{ isEmpty
? <div className="flex flex-col items-center justify-center gap-1 py-8 opacity-50 text-sm">
<FaInbox size={ 22 } />
<span>No issues picked by you</span>
</div>
: <div className="flex flex-col overflow-auto">
{ myIssues.map(issue => (
<div key={ issue.issueId } className="grid grid-cols-[100px_1fr_100px_90px_90px] gap-2 items-center px-1 py-1.5 text-sm border-b border-zinc-100 even:bg-black/[0.02] hover:bg-sky-50/50 transition-colors">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border bg-sky-50 text-sky-800 border-sky-200 w-fit">
{ GetIssueCategoryName(issue.categoryId) }
</span>
<span className="font-medium truncate">{ issue.reportedUserName }</span>
<span className="font-mono text-[.75rem] opacity-70 tabular-nums">
{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }
</span>
<button
className="inline-flex items-center justify-center gap-1 px-2 py-1 rounded text-xs font-medium bg-sky-600 text-white hover:bg-sky-700 transition-colors"
onClick={ () => handleIssue(issue.issueId) }>
<FaTools size={ 10 } /> Handle
</button>
<button
className="inline-flex items-center justify-center gap-1 px-2 py-1 rounded text-xs font-medium bg-rose-600 text-white hover:bg-rose-700 transition-colors"
onClick={ () => releaseIssue(issue.issueId) }>
<FaSignOutAlt size={ 10 } /> Release
</button>
</div>
)) }
</div> }
</div>
);
};
@@ -1,7 +1,7 @@
import { IssueMessageData, PickIssuesMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useRef } from 'react';
import { SendMessageComposer } from '../../../../api';
import { Button, Column, Grid } from '../../../../common';
import { FaClock, FaHandPointer, FaInbox, FaUser } from 'react-icons/fa';
import { GetIssueCategoryName, SendMessageComposer } from '../../../../api';
interface ModToolsOpenIssuesTabViewProps
{
@@ -23,31 +23,39 @@ export const ModToolsOpenIssuesTabView: FC<ModToolsOpenIssuesTabViewProps> = pro
setTimeout(() => pendingPicksRef.current.delete(issueId), 2000);
};
const isEmpty = !openIssues || openIssues.length === 0;
return (
<Column gap={ 0 } overflow="hidden">
<Column gap={ 2 }>
<Grid className="text-black font-bold border-bottom pb-1" gap={ 1 }>
<div className="col-span-2">Type</div>
<div className="col-span-3">Room/Player</div>
<div className="col-span-4">Opened</div>
<div className="col-span-3"></div>
</Grid>
</Column>
<Column className="striped-children" gap={ 0 } overflow="auto">
{ openIssues && (openIssues.length > 0) && openIssues.map(issue =>
{
return (
<Grid key={ issue.issueId } alignItems="center" className="text-black py-1 border-bottom" gap={ 1 }>
<div className="col-span-2">{ issue.categoryId }</div>
<div className="col-span-3">{ issue.reportedUserName }</div>
<div className="col-span-4">{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }</div>
<div className="col-span-3">
<Button variant="success" onClick={ () => pickIssue(issue.issueId) }>Pick Issue</Button>
</div>
</Grid>
);
}) }
</Column>
</Column>
<div className="flex flex-col gap-1 overflow-hidden">
<div className="grid grid-cols-[100px_1fr_100px_100px] gap-2 text-[.7rem] uppercase tracking-wide opacity-60 font-semibold border-b border-zinc-200 pb-1 px-1">
<div>Type</div>
<div className="flex items-center gap-1"><FaUser size={ 10 } /> Reported</div>
<div className="flex items-center gap-1"><FaClock size={ 10 } /> Opened</div>
<div></div>
</div>
{ isEmpty
? <div className="flex flex-col items-center justify-center gap-1 py-8 opacity-50 text-sm">
<FaInbox size={ 22 } />
<span>No open issues</span>
</div>
: <div className="flex flex-col overflow-auto">
{ openIssues.map(issue => (
<div key={ issue.issueId } className="grid grid-cols-[100px_1fr_100px_100px] gap-2 items-center px-1 py-1.5 text-sm border-b border-zinc-100 even:bg-black/[0.02] hover:bg-amber-50/50 transition-colors">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border bg-amber-50 text-amber-800 border-amber-200 w-fit">
{ GetIssueCategoryName(issue.categoryId) }
</span>
<span className="font-medium truncate">{ issue.reportedUserName }</span>
<span className="font-mono text-[.75rem] opacity-70 tabular-nums">
{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }
</span>
<button
className="inline-flex items-center justify-center gap-1 px-2 py-1 rounded text-xs font-medium bg-emerald-600 text-white hover:bg-emerald-700 transition-colors"
onClick={ () => pickIssue(issue.issueId) }>
<FaHandPointer size={ 10 } /> Pick
</button>
</div>
)) }
</div> }
</div>
);
};
@@ -1,6 +1,7 @@
import { IssueMessageData } from '@nitrots/nitro-renderer';
import { FC } from 'react';
import { Column, Grid } from '../../../../common';
import { FaClock, FaInbox, FaUser, FaUserShield } from 'react-icons/fa';
import { GetIssueCategoryName } from '../../../../api';
interface ModToolsPickedIssuesTabViewProps
{
@@ -10,30 +11,35 @@ interface ModToolsPickedIssuesTabViewProps
export const ModToolsPickedIssuesTabView: FC<ModToolsPickedIssuesTabViewProps> = props =>
{
const { pickedIssues = null } = props;
const isEmpty = !pickedIssues || pickedIssues.length === 0;
return (
<Column gap={ 0 } overflow="hidden">
<Column gap={ 2 }>
<Grid className="text-black font-bold border-bottom pb-1" gap={ 1 }>
<div className="col-span-2">Type</div>
<div className="col-span-3">Room/Player</div>
<div className="col-span-4">Opened</div>
<div className="col-span-3">Picker</div>
</Grid>
</Column>
<Column className="striped-children" gap={ 0 } overflow="auto">
{ pickedIssues && (pickedIssues.length > 0) && pickedIssues.map(issue =>
{
return (
<Grid key={ issue.issueId } alignItems="center" className="text-black py-1 border-bottom" gap={ 1 }>
<div className="col-span-2">{ issue.categoryId }</div>
<div className="col-span-3">{ issue.reportedUserName }</div>
<div className="col-span-4">{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }</div>
<div className="col-span-3">{ issue.pickerUserName }</div>
</Grid>
);
}) }
</Column>
</Column>
<div className="flex flex-col gap-1 overflow-hidden">
<div className="grid grid-cols-[100px_1fr_100px_120px] gap-2 text-[.7rem] uppercase tracking-wide opacity-60 font-semibold border-b border-zinc-200 pb-1 px-1">
<div>Type</div>
<div className="flex items-center gap-1"><FaUser size={ 10 } /> Reported</div>
<div className="flex items-center gap-1"><FaClock size={ 10 } /> Opened</div>
<div className="flex items-center gap-1"><FaUserShield size={ 10 } /> Picker</div>
</div>
{ isEmpty
? <div className="flex flex-col items-center justify-center gap-1 py-8 opacity-50 text-sm">
<FaInbox size={ 22 } />
<span>No picked issues</span>
</div>
: <div className="flex flex-col overflow-auto">
{ pickedIssues.map(issue => (
<div key={ issue.issueId } className="grid grid-cols-[100px_1fr_100px_120px] gap-2 items-center px-1 py-1.5 text-sm border-b border-zinc-100 even:bg-black/[0.02]">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border bg-zinc-50 text-zinc-700 border-zinc-200 w-fit">
{ GetIssueCategoryName(issue.categoryId) }
</span>
<span className="font-medium truncate">{ issue.reportedUserName }</span>
<span className="font-mono text-[.75rem] opacity-70 tabular-nums">
{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }
</span>
<span className="truncate font-medium opacity-80">{ issue.pickerUserName }</span>
</div>
)) }
</div> }
</div>
);
};
@@ -1,5 +1,6 @@
import { GetSessionDataManager, IssueMessageData } from '@nitrots/nitro-renderer';
import { FC, useState } from 'react';
import { FC, useMemo, useState } from 'react';
import { FaCheckSquare, FaListUl, FaUserCheck } from 'react-icons/fa';
import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../../../common';
import { useModTools } from '../../../../hooks';
import { ModToolsIssueInfoView } from './ModToolsIssueInfoView';
@@ -12,11 +13,30 @@ interface ModToolsTicketsViewProps
onCloseClick: () => void;
}
const TABS: string[] = [
'Open Issues',
'My Issues',
'Picked Issues'
];
interface TabBadgeProps
{
label: string;
count: number;
icon: React.ReactNode;
tone: 'amber' | 'sky' | 'zinc';
}
const TONE_MAP: Record<TabBadgeProps['tone'], string> = {
amber: 'bg-amber-500 text-white',
sky: 'bg-sky-500 text-white',
zinc: 'bg-zinc-400 text-white'
};
const TabLabel: FC<TabBadgeProps> = ({ label, count, icon, tone }) => (
<span className="inline-flex items-center gap-1.5">
<span className="opacity-80">{ icon }</span>
<span>{ label }</span>
{ count > 0 &&
<span className={ `inline-flex items-center justify-center min-w-[1.25rem] h-4 px-1 rounded-full text-[10px] font-semibold ${ TONE_MAP[tone] }` }>
{ count > 99 ? '99+' : count }
</span> }
</span>
);
export const ModToolsTicketsView: FC<ModToolsTicketsViewProps> = props =>
{
@@ -25,9 +45,15 @@ export const ModToolsTicketsView: FC<ModToolsTicketsViewProps> = props =>
const [ issueInfoWindows, setIssueInfoWindows ] = useState<number[]>([]);
const { tickets = [] } = useModTools();
const openIssues = tickets.filter(issue => issue.state === IssueMessageData.STATE_OPEN);
const myIssues = tickets.filter(issue => (issue.state === IssueMessageData.STATE_PICKED) && (issue.pickerUserId === GetSessionDataManager().userId));
const pickedIssues = tickets.filter(issue => issue.state === IssueMessageData.STATE_PICKED);
const { openIssues, myIssues, pickedIssues } = useMemo(() =>
{
const ownId = GetSessionDataManager()?.userId;
return {
openIssues: tickets.filter(issue => issue.state === IssueMessageData.STATE_OPEN),
myIssues: tickets.filter(issue => (issue.state === IssueMessageData.STATE_PICKED) && (issue.pickerUserId === ownId)),
pickedIssues: tickets.filter(issue => issue.state === IssueMessageData.STATE_PICKED)
};
}, [ tickets ]);
const closeIssue = (issueId: number) =>
{
@@ -56,32 +82,34 @@ export const ModToolsTicketsView: FC<ModToolsTicketsViewProps> = props =>
});
};
const CurrentTabComponent = () =>
const renderTab = () =>
{
switch(currentTab)
{
case 0: return <ModToolsOpenIssuesTabView openIssues={ openIssues }/>;
case 1: return <ModToolsMyIssuesTabView handleIssue={ handleIssue } myIssues={ myIssues }/>;
case 2: return <ModToolsPickedIssuesTabView pickedIssues={ pickedIssues }/>;
case 0: return <ModToolsOpenIssuesTabView openIssues={ openIssues } />;
case 1: return <ModToolsMyIssuesTabView handleIssue={ handleIssue } myIssues={ myIssues } />;
case 2: return <ModToolsPickedIssuesTabView pickedIssues={ pickedIssues } />;
}
return null;
};
return (
<>
<NitroCardView className="nitro-mod-tools-tickets">
<NitroCardView className="nitro-mod-tools-tickets min-w-[520px] max-w-[640px] max-h-[520px]">
<NitroCardHeaderView headerText={ 'Tickets' } onCloseClick={ onCloseClick } />
<NitroCardTabsView>
{ TABS.map((tab, index) =>
{
return (<NitroCardTabsItemView key={ index } isActive={ (currentTab === index) } onClick={ event => setCurrentTab(index) }>
{ tab }
</NitroCardTabsItemView>);
}) }
<NitroCardTabsItemView isActive={ currentTab === 0 } onClick={ () => setCurrentTab(0) }>
<TabLabel label="Open" count={ openIssues.length } icon={ <FaListUl size={ 10 } /> } tone="amber" />
</NitroCardTabsItemView>
<NitroCardTabsItemView isActive={ currentTab === 1 } onClick={ () => setCurrentTab(1) }>
<TabLabel label="Mine" count={ myIssues.length } icon={ <FaUserCheck size={ 10 } /> } tone="sky" />
</NitroCardTabsItemView>
<NitroCardTabsItemView isActive={ currentTab === 2 } onClick={ () => setCurrentTab(2) }>
<TabLabel label="All picked" count={ pickedIssues.length } icon={ <FaCheckSquare size={ 10 } /> } tone="zinc" />
</NitroCardTabsItemView>
</NitroCardTabsView>
<NitroCardContentView gap={ 1 }>
<CurrentTabComponent />
{ renderTab() }
</NitroCardContentView>
</NitroCardView>
{ issueInfoWindows && (issueInfoWindows.length > 0) && issueInfoWindows.map(issueId => <ModToolsIssueInfoView key={ issueId } issueId={ issueId } onIssueInfoClosed={ closeIssue } />) }
@@ -1,5 +1,6 @@
import { ChatRecordData, GetUserChatlogMessageComposer, UserChatlogEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FaSpinner } from 'react-icons/fa';
import { SendMessageComposer } from '../../../../api';
import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useMessageEvent } from '../../../../hooks';
@@ -33,11 +34,15 @@ export const ModToolsUserChatlogView: FC<ModToolsUserChatlogViewProps> = props =
}, [ userId ]);
return (
<NitroCardView className="nitro-mod-tools-chatlog" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ `User Chatlog: ${ username || '' }` } onCloseClick={ onCloseClick } />
<NitroCardContentView className="text-black h-full">
{ userChatlog &&
<ChatlogView records={ userChatlog } /> }
<NitroCardView className="nitro-mod-tools-chatlog min-w-[460px] max-w-[520px] max-h-[500px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ username ? `User Chatlog: ${ username }` : 'User Chatlog' } onCloseClick={ onCloseClick } />
<NitroCardContentView className="text-black h-full" gap={ 1 }>
{ userChatlog
? <ChatlogView records={ userChatlog } />
: <div className="flex flex-col items-center justify-center gap-2 py-8 opacity-50 text-sm">
<FaSpinner className="animate-spin" size={ 22 } />
<span>Loading chatlog</span>
</div> }
</NitroCardContentView>
</NitroCardView>
);
@@ -1,7 +1,8 @@
import { CallForHelpTopicData, DefaultSanctionMessageComposer, ModAlertMessageComposer, ModBanMessageComposer, ModKickMessageComposer, ModMessageMessageComposer, ModMuteMessageComposer, ModTradingLockMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useMemo, useRef, useState } from 'react';
import { FaBan, FaBolt, FaEnvelope, FaExclamationTriangle, FaGavel, FaUserSlash, FaVolumeMute } from 'react-icons/fa';
import { ISelectedUser, LocalizeText, ModActionDefinition, NotificationAlertType, SendMessageComposer } from '../../../../api';
import { Button, DraggableWindowPosition, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useModTools, useNotification } from '../../../../hooks';
interface ModToolsUserModActionViewProps
@@ -25,6 +26,24 @@ const MOD_ACTION_DEFINITIONS = [
new ModActionDefinition(105, 'Message', ModActionDefinition.MESSAGE, 0, 0),
];
const ACTION_ICONS: Record<number, React.ReactNode> = {
[ModActionDefinition.ALERT]: <FaExclamationTriangle size={ 10 } />,
[ModActionDefinition.MUTE]: <FaVolumeMute size={ 10 } />,
[ModActionDefinition.BAN]: <FaBan size={ 10 } />,
[ModActionDefinition.KICK]: <FaUserSlash size={ 10 } />,
[ModActionDefinition.TRADE_LOCK]: <FaGavel size={ 10 } />,
[ModActionDefinition.MESSAGE]: <FaEnvelope size={ 10 } />,
};
const ACTION_TONE: Record<number, string> = {
[ModActionDefinition.ALERT]: 'bg-amber-100 text-amber-800 border-amber-200',
[ModActionDefinition.MUTE]: 'bg-sky-100 text-sky-800 border-sky-200',
[ModActionDefinition.BAN]: 'bg-rose-100 text-rose-800 border-rose-200',
[ModActionDefinition.KICK]: 'bg-orange-100 text-orange-800 border-orange-200',
[ModActionDefinition.TRADE_LOCK]: 'bg-fuchsia-100 text-fuchsia-800 border-fuchsia-200',
[ModActionDefinition.MESSAGE]: 'bg-zinc-100 text-zinc-800 border-zinc-200',
};
export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = props =>
{
const { user = null, onCloseClick = null } = props;
@@ -50,26 +69,20 @@ export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = pro
return values;
}, [ cfhCategories ]);
const sendAlert = (message: string) => simpleAlert(message, NotificationAlertType.DEFAULT, null, null, 'Error');
const sendAlert = (m: string) => simpleAlert(m, NotificationAlertType.DEFAULT, null, null, 'Error');
const sendDefaultSanction = () =>
{
if(isSendingRef.current) return;
let errorMessage: string = null;
const category = topics[selectedTopic];
if(selectedTopic === -1) errorMessage = 'You must select a CFH topic';
if(errorMessage) return sendAlert(errorMessage);
if(selectedTopic === -1) return sendAlert('You must select a CFH topic');
const messageOrDefault = (message.trim().length === 0) ? LocalizeText(`help.cfh.topic.${ category.id }`) : message;
isSendingRef.current = true;
SendMessageComposer(new DefaultSanctionMessageComposer(user.userId, selectedTopic, messageOrDefault));
onCloseClick();
};
@@ -78,7 +91,6 @@ export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = pro
if(isSendingRef.current) return;
let errorMessage: string = null;
const category = topics[selectedTopic];
const sanction = MOD_ACTION_DEFINITIONS[selectedAction];
@@ -87,25 +99,14 @@ export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = pro
else if(!category) errorMessage = 'You must select a CFH topic';
else if(!sanction) errorMessage = 'You must select a sanction';
if(errorMessage)
{
sendAlert(errorMessage);
return;
}
if(errorMessage) return sendAlert(errorMessage);
const messageOrDefault = (message.trim().length === 0) ? LocalizeText(`help.cfh.topic.${ category.id }`) : message;
switch(sanction.actionType)
{
case ModActionDefinition.ALERT: {
if(!settings.alertPermission)
{
sendAlert('You have insufficient permissions');
return;
}
if(!settings.alertPermission) return sendAlert('You have insufficient permissions');
SendMessageComposer(new ModAlertMessageComposer(user.userId, messageOrDefault, category.id));
break;
}
@@ -113,72 +114,106 @@ export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = pro
SendMessageComposer(new ModMuteMessageComposer(user.userId, messageOrDefault, category.id));
break;
case ModActionDefinition.BAN: {
if(!settings.banPermission)
{
sendAlert('You have insufficient permissions');
return;
}
if(!settings.banPermission) return sendAlert('You have insufficient permissions');
SendMessageComposer(new ModBanMessageComposer(user.userId, messageOrDefault, category.id, selectedAction, (sanction.actionId === 106)));
break;
}
case ModActionDefinition.KICK: {
if(!settings.kickPermission)
{
sendAlert('You have insufficient permissions');
return;
}
if(!settings.kickPermission) return sendAlert('You have insufficient permissions');
SendMessageComposer(new ModKickMessageComposer(user.userId, messageOrDefault, category.id));
break;
}
case ModActionDefinition.TRADE_LOCK: {
const numSeconds = (sanction.actionLengthHours * 60);
SendMessageComposer(new ModTradingLockMessageComposer(user.userId, messageOrDefault, numSeconds, category.id));
break;
}
case ModActionDefinition.MESSAGE: {
if(message.trim().length === 0)
{
sendAlert('Please write a message to user');
return;
}
if(message.trim().length === 0) return sendAlert('Please write a message to user');
SendMessageComposer(new ModMessageMessageComposer(user.userId, message, category.id));
break;
}
}
isSendingRef.current = true;
onCloseClick();
};
if(!user) return null;
const selectedSanction = selectedAction >= 0 ? MOD_ACTION_DEFINITIONS[selectedAction] : null;
const selectedTopicName = selectedTopic >= 0 && topics[selectedTopic] ? LocalizeText('help.cfh.topic.' + topics[selectedTopic].id) : null;
const sanctionTone = selectedSanction ? ACTION_TONE[selectedSanction.actionType] : '';
const sanctionIcon = selectedSanction ? ACTION_ICONS[selectedSanction.actionType] : null;
const canSubmit = (selectedTopic !== -1);
return (
<NitroCardView className="nitro-mod-tools-user-action" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ 'Mod Action: ' + (user ? user.username : '') } onCloseClick={ () => onCloseClick() } />
<NitroCardContentView className="text-black">
<select className="form-select form-select-sm" value={ selectedTopic } onChange={ event => setSelectedTopic(parseInt(event.target.value)) }>
<option disabled value={ -1 }>CFH Topic</option>
{ topics.map((topic, index) => <option key={ index } value={ index }>{ LocalizeText('help.cfh.topic.' + topic.id) }</option>) }
</select>
<select className="form-select form-select-sm" value={ selectedAction } onChange={ event => setSelectedAction(parseInt(event.target.value)) }>
<option disabled value={ -1 }>Sanction Type</option>
{ MOD_ACTION_DEFINITIONS.map((action, index) => <option key={ index } value={ index }>{ action.name }</option>) }
</select>
<div className="flex flex-col gap-1">
<Text small>Optional message type, overrides default</Text>
<textarea className="min-h-[calc(1.5em+ .5rem+2px)] px-[.5rem] py-[.25rem] rounded-[.2rem]" value={ message } onChange={ event => setMessage(event.target.value) } />
<NitroCardView className="nitro-mod-tools-user-action min-w-[420px] max-w-[460px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ `Mod Action: ${ user.username }` } onCloseClick={ () => onCloseClick() } />
<NitroCardContentView className="text-black" gap={ 2 }>
{/* Target header */}
<div className="flex items-center gap-2 bg-gradient-to-r from-rose-50 to-transparent rounded p-2 border border-rose-100">
<FaGavel className="text-rose-600 shrink-0" size={ 16 } />
<div className="flex flex-col grow min-w-0">
<div className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">Sanctioning</div>
<div className="font-semibold leading-tight truncate">{ user.username }</div>
</div>
</div>
{/* CFH topic */}
<div className="flex flex-col gap-1">
<label className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">1. CFH Topic</label>
<select className="form-select form-select-sm" value={ selectedTopic } onChange={ event => setSelectedTopic(parseInt(event.target.value)) }>
<option disabled value={ -1 }>Select a topic</option>
{ topics.map((topic, index) => <option key={ index } value={ index }>{ LocalizeText('help.cfh.topic.' + topic.id) }</option>) }
</select>
</div>
{/* Sanction type */}
<div className="flex flex-col gap-1">
<label className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">2. Sanction</label>
<select className="form-select form-select-sm" value={ selectedAction } onChange={ event => setSelectedAction(parseInt(event.target.value)) }>
<option disabled value={ -1 }>Select a sanction</option>
{ MOD_ACTION_DEFINITIONS.map((action, index) => <option key={ index } value={ index }>{ action.name }</option>) }
</select>
</div>
{/* Message */}
<div className="flex flex-col gap-1">
<label className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">3. Custom message <span className="opacity-50 normal-case font-normal">(optional overrides default)</span></label>
<textarea
className="min-h-[60px] px-2 py-1.5 rounded text-sm border border-zinc-300 focus:outline-none focus:ring-2 focus:ring-rose-300"
placeholder="Leave empty to use the default topic message"
value={ message }
onChange={ event => setMessage(event.target.value) }
/>
</div>
{/* Preview */}
{ (selectedSanction || selectedTopicName) &&
<div className="flex flex-col gap-1 bg-zinc-50 border border-zinc-200 rounded p-2">
<div className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">Preview</div>
<div className="flex items-center gap-2 flex-wrap">
{ selectedTopicName &&
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border bg-white border-zinc-200">
{ selectedTopicName }
</span> }
{ selectedSanction &&
<span className={ `inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${ sanctionTone }` }>
{ sanctionIcon } { selectedSanction.name }
</span> }
</div>
</div> }
{/* Action buttons */}
<div className="flex gap-1.5 pt-1 border-t border-zinc-200">
<Button className="grow" disabled={ !canSubmit } gap={ 1 } variant="primary" onClick={ sendDefaultSanction }>
<FaBolt size={ 12 } /> Default Sanction
</Button>
<Button className="grow" disabled={ !canSubmit || selectedAction === -1 } gap={ 1 } variant="success" onClick={ sendSanction }>
<FaGavel size={ 12 } /> Apply Sanction
</Button>
</div>
<Flex gap={ 1 } justifyContent="between">
<Button variant="primary" onClick={ sendDefaultSanction }>Default Sanction</Button>
<Button variant="success" onClick={ sendSanction }>Sanction</Button>
</Flex>
</NitroCardContentView>
</NitroCardView>
);
@@ -1,7 +1,8 @@
import { GetRoomVisitsMessageComposer, RoomVisitsData, RoomVisitsEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FaClock, FaDoorOpen, FaSignInAlt } from 'react-icons/fa';
import { SendMessageComposer, TryVisitRoom } from '../../../../api';
import { Column, DraggableWindowPosition, Grid, InfiniteScroll, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
import { DraggableWindowPosition, InfiniteScroll, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useMessageEvent } from '../../../../hooks';
interface ModToolsUserRoomVisitsViewProps
@@ -31,29 +32,51 @@ export const ModToolsUserRoomVisitsView: FC<ModToolsUserRoomVisitsViewProps> = p
if(!userId) return null;
const rows = roomVisitData?.rooms ?? [];
const isEmpty = rows.length === 0;
return (
<NitroCardView className="nitro-mod-tools-user-visits" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardView className="nitro-mod-tools-user-visits min-w-[400px] max-w-[460px] max-h-[460px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ 'User Visits' } onCloseClick={ onCloseClick } />
<NitroCardContentView className="text-black" gap={ 1 }>
<Column fullHeight gap={ 0 } overflow="hidden">
<Column gap={ 2 }>
<Grid className="text-black font-bold border-bottom pb-1" gap={ 1 }>
<div className="col-span-2">Time</div>
<div className="col-span-7">Room name</div>
<div className="col-span-3">Visit</div>
</Grid>
</Column>
<InfiniteScroll rowRender={ row =>
{
return (
<Grid alignItems="center" className="text-black py-1 border-bottom" fullHeight={ false } gap={ 1 }>
<Text className="col-span-2">{ row.enterHour.toString().padStart(2, '0') }: { row.enterMinute.toString().padStart(2, '0') }</Text>
<Text className="col-span-7">{ row.roomName }</Text>
<Text bold pointer underline className="col-span-3" variant="primary" onClick={ event => TryVisitRoom(row.roomId) }>Visit Room</Text>
</Grid>
);
} } rows={ roomVisitData?.rooms ?? [] } />
</Column>
{/* Header strip */}
<div className="flex items-center gap-2 bg-gradient-to-r from-sky-50 to-transparent rounded p-2 border border-sky-100">
<FaDoorOpen className="text-sky-600 shrink-0" size={ 14 } />
<div className="text-sm font-semibold leading-tight grow">Recent visited rooms</div>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border bg-white border-zinc-200">
{ rows.length } { rows.length === 1 ? 'entry' : 'entries' }
</span>
</div>
{/* Table head */}
<div className="grid grid-cols-[60px_1fr_80px] gap-2 text-[.7rem] uppercase tracking-wide opacity-60 font-semibold border-b border-zinc-200 pb-1 px-1">
<div className="flex items-center gap-1"><FaClock size={ 10 } /> Time</div>
<div>Room name</div>
<div className="text-right">Action</div>
</div>
{/* Rows */}
{ isEmpty
? <div className="flex flex-col items-center justify-center gap-1 py-6 opacity-50 text-sm">
<FaDoorOpen size={ 22 } />
<span>No recent visits</span>
</div>
: <div className="flex flex-col grow min-h-0 overflow-hidden">
<InfiniteScroll rowRender={ row => (
<div className="grid grid-cols-[60px_1fr_80px] gap-2 items-center px-1 py-1.5 text-sm border-b border-zinc-100 even:bg-black/[0.02] hover:bg-sky-50 transition-colors">
<span className="font-mono text-[.75rem] opacity-70 tabular-nums">
{ row.enterHour.toString().padStart(2, '0') }:{ row.enterMinute.toString().padStart(2, '0') }
</span>
<span className="truncate font-medium">{ row.roomName }</span>
<button
className="inline-flex items-center justify-end gap-1 text-sky-700 hover:text-sky-900 hover:underline text-xs"
onClick={ () => TryVisitRoom(row.roomId) }
title="Visit room">
<FaSignInAlt size={ 10 } /> Visit
</button>
</div>
) } rows={ rows } />
</div> }
</NitroCardContentView>
</NitroCardView>
);
@@ -1,7 +1,8 @@
import { ModMessageMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useState } from 'react';
import { FaEnvelope, FaPaperPlane, FaUser } from 'react-icons/fa';
import { ISelectedUser, SendMessageComposer } from '../../../../api';
import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useNotification } from '../../../../hooks';
interface ModToolsUserSendMessageViewProps
@@ -18,27 +19,55 @@ export const ModToolsUserSendMessageView: FC<ModToolsUserSendMessageViewProps> =
if(!user) return null;
const trimmed = message.trim();
const canSend = trimmed.length > 0;
const sendMessage = () =>
{
if(message.trim().length === 0)
if(!canSend)
{
simpleAlert('Please write a message to user.', null, null, null, 'Error', null);
return;
}
SendMessageComposer(new ModMessageMessageComposer(user.userId, message, -999));
onCloseClick();
};
return (
<NitroCardView className="nitro-mod-tools-user-message" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardView className="nitro-mod-tools-user-message min-w-[360px] max-w-[420px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ 'Send Message' } onCloseClick={ () => onCloseClick() } />
<NitroCardContentView className="text-black">
<Text>Message To: { user.username }</Text>
<textarea className="min-h-[calc(1.5em+ .5rem+2px)] px-[.5rem] py-[.25rem] rounded-[.2rem]" value={ message } onChange={ event => setMessage(event.target.value) }></textarea>
<Button fullWidth onClick={ sendMessage }>Send message</Button>
<NitroCardContentView className="text-black" gap={ 2 }>
{/* Recipient header */}
<div className="flex items-center gap-2 bg-gradient-to-r from-sky-50 to-transparent rounded p-2 border border-sky-100">
<FaEnvelope className="text-sky-600 shrink-0" size={ 16 } />
<div className="flex flex-col grow min-w-0">
<div className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">Message to</div>
<div className="flex items-center gap-1.5 font-semibold leading-tight truncate">
<FaUser className="opacity-60" size={ 11 } />
<span className="truncate">{ user.username }</span>
</div>
</div>
</div>
{/* Body */}
<div className="flex flex-col gap-1">
<label className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">Message</label>
<textarea
autoFocus
className="min-h-[100px] px-2 py-1.5 rounded text-sm border border-zinc-300 focus:outline-none focus:ring-2 focus:ring-sky-300"
placeholder="Write something useful — the user will see it as a moderator message."
value={ message }
onChange={ event => setMessage(event.target.value) }
/>
<div className="flex justify-between text-xs opacity-60">
<span>{ canSend ? `${ trimmed.length } chars` : 'Empty' }</span>
</div>
</div>
<Button disabled={ !canSend } fullWidth gap={ 1 } variant="primary" onClick={ sendMessage }>
<FaPaperPlane size={ 12 } /> Send Message
</Button>
</NitroCardContentView>
</NitroCardView>
);