Merge pull request #145 from simoleo89/feat/mod-tools-redesign

feat(mod-tools): complete redesign + i18n + correctness fixes
This commit is contained in:
DuckieTM
2026-05-21 07:39:10 +02:00
committed by GitHub
17 changed files with 1016 additions and 526 deletions
+142
View File
@@ -98,6 +98,148 @@
"catalog.prefix.price.amount": "5 Credits", "catalog.prefix.price.amount": "5 Credits",
"catalog.prefix.purchased": "? Purchased!", "catalog.prefix.purchased": "? Purchased!",
"catalog.prefix.purchase": "Purchase", "catalog.prefix.purchase": "Purchase",
"modtools.userinfo.title": "User Info: %username%",
"modtools.userinfo.userName": "Name",
"modtools.userinfo.cfhCount": "CFHs",
"modtools.userinfo.abusiveCfhCount": "Abusive CFHs",
"modtools.userinfo.cautionCount": "Cautions",
"modtools.userinfo.banCount": "Bans",
"modtools.userinfo.lastSanctionTime": "Last Sanction",
"modtools.userinfo.tradingLockCount": "Trade Locks",
"modtools.userinfo.tradingExpiryDate": "Lock Expires",
"modtools.userinfo.minutesSinceLastLogin": "Last Login",
"modtools.userinfo.lastPurchaseDate": "Last Purchase",
"modtools.userinfo.primaryEmailAddress": "Email",
"modtools.userinfo.identityRelatedBanCount": "Banned Accs",
"modtools.userinfo.registrationAgeInMinutes": "Registered",
"modtools.userinfo.userClassification": "Rank",
"modtools.window.title": "Mod Tools",
"modtools.window.tools.room": "Room Tool",
"modtools.window.tools.chatlog": "Chatlog Tool",
"modtools.window.tools.report": "Report Tool",
"modtools.window.select.user": "Select a user",
"modtools.window.no.room": "Enter a room first",
"modtools.window.user.in_room": "Still in this room",
"modtools.window.user.left_room": "No longer in this room",
"modtools.window.user.clear": "Clear selection",
"modtools.window.tickets.open": "%count% open ticket",
"modtools.window.tickets.open.many": "%count% open tickets",
"modtools.window.section.room": "Room",
"modtools.window.section.user": "User",
"modtools.window.section.reports": "Reports",
"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",
"modtools.userinfo.presence.online": "Online",
"modtools.userinfo.presence.online.title": "Online on the hotel",
"modtools.userinfo.presence.offline": "Offline",
"modtools.userinfo.presence.offline.title": "Offline at panel open",
"modtools.userinfo.section.account": "Account",
"modtools.userinfo.section.activity": "Activity",
"modtools.userinfo.section.sanctions": "Sanctions",
"modtools.userinfo.section.trading": "Trading",
"modtools.userinfo.button.room.chat": "Room Chat",
"modtools.userinfo.button.send.message": "Send Message",
"modtools.userinfo.button.room.visits": "Room Visits",
"modtools.userinfo.button.mod.action": "Mod Action",
"modtools.userinfo.stat.cfh": "CFH",
"modtools.userinfo.stat.cautions": "Cautions",
"modtools.userinfo.stat.bans": "Bans",
"modtools.userinfo.stat.trade.locks": "Trade locks",
"modtools.roominfo.title": "Room Info",
"modtools.roominfo.refresh": "Refresh room info",
"modtools.roominfo.loading": "Loading…",
"modtools.roominfo.owner.here": "Owner here",
"modtools.roominfo.owner.away": "Owner away",
"modtools.roominfo.owner.title.here": "The room owner is currently inside",
"modtools.roominfo.owner.title.away": "The room owner is NOT inside",
"modtools.roominfo.stat.users": "Users",
"modtools.roominfo.stat.owner": "Owner",
"modtools.roominfo.owner.open": "Open %username%'s info",
"modtools.roominfo.button.visit": "Visit Room",
"modtools.roominfo.button.chatlog": "Chatlog",
"modtools.roominfo.moderate.title": "Moderate room",
"modtools.roominfo.moderate.kick": "Kick everyone out",
"modtools.roominfo.moderate.doorbell": "Enable the doorbell",
"modtools.roominfo.moderate.rename": "Change room name",
"modtools.roominfo.moderate.message.placeholder": "Mandatory message to deliver with the action…",
"modtools.roominfo.moderate.send.caution": "Send Caution",
"modtools.roominfo.moderate.send.alert": "Send Alert",
"modtools.user.message.title": "Send Message",
"modtools.user.message.recipient": "Message to",
"modtools.user.message.label": "Message",
"modtools.user.message.placeholder": "Write something useful — the user will see it as a moderator message.",
"modtools.user.message.empty": "Empty",
"modtools.user.message.chars": "%count% chars",
"modtools.user.message.send": "Send Message",
"modtools.user.modaction.title": "Mod Action: %username%",
"modtools.user.modaction.sanctioning": "Sanctioning",
"modtools.user.modaction.step.topic": "1. CFH Topic",
"modtools.user.modaction.step.topic.placeholder": "Select a topic…",
"modtools.user.modaction.step.sanction": "2. Sanction",
"modtools.user.modaction.step.sanction.placeholder": "Select a sanction…",
"modtools.user.modaction.step.message": "3. Custom message",
"modtools.user.modaction.step.message.optional": "(optional — overrides default)",
"modtools.user.modaction.message.placeholder": "Leave empty to use the default topic message",
"modtools.user.modaction.preview": "Preview",
"modtools.user.modaction.button.default": "Default Sanction",
"modtools.user.modaction.button.apply": "Apply Sanction",
"modtools.user.modaction.error.no.topic": "You must select a CFH topic",
"modtools.user.modaction.error.no.action": "You must select a CFH topic and Sanction",
"modtools.user.modaction.error.no.permission": "You do not have permission to do this",
"modtools.user.modaction.error.no.message": "Please write a message to user",
"modtools.user.modaction.error.no.permission.alert": "You have insufficient permissions",
"modtools.user.visits.title": "User Visits",
"modtools.user.visits.recent": "Recent visited rooms",
"modtools.user.visits.entries.one": "%count% entry",
"modtools.user.visits.entries.many": "%count% entries",
"modtools.user.visits.empty": "No recent visits",
"modtools.user.visits.time": "Time",
"modtools.user.visits.room": "Room name",
"modtools.user.visits.action": "Action",
"modtools.user.visits.visit": "Visit",
"modtools.user.visits.visit.title": "Visit room",
"modtools.user.chatlog.title": "User Chatlog",
"modtools.user.chatlog.title.with": "User Chatlog: %username%",
"modtools.user.chatlog.loading": "Loading chatlog…",
"modtools.room.chatlog.title": "Room Chatlog",
"modtools.chatlog.column.time": "Time",
"modtools.chatlog.column.user": "User",
"modtools.chatlog.column.message": "Message",
"modtools.chatlog.empty": "No messages",
"modtools.chatlog.visit": "Visit",
"modtools.chatlog.tools": "Tools",
"modtools.tickets.title": "Tickets",
"modtools.tickets.tab.open": "Open",
"modtools.tickets.tab.mine": "Mine",
"modtools.tickets.tab.picked": "All picked",
"modtools.tickets.column.type": "Type",
"modtools.tickets.column.reported": "Reported",
"modtools.tickets.column.opened": "Opened",
"modtools.tickets.column.picker": "Picker",
"modtools.tickets.empty.open": "No open issues",
"modtools.tickets.empty.mine": "No issues picked by you",
"modtools.tickets.empty.picked": "No picked issues",
"modtools.tickets.action.pick": "Pick",
"modtools.tickets.action.handle": "Handle",
"modtools.tickets.action.release": "Release",
"modtools.tickets.issue.title": "Resolving issue #%issueId%",
"modtools.tickets.issue.label": "Issue #%issueId%",
"modtools.tickets.issue.details": "Details",
"modtools.tickets.issue.field.source": "Source",
"modtools.tickets.issue.field.category": "Category",
"modtools.tickets.issue.field.description": "Description",
"modtools.tickets.issue.field.caller": "Caller",
"modtools.tickets.issue.field.reported": "Reported",
"modtools.tickets.issue.chatlog.view": "View chatlog",
"modtools.tickets.issue.chatlog.close": "Close chatlog",
"modtools.tickets.issue.resolve.heading": "Resolve as",
"modtools.tickets.issue.resolve.resolved": "Resolved",
"modtools.tickets.issue.resolve.useless": "Useless",
"modtools.tickets.issue.resolve.abusive": "Abusive",
"modtools.tickets.issue.release": "Release back to queue",
"modtools.tickets.cfh.chatlog.title": "Issue #%issueId% Chatlog",
"groupforum.list.tab.most_active": "Most active threads", "groupforum.list.tab.most_active": "Most active threads",
"groupforum.list.tab.my_forums": "My group forums", "groupforum.list.tab.my_forums": "My group forums",
"groupforum.list.no_forums": "There are no forums", "groupforum.list.no_forums": "There are no forums",
+52 -33
View File
@@ -1,6 +1,6 @@
import { AddLinkEventTracker, CreateLinkEvent, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomId, RoomObjectCategory, RoomObjectType } from '@nitrots/nitro-renderer'; import { AddLinkEventTracker, CreateLinkEvent, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomId, RoomObjectCategory, RoomObjectType } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useRef, useState } from 'react'; import { FC, useEffect, useMemo, useRef, useState } from 'react';
import { FaTimes } from 'react-icons/fa'; import { FaTimes, FaUserSlash } from 'react-icons/fa';
import { GetRoomSession, ISelectedUser, LocalizeText } from '../../api'; import { GetRoomSession, ISelectedUser, LocalizeText } from '../../api';
import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common'; import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
import { useModTools, useNitroEvent, useObjectSelectedEvent, useRoomUserListSnapshot } from '../../hooks'; import { useModTools, useNitroEvent, useObjectSelectedEvent, useRoomUserListSnapshot } from '../../hooks';
@@ -134,63 +134,82 @@ export const ModToolsView: FC<{}> = props =>
return () => RemoveLinkEventTracker(linkTracker); return () => RemoveLinkEventTracker(linkTracker);
}, [ openRoomInfo, closeRoomInfo, toggleRoomInfo, openRoomChatlog, closeRoomChatlog, toggleRoomChatlog, openUserInfo, closeUserInfo, toggleUserInfo, openUserChatlog, closeUserChatlog, toggleUserChatlog ]); }, [ openRoomInfo, closeRoomInfo, toggleRoomInfo, openRoomChatlog, closeRoomChatlog, toggleRoomChatlog, openUserInfo, closeUserInfo, toggleUserInfo, openUserChatlog, closeUserChatlog, toggleUserChatlog ]);
const isRoomInfoOpen = currentRoomId > 0 && openRooms.includes(currentRoomId); const isInRoom = currentRoomId > 0;
const isRoomChatlogOpen = currentRoomId > 0 && openRoomChatlogs.includes(currentRoomId); const isRoomInfoOpen = isInRoom && openRooms.includes(currentRoomId);
const isRoomChatlogOpen = isInRoom && openRoomChatlogs.includes(currentRoomId);
const isUserInfoOpen = selectedUser && openUserInfos.includes(selectedUser.userId); 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 ( return (
<> <>
{ isVisible && { 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={ 'Mod Tools' } onCloseClick={ event => setIsVisible(false) } /> <NitroCardHeaderView headerText={ LocalizeText('modtools.window.title') } onCloseClick={ event => setIsVisible(false) } />
<NitroCardContentView className="text-black" gap={ 2 }> <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 }`) }> {/* 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" /> <div className="nitro-icon icon-small-room shrink-0" />
<span className="grow text-start">Room Tool</span> <span className="grow text-start">{ LocalizeText('modtools.window.tools.room') }</span>
</Button> </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 }`) }> <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" /> <div className="nitro-icon icon-chat-history shrink-0" />
<span className="grow text-start">Chatlog Tool</span> <span className="grow text-start">{ LocalizeText('modtools.window.tools.chatlog') }</span>
</Button> </Button>
<Button active={ !!isUserInfoOpen } disabled={ !selectedUser } gap={ 2 } justifyContent="start" onClick={ () => selectedUser && CreateLinkEvent(`mod-tools/toggle-user-info/${ selectedUser.userId }`) }> </div>
<div className="nitro-icon icon-user shrink-0" />
{/* 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 { selectedUser
? ( ? (
<> <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' }` }>
<span className="truncate grow text-start">{ selectedUser.username }</span> <div className="flex items-center gap-1.5">
<span <span className={ `inline-block w-2 h-2 rounded-full shrink-0 ${ isSelectedUserPresent ? 'bg-emerald-500' : 'bg-zinc-400' }` }
aria-label={ isSelectedUserPresent ? 'In room' : 'Left room' } title={ isSelectedUserPresent ? LocalizeText('modtools.window.user.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' }` } aria-label={ isSelectedUserPresent ? LocalizeText('modtools.userinfo.presence.in_room') : LocalizeText('modtools.window.user.left_room') } />
title={ isSelectedUserPresent ? 'Still in this room' : 'No longer in this room' } <span className="truncate grow text-start text-sm font-semibold leading-tight">{ selectedUser.username }</span>
/> <button
<span 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"
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 => onClick={ event =>
{ {
event.stopPropagation(); event.stopPropagation();
setSelectedUser(null); setSelectedUser(null);
} } } }
role="button" title={ LocalizeText('modtools.window.user.clear') }>
tabIndex={ 0 } <FaTimes size={ 10 } />
title="Clear selection"> </button>
<FaTimes /> </div>
</span> <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>
: <span className="opacity-50 italic grow text-start">Select a user</span>
}
</Button> </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>
)
}
</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) }> <Button active={ isTicketsVisible } gap={ 2 } justifyContent="start" onClick={ () => setIsTicketsVisible(prevValue => !prevValue) }>
<div className="nitro-icon icon-tickets shrink-0" /> <div className="nitro-icon icon-tickets shrink-0" />
<span className="grow text-start">Report Tool</span> <span className="grow text-start">{ LocalizeText('modtools.window.tools.report') }</span>
{ (openTicketsCount > 0) && { (openTicketsCount > 0) &&
<span <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" 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={ `${ openTicketsCount } open ticket${ openTicketsCount === 1 ? '' : 's' }` }> title={ LocalizeText(openTicketsCount === 1 ? 'modtools.window.tickets.open' : 'modtools.window.tickets.open.many', [ 'count' ], [ openTicketsCount.toString() ]) }>
{ openTicketsCount > 99 ? '99+' : openTicketsCount } { openTicketsCount > 99 ? '99+' : openTicketsCount }
</span> } </span> }
</Button> </Button>
</div>
</NitroCardContentView> </NitroCardContentView>
</NitroCardView> } </NitroCardView> }
{ (openRooms.length > 0) && openRooms.map(roomId => <ModToolsRoomView key={ roomId } roomId={ roomId } onCloseClick={ () => CreateLinkEvent(`mod-tools/close-room-info/${ roomId }`) } />) } { (openRooms.length > 0) && openRooms.map(roomId => <ModToolsRoomView key={ roomId } roomId={ roomId } onCloseClick={ () => CreateLinkEvent(`mod-tools/close-room-info/${ roomId }`) } />) }
@@ -1,7 +1,8 @@
import { ChatRecordData, CreateLinkEvent } from '@nitrots/nitro-renderer'; import { ChatRecordData, CreateLinkEvent } from '@nitrots/nitro-renderer';
import { FC, useMemo } from 'react'; import { FC, useMemo } from 'react';
import { TryVisitRoom } from '../../../../api'; import { FaCommentDots, FaDoorOpen, FaSignInAlt, FaTools } from 'react-icons/fa';
import { Button, Column, Flex, Grid, InfiniteScroll, Text } from '../../../../common'; import { LocalizeText, TryVisitRoom } from '../../../../api';
import { Column, InfiniteScroll } from '../../../../common';
import { useModTools } from '../../../../hooks'; import { useModTools } from '../../../../hooks';
import { ChatlogRecord } from './ChatlogRecord'; import { ChatlogRecord } from './ChatlogRecord';
@@ -43,46 +44,61 @@ export const ChatlogView: FC<ChatlogViewProps> = props =>
return results; return results;
}, [ records ]); }, [ records ]);
const RoomInfo = (props: { roomId: number, roomName: string }) => const totalMessages = useMemo(
{ () => allRecords.filter(r => !r.isRoomInfo).length,
return ( [ allRecords ]
<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 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 } /> { LocalizeText('modtools.chatlog.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 } /> { LocalizeText('modtools.chatlog.tools') }
</button>
</div>
</div>
);
const isEmpty = !records || records.length === 0 || totalMessages === 0;
return ( return (
<>
<Column fit gap={ 0 } overflow="hidden"> <Column fit gap={ 0 } overflow="hidden">
<Column gap={ 2 }> {/* Column headers */}
<Grid className="text-black font-bold border-bottom pb-1 text-[11px] uppercase opacity-60 tracking-wider" gap={ 1 }> <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 className="col-span-2">Time</div> <div>{ LocalizeText('modtools.chatlog.column.time') }</div>
<div className="col-span-3">User</div> <div>{ LocalizeText('modtools.chatlog.column.user') }</div>
<div className="col-span-7">Message</div> <div>{ LocalizeText('modtools.chatlog.column.message') }</div>
</Grid> </div>
</Column> { isEmpty
{ (records && (records.length > 0)) && ? <div className="flex flex-col items-center justify-center gap-1 py-6 opacity-50 text-sm">
<InfiniteScroll rowRender={ (row: ChatlogRecord) => <FaCommentDots size={ 22 } />
<span>{ LocalizeText('modtools.chatlog.empty') }</span>
</div>
: <InfiniteScroll rowRender={ (row: ChatlogRecord) =>
{ {
if(row.isRoomInfo) return <RoomInfo roomId={ row.roomId } roomName={ row.roomName } />;
return ( 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' : '' }` }>
{ row.isRoomInfo && <span className="font-mono text-[.7rem] opacity-70 tabular-nums whitespace-nowrap">{ row.timestamp }</span>
<RoomInfo roomId={ row.roomId } roomName={ row.roomName } /> } <button
{ !row.isRoomInfo && className="text-left font-semibold text-sky-700 hover:text-sky-900 hover:underline truncate"
<Grid alignItems="center" className="log-entry py-1.5 border-bottom even:bg-black/[0.03]" fullHeight={ false } gap={ 1 }> onClick={ () => CreateLinkEvent(`mod-tools/open-user-info/${ row.habboId }`) }>
<Text className="col-span-2 opacity-60 text-[11px]">{ row.timestamp }</Text> { row.username }
<Text bold pointer underline className="col-span-3" onClick={ event => CreateLinkEvent(`mod-tools/open-user-info/${ row.habboId }`) }>{ row.username }</Text> </button>
<Text textBreak wrap className="col-span-7">{ row.message }</Text> <span className="break-words">{ row.message }</span>
</Grid> } </div>
</>
); );
} } rows={ allRecords } /> } } } rows={ allRecords } /> }
</Column> </Column>
</>
); );
}; };
@@ -1,7 +1,9 @@
import { ChatRecordData, GetRoomChatlogMessageComposer, RoomChatlogEvent } from '@nitrots/nitro-renderer'; import { ChatRecordData, GetRoomChatlogMessageComposer, RoomChatlogEvent } from '@nitrots/nitro-renderer';
import { FC } from 'react'; import { FC, useEffect, useState } from 'react';
import { useNitroQuery } from '../../../../api/nitro-query'; import { FaSpinner } from 'react-icons/fa';
import { LocalizeText, SendMessageComposer } from '../../../../api';
import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useMessageEvent } from '../../../../hooks';
import { ChatlogView } from '../chatlog/ChatlogView'; import { ChatlogView } from '../chatlog/ChatlogView';
interface ModToolsChatlogViewProps interface ModToolsChatlogViewProps
@@ -13,24 +15,32 @@ interface ModToolsChatlogViewProps
export const ModToolsChatlogView: FC<ModToolsChatlogViewProps> = props => export const ModToolsChatlogView: FC<ModToolsChatlogViewProps> = props =>
{ {
const { roomId = null, onCloseClick = null } = props; const { roomId = null, onCloseClick = null } = props;
const [ roomChatlog, setRoomChatlog ] = useState<ChatRecordData>(null);
const { data: roomChatlog } = useNitroQuery<RoomChatlogEvent, ChatRecordData>({ useMessageEvent<RoomChatlogEvent>(RoomChatlogEvent, event =>
key: [ 'nitro', 'mod-tools', 'room-chatlog', roomId ], {
request: () => new GetRoomChatlogMessageComposer(roomId), const parser = event.getParser();
parser: RoomChatlogEvent,
accept: e => e.getParser()?.data.roomId === roomId, if(!parser || parser.data.roomId !== roomId) return;
select: e => e.getParser().data,
enabled: roomId !== null setRoomChatlog(parser.data);
}); });
if(!roomChatlog) return null; useEffect(() =>
{
SendMessageComposer(new GetRoomChatlogMessageComposer(roomId));
}, [ roomId ]);
return ( 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 } /> <NitroCardHeaderView headerText={ LocalizeText('modtools.room.chatlog.title') } onCloseClick={ onCloseClick } />
<NitroCardContentView className="text-black" overflow="auto"> <NitroCardContentView className="text-black" gap={ 1 } overflow="auto">
{ roomChatlog && { roomChatlog
<ChatlogView records={ [ roomChatlog ] } /> } ? <ChatlogView records={ [ roomChatlog ] } />
: <div className="flex flex-col items-center justify-center gap-2 py-8 opacity-50 text-sm">
<FaSpinner className="animate-spin" size={ 22 } />
<span>{ LocalizeText('modtools.user.chatlog.loading') }</span>
</div> }
</NitroCardContentView> </NitroCardContentView>
</NitroCardView> </NitroCardView>
); );
@@ -1,7 +1,8 @@
import { CreateLinkEvent, GetModeratorRoomInfoMessageComposer, ModerateRoomMessageComposer, ModeratorActionMessageComposer, ModeratorRoomInfoEvent } from '@nitrots/nitro-renderer'; import { CreateLinkEvent, GetModeratorRoomInfoMessageComposer, ModerateRoomMessageComposer, ModeratorActionMessageComposer, ModeratorRoomInfoEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react'; import { FC, useEffect, useState } from 'react';
import { SendMessageComposer, TryVisitRoom } from '../../../../api'; import { FaBullhorn, FaCommentDots, FaDoorOpen, FaExclamationTriangle, FaSignInAlt, FaSync, FaUserShield, FaUsers } from 'react-icons/fa';
import { Button, Column, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common'; import { LocalizeText, SendMessageComposer, TryVisitRoom } from '../../../../api';
import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
import { useMessageEvent } from '../../../../hooks'; import { useMessageEvent } from '../../../../hooks';
interface ModToolsRoomViewProps interface ModToolsRoomViewProps
@@ -25,7 +26,9 @@ export const ModToolsRoomView: FC<ModToolsRoomViewProps> = props =>
const [ changeRoomName, setChangeRoomName ] = useState(false); const [ changeRoomName, setChangeRoomName ] = useState(false);
const [ message, setMessage ] = useState(''); const [ message, setMessage ] = useState('');
const handleClick = (action: string, value?: string) => const refresh = () => SendMessageComposer(new GetModeratorRoomInfoMessageComposer(roomId));
const handleClick = (action: string) =>
{ {
if(!action) return; if(!action) return;
@@ -66,55 +69,102 @@ export const ModToolsRoomView: FC<ModToolsRoomViewProps> = props =>
SendMessageComposer(new GetModeratorRoomInfoMessageComposer(roomId)); SendMessageComposer(new GetModeratorRoomInfoMessageComposer(roomId));
setInfoRequested(true); 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 ( return (
<NitroCardView className="nitro-mod-tools-room min-w-[280px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }> <NitroCardView className="nitro-mod-tools-room min-w-[400px] max-w-[460px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ 'Room Info' } onCloseClick={ event => onCloseClick() } /> <NitroCardHeaderView headerText={ LocalizeText('modtools.roominfo.title') } onCloseClick={ () => onCloseClick() } />
<NitroCardContentView className="text-black" gap={ 2 }> <NitroCardContentView className="text-black" gap={ 2 }>
{ name && {/* Identity header */}
<div className="bg-muted rounded px-2 py-1.5 text-center"> <div className="flex items-center gap-2 bg-gradient-to-r from-sky-50 to-transparent rounded p-2 border border-sky-100">
<Text bold truncate>{ name }</Text> <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 || LocalizeText('modtools.roominfo.loading') }</Text>
<Text className="opacity-60 text-xs truncate">#{ roomId }</Text>
</div> </div>
} <span
<div className="flex gap-2"> className={ `inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium border ${ ownerPillClass }` }
<Column grow gap={ 1 }> title={ ownerInRoom ? LocalizeText('modtools.roominfo.owner.title.here') : LocalizeText('modtools.roominfo.owner.title.away') }>
<div className="flex items-center gap-1"> <span className={ `inline-block w-2 h-2 rounded-full ${ ownerDotClass }` } />
<Text bold className="opacity-60 shrink-0">Owner:</Text> { ownerInRoom ? LocalizeText('modtools.roominfo.owner.here') : LocalizeText('modtools.roominfo.owner.away') }
<Text bold pointer truncate underline onClick={ () => CreateLinkEvent(`mod-tools/open-user-info/${ ownerId }`) }>{ ownerName }</Text> </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={ LocalizeText('modtools.roominfo.refresh') }>
<FaSync size={ 12 } />
</button>
</div> </div>
<div className="flex items-center gap-1">
<Text bold className="opacity-60 shrink-0">Users in room:</Text> {/* Stat strip */}
<Text>{ usersInRoom }</Text> <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>{ LocalizeText('modtools.roominfo.stat.users') }</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="text-lg font-semibold tabular-nums leading-tight">{ usersInRoom }</div>
<Text bold className="opacity-60 shrink-0">Owner here:</Text>
<Text className={ ownerInRoom ? 'text-green-700' : 'text-red-700' }>{ ownerInRoom ? 'Yes' : 'No' }</Text>
</div> </div>
</Column> <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 flex-col gap-1 shrink-0"> <div className="flex items-center gap-1.5 text-[.7rem] uppercase tracking-wide opacity-70">
<Button onClick={ event => TryVisitRoom(roomId) }>Visit Room</Button> <FaUserShield size={ 10 } /><span>{ LocalizeText('modtools.roominfo.stat.owner') }</span>
<Button onClick={ event => CreateLinkEvent(`mod-tools/open-room-chatlog/${ roomId }`) }>Chatlog</Button> </div>
<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 ? LocalizeText('modtools.roominfo.owner.open', [ 'username' ], [ ownerName ]) : '' }>
{ ownerName || '-' }
</div> </div>
</div> </div>
<Column className="bg-muted rounded p-2" gap={ 1 }> </div>
<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 } /> { LocalizeText('modtools.roominfo.button.visit') }
</Button>
<Button gap={ 1 } variant="secondary" onClick={ () => CreateLinkEvent(`mod-tools/open-room-chatlog/${ roomId }`) }>
<FaCommentDots size={ 12 } /> { LocalizeText('modtools.roominfo.button.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 } /> { LocalizeText('modtools.roominfo.moderate.title') }
</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) } /> <input checked={ kickUsers } className="form-check-input" type="checkbox" onChange={ event => setKickUsers(event.target.checked) } />
<Text small>Kick everyone out</Text> <span>{ LocalizeText('modtools.roominfo.moderate.kick') }</span>
</div> </label>
<div className="flex items-center gap-2"> <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) } /> <input checked={ lockRoom } className="form-check-input" type="checkbox" onChange={ event => setLockRoom(event.target.checked) } />
<Text small>Enable the doorbell</Text> <span>{ LocalizeText('modtools.roominfo.moderate.doorbell') }</span>
</div> </label>
<div className="flex items-center gap-2"> <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) } /> <input checked={ changeRoomName } className="form-check-input" type="checkbox" onChange={ event => setChangeRoomName(event.target.checked) } />
<Text small>Change room name</Text> <span>{ LocalizeText('modtools.roominfo.moderate.rename') }</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={ LocalizeText('modtools.roominfo.moderate.message.placeholder') }
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 } /> { LocalizeText('modtools.roominfo.moderate.send.caution') }
</Button>
<Button className="grow" disabled={ !hasMessage || !isLoaded } gap={ 1 } variant="warning" onClick={ () => handleClick('alert_only') }>
<FaExclamationTriangle size={ 12 } /> { LocalizeText('modtools.roominfo.moderate.send.alert') }
</Button>
</div> </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> </div>
</NitroCardContentView> </NitroCardContentView>
</NitroCardView> </NitroCardView>
@@ -1,7 +1,9 @@
import { CfhChatlogData, CfhChatlogEvent, GetCfhChatlogMessageComposer } from '@nitrots/nitro-renderer'; import { CfhChatlogData, CfhChatlogEvent, GetCfhChatlogMessageComposer } from '@nitrots/nitro-renderer';
import { FC } from 'react'; import { FC, useEffect, useState } from 'react';
import { useNitroQuery } from '../../../../api/nitro-query'; import { FaSpinner } from 'react-icons/fa';
import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; import { LocalizeText, SendMessageComposer } from '../../../../api';
import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useMessageEvent } from '../../../../hooks';
import { ChatlogView } from '../chatlog/ChatlogView'; import { ChatlogView } from '../chatlog/ChatlogView';
interface CfhChatlogViewProps interface CfhChatlogViewProps
@@ -13,21 +15,32 @@ interface CfhChatlogViewProps
export const CfhChatlogView: FC<CfhChatlogViewProps> = props => export const CfhChatlogView: FC<CfhChatlogViewProps> = props =>
{ {
const { onCloseClick = null, issueId = null } = props; const { onCloseClick = null, issueId = null } = props;
const [ chatlogData, setChatlogData ] = useState<CfhChatlogData>(null);
const { data: chatlogData } = useNitroQuery<CfhChatlogEvent, CfhChatlogData>({ useMessageEvent<CfhChatlogEvent>(CfhChatlogEvent, event =>
key: [ 'nitro', 'mod-tools', 'cfh-chatlog', issueId ], {
request: () => new GetCfhChatlogMessageComposer(issueId), const parser = event.getParser();
parser: CfhChatlogEvent,
accept: e => e.getParser()?.data.issueId === issueId, if(!parser || parser.data.issueId !== issueId) return;
select: e => e.getParser().data,
enabled: issueId !== null setChatlogData(parser.data);
}); });
useEffect(() =>
{
SendMessageComposer(new GetCfhChatlogMessageComposer(issueId));
}, [ issueId ]);
return ( return (
<NitroCardView className="nitro-mod-tools-chatlog" theme="primary-slim"> <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 Chatlog' } onCloseClick={ onCloseClick } /> <NitroCardHeaderView headerText={ LocalizeText('modtools.tickets.cfh.chatlog.title', [ 'issueId' ], [ issueId.toString() ]) } onCloseClick={ onCloseClick } />
<NitroCardContentView className="text-black"> <NitroCardContentView className="text-black" gap={ 1 }>
{ chatlogData && <ChatlogView records={ [ chatlogData.chatRecord ] } /> } { 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>{ LocalizeText('modtools.user.chatlog.loading') }</span>
</div> }
</NitroCardContentView> </NitroCardContentView>
</NitroCardView> </NitroCardView>
); );
@@ -1,7 +1,8 @@
import { CloseIssuesMessageComposer, ReleaseIssuesMessageComposer } from '@nitrots/nitro-renderer'; import { CloseIssuesMessageComposer, ReleaseIssuesMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useState } from 'react'; import { FC, useState } from 'react';
import { FaBan, FaCheck, FaCommentDots, FaExternalLinkAlt, FaSignOutAlt, FaTrashAlt } from 'react-icons/fa';
import { GetIssueCategoryName, LocalizeText, SendMessageComposer } from '../../../../api'; 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 { useModTools } from '../../../../hooks';
import { CfhChatlogView } from './CfhChatlogView'; import { CfhChatlogView } from './CfhChatlogView';
@@ -11,76 +12,102 @@ interface IssueInfoViewProps
onIssueInfoClosed(issueId: number): void; 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">-</span> }</dd>
</>
);
export const ModToolsIssueInfoView: FC<IssueInfoViewProps> = props => export const ModToolsIssueInfoView: FC<IssueInfoViewProps> = props =>
{ {
const { issueId = null, onIssueInfoClosed = null } = props; const { issueId = null, onIssueInfoClosed = null } = props;
const [ cfhChatlogOpen, setcfhChatlogOpen ] = useState(false); const [ cfhChatlogOpen, setCfhChatlogOpen ] = useState(false);
const { tickets = [], openUserInfo = null } = useModTools(); const { tickets = [], openUserInfo = null } = useModTools();
const ticket = tickets.find(issue => (issue.issueId === issueId)); const ticket = tickets.find(issue => (issue.issueId === issueId));
const releaseIssue = (issueId: number) => const releaseIssue = () =>
{ {
SendMessageComposer(new ReleaseIssuesMessageComposer([ issueId ])); SendMessageComposer(new ReleaseIssuesMessageComposer([ issueId ]));
onIssueInfoClosed(issueId); onIssueInfoClosed(issueId);
}; };
const closeIssue = (resolutionType: number) => const closeIssue = (resolutionType: number) =>
{ {
SendMessageComposer(new CloseIssuesMessageComposer([ issueId ], resolutionType)); SendMessageComposer(new CloseIssuesMessageComposer([ issueId ], resolutionType));
onIssueInfoClosed(issueId); onIssueInfoClosed(issueId);
}; };
if(!ticket) return null;
return ( return (
<> <>
<NitroCardView className="nitro-mod-tools-handle-issue" theme="primary-slim"> <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) } /> <NitroCardHeaderView headerText={ LocalizeText('modtools.tickets.issue.title', [ 'issueId' ], [ issueId.toString() ]) } onCloseClick={ () => onIssueInfoClosed(issueId) } />
<NitroCardContentView className="text-black"> <NitroCardContentView className="text-black" gap={ 2 }>
<Text fontSize={ 4 }>Issue Information</Text> {/* Issue header */}
<Grid overflow="auto"> <div className="flex items-center gap-2 bg-gradient-to-r from-amber-50 to-transparent rounded p-2 border border-amber-100">
<Column size={ 8 }> <FaCommentDots className="text-amber-600 shrink-0" size={ 16 } />
<table className="table table-striped table-sm table-text-small text-black m-0"> <div className="flex flex-col grow min-w-0">
<tbody> <div className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">{ LocalizeText('modtools.tickets.issue.label', [ 'issueId' ], [ issueId.toString() ]) }</div>
<tr> <div className="font-semibold leading-tight truncate">{ GetIssueCategoryName(ticket.categoryId) }</div>
<th>Source</th> </div>
<td>{ GetIssueCategoryName(ticket.categoryId) }</td> <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">
</tr> { LocalizeText('help.cfh.topic.' + ticket.reportedCategoryId) }
<tr> </span>
<th>Category</th> </div>
<td className="text-break">{ LocalizeText('help.cfh.topic.' + ticket.reportedCategoryId) }</td>
</tr> {/* Details */}
<tr> <div className="flex flex-col gap-1">
<th>Description</th> <div className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold border-b border-zinc-200 pb-1 mb-0.5">{ LocalizeText('modtools.tickets.issue.details') }</div>
<td className="text-break">{ ticket.message }</td> <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-[.8rem] m-0">
</tr> <Field label={ LocalizeText('modtools.tickets.issue.field.source') }>{ GetIssueCategoryName(ticket.categoryId) }</Field>
<tr> <Field label={ LocalizeText('modtools.tickets.issue.field.category') }>{ LocalizeText('help.cfh.topic.' + ticket.reportedCategoryId) }</Field>
<th>Caller</th> <Field label={ LocalizeText('modtools.tickets.issue.field.description') }>{ ticket.message }</Field>
<td> <Field label={ LocalizeText('modtools.tickets.issue.field.caller') }>
<Text bold pointer underline onClick={ event => openUserInfo(ticket.reporterUserId) }>{ ticket.reporterUserName }</Text> <button
</td> className="font-semibold text-sky-700 hover:text-sky-900 hover:underline inline-flex items-center gap-1"
</tr> onClick={ () => openUserInfo(ticket.reporterUserId) }>
<tr> { ticket.reporterUserName } <FaExternalLinkAlt size={ 8 } className="opacity-60" />
<th>Reported User</th> </button>
<td> </Field>
<Text bold pointer underline onClick={ event => openUserInfo(ticket.reportedUserId) }>{ ticket.reportedUserName }</Text> <Field label={ LocalizeText('modtools.tickets.issue.field.reported') }>
</td> <button
</tr> className="font-semibold text-sky-700 hover:text-sky-900 hover:underline inline-flex items-center gap-1"
</tbody> onClick={ () => openUserInfo(ticket.reportedUserId) }>
</table> { ticket.reportedUserName } <FaExternalLinkAlt size={ 8 } className="opacity-60" />
</Column> </button>
<Column gap={ 1 } size={ 4 }> </Field>
<Button variant="secondary" onClick={ () => setcfhChatlogOpen(!cfhChatlogOpen) }>Chatlog</Button> </dl>
<Button onClick={ event => closeIssue(CloseIssuesMessageComposer.RESOLUTION_USELESS) }>Close as useless</Button> </div>
<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> {/* Tools */}
<Button variant="secondary" onClick={ event => releaseIssue(issueId) } >Release</Button> <Button gap={ 1 } variant="secondary" onClick={ () => setCfhChatlogOpen(prev => !prev) }>
</Column> <FaCommentDots size={ 12 } /> { cfhChatlogOpen ? LocalizeText('modtools.tickets.issue.chatlog.close') : LocalizeText('modtools.tickets.issue.chatlog.view') }
</Grid> </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">{ LocalizeText('modtools.tickets.issue.resolve.heading') }</div>
<div className="grid grid-cols-3 gap-1.5">
<Button gap={ 1 } variant="success" onClick={ () => closeIssue(CloseIssuesMessageComposer.RESOLUTION_RESOLVED) }>
<FaCheck size={ 11 } /> { LocalizeText('modtools.tickets.issue.resolve.resolved') }
</Button>
<Button gap={ 1 } variant="dark" onClick={ () => closeIssue(CloseIssuesMessageComposer.RESOLUTION_USELESS) }>
<FaTrashAlt size={ 11 } /> { LocalizeText('modtools.tickets.issue.resolve.useless') }
</Button>
<Button gap={ 1 } variant="danger" onClick={ () => closeIssue(CloseIssuesMessageComposer.RESOLUTION_ABUSIVE) }>
<FaBan size={ 11 } /> { LocalizeText('modtools.tickets.issue.resolve.abusive') }
</Button>
</div>
<Button gap={ 1 } variant="secondary" onClick={ releaseIssue }>
<FaSignOutAlt size={ 12 } /> { LocalizeText('modtools.tickets.issue.release') }
</Button>
</div>
</NitroCardContentView> </NitroCardContentView>
</NitroCardView> </NitroCardView>
{ cfhChatlogOpen && { 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 { IssueMessageData, ReleaseIssuesMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useRef } from 'react'; import { FC, useRef } from 'react';
import { SendMessageComposer } from '../../../../api'; import { FaClock, FaInbox, FaSignOutAlt, FaTools, FaUser } from 'react-icons/fa';
import { Button, Column, Grid } from '../../../../common'; import { GetIssueCategoryName, LocalizeText, SendMessageComposer } from '../../../../api';
interface ModToolsMyIssuesTabViewProps interface ModToolsMyIssuesTabViewProps
{ {
@@ -24,35 +24,45 @@ export const ModToolsMyIssuesTabView: FC<ModToolsMyIssuesTabViewProps> = props =
setTimeout(() => pendingReleasesRef.current.delete(issueId), 2000); setTimeout(() => pendingReleasesRef.current.delete(issueId), 2000);
}; };
const isEmpty = !myIssues || myIssues.length === 0;
return ( return (
<Column gap={ 0 } overflow="hidden"> <div className="flex flex-col gap-1 overflow-hidden">
<Column gap={ 2 }> <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">
<Grid className="text-black font-bold border-bottom pb-1" gap={ 1 }> <div>{ LocalizeText('modtools.tickets.column.type') }</div>
<div className="col-span-2">Type</div> <div className="flex items-center gap-1"><FaUser size={ 10 } /> { LocalizeText('modtools.tickets.column.reported') }</div>
<div className="col-span-3">Room/Player</div> <div className="flex items-center gap-1"><FaClock size={ 10 } /> { LocalizeText('modtools.tickets.column.opened') }</div>
<div className="col-span-3">Opened</div> <div></div>
<div className="col-span-2"></div> <div></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>
<div className="col-span-2"> { isEmpty
<Button variant="danger" onClick={ () => releaseIssue(issue.issueId) }>Release</Button> ? <div className="flex flex-col items-center justify-center gap-1 py-8 opacity-50 text-sm">
<FaInbox size={ 22 } />
<span>{ LocalizeText('modtools.tickets.empty.mine') }</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 } /> { LocalizeText('modtools.tickets.action.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 } /> { LocalizeText('modtools.tickets.action.release') }
</button>
</div>
)) }
</div> }
</div> </div>
</Grid>
);
}) }
</Column>
</Column>
); );
}; };
@@ -1,7 +1,7 @@
import { IssueMessageData, PickIssuesMessageComposer } from '@nitrots/nitro-renderer'; import { IssueMessageData, PickIssuesMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useRef } from 'react'; import { FC, useRef } from 'react';
import { SendMessageComposer } from '../../../../api'; import { FaClock, FaHandPointer, FaInbox, FaUser } from 'react-icons/fa';
import { Button, Column, Grid } from '../../../../common'; import { GetIssueCategoryName, LocalizeText, SendMessageComposer } from '../../../../api';
interface ModToolsOpenIssuesTabViewProps interface ModToolsOpenIssuesTabViewProps
{ {
@@ -23,31 +23,39 @@ export const ModToolsOpenIssuesTabView: FC<ModToolsOpenIssuesTabViewProps> = pro
setTimeout(() => pendingPicksRef.current.delete(issueId), 2000); setTimeout(() => pendingPicksRef.current.delete(issueId), 2000);
}; };
const isEmpty = !openIssues || openIssues.length === 0;
return ( return (
<Column gap={ 0 } overflow="hidden"> <div className="flex flex-col gap-1 overflow-hidden">
<Column gap={ 2 }> <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">
<Grid className="text-black font-bold border-bottom pb-1" gap={ 1 }> <div>{ LocalizeText('modtools.tickets.column.type') }</div>
<div className="col-span-2">Type</div> <div className="flex items-center gap-1"><FaUser size={ 10 } /> { LocalizeText('modtools.tickets.column.reported') }</div>
<div className="col-span-3">Room/Player</div> <div className="flex items-center gap-1"><FaClock size={ 10 } /> { LocalizeText('modtools.tickets.column.opened') }</div>
<div className="col-span-4">Opened</div> <div></div>
<div className="col-span-3"></div> </div>
</Grid> { isEmpty
</Column> ? <div className="flex flex-col items-center justify-center gap-1 py-8 opacity-50 text-sm">
<Column className="striped-children" gap={ 0 } overflow="auto"> <FaInbox size={ 22 } />
{ openIssues && (openIssues.length > 0) && openIssues.map(issue => <span>{ LocalizeText('modtools.tickets.empty.open') }</span>
{ </div>
return ( : <div className="flex flex-col overflow-auto">
<Grid key={ issue.issueId } alignItems="center" className="text-black py-1 border-bottom" gap={ 1 }> { openIssues.map(issue => (
<div className="col-span-2">{ issue.categoryId }</div> <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">
<div className="col-span-3">{ issue.reportedUserName }</div> <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">
<div className="col-span-4">{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }</div> { GetIssueCategoryName(issue.categoryId) }
<div className="col-span-3"> </span>
<Button variant="success" onClick={ () => pickIssue(issue.issueId) }>Pick Issue</Button> <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 } /> { LocalizeText('modtools.tickets.action.pick') }
</button>
</div>
)) }
</div> }
</div> </div>
</Grid>
);
}) }
</Column>
</Column>
); );
}; };
@@ -1,6 +1,7 @@
import { IssueMessageData } from '@nitrots/nitro-renderer'; import { IssueMessageData } from '@nitrots/nitro-renderer';
import { FC } from 'react'; import { FC } from 'react';
import { Column, Grid } from '../../../../common'; import { FaClock, FaInbox, FaUser, FaUserShield } from 'react-icons/fa';
import { GetIssueCategoryName, LocalizeText } from '../../../../api';
interface ModToolsPickedIssuesTabViewProps interface ModToolsPickedIssuesTabViewProps
{ {
@@ -10,30 +11,35 @@ interface ModToolsPickedIssuesTabViewProps
export const ModToolsPickedIssuesTabView: FC<ModToolsPickedIssuesTabViewProps> = props => export const ModToolsPickedIssuesTabView: FC<ModToolsPickedIssuesTabViewProps> = props =>
{ {
const { pickedIssues = null } = props; const { pickedIssues = null } = props;
const isEmpty = !pickedIssues || pickedIssues.length === 0;
return ( return (
<Column gap={ 0 } overflow="hidden"> <div className="flex flex-col gap-1 overflow-hidden">
<Column gap={ 2 }> <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">
<Grid className="text-black font-bold border-bottom pb-1" gap={ 1 }> <div>{ LocalizeText('modtools.tickets.column.type') }</div>
<div className="col-span-2">Type</div> <div className="flex items-center gap-1"><FaUser size={ 10 } /> { LocalizeText('modtools.tickets.column.reported') }</div>
<div className="col-span-3">Room/Player</div> <div className="flex items-center gap-1"><FaClock size={ 10 } /> { LocalizeText('modtools.tickets.column.opened') }</div>
<div className="col-span-4">Opened</div> <div className="flex items-center gap-1"><FaUserShield size={ 10 } /> { LocalizeText('modtools.tickets.column.picker') }</div>
<div className="col-span-3">Picker</div> </div>
</Grid> { isEmpty
</Column> ? <div className="flex flex-col items-center justify-center gap-1 py-8 opacity-50 text-sm">
<Column className="striped-children" gap={ 0 } overflow="auto"> <FaInbox size={ 22 } />
{ pickedIssues && (pickedIssues.length > 0) && pickedIssues.map(issue => <span>{ LocalizeText('modtools.tickets.empty.picked') }</span>
{ </div>
return ( : <div className="flex flex-col overflow-auto">
<Grid key={ issue.issueId } alignItems="center" className="text-black py-1 border-bottom" gap={ 1 }> { pickedIssues.map(issue => (
<div className="col-span-2">{ issue.categoryId }</div> <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]">
<div className="col-span-3">{ issue.reportedUserName }</div> <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">
<div className="col-span-4">{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }</div> { GetIssueCategoryName(issue.categoryId) }
<div className="col-span-3">{ issue.pickerUserName }</div> </span>
</Grid> <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() }
</Column> </span>
</Column> <span className="truncate font-medium opacity-80">{ issue.pickerUserName }</span>
</div>
)) }
</div> }
</div>
); );
}; };
@@ -1,5 +1,7 @@
import { GetSessionDataManager, IssueMessageData } from '@nitrots/nitro-renderer'; 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 { LocalizeText } from '../../../../api';
import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../../../common'; import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../../../common';
import { useModTools } from '../../../../hooks'; import { useModTools } from '../../../../hooks';
import { ModToolsIssueInfoView } from './ModToolsIssueInfoView'; import { ModToolsIssueInfoView } from './ModToolsIssueInfoView';
@@ -12,11 +14,30 @@ interface ModToolsTicketsViewProps
onCloseClick: () => void; onCloseClick: () => void;
} }
const TABS: string[] = [ interface TabBadgeProps
'Open Issues', {
'My Issues', label: string;
'Picked Issues' 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 => export const ModToolsTicketsView: FC<ModToolsTicketsViewProps> = props =>
{ {
@@ -25,9 +46,15 @@ export const ModToolsTicketsView: FC<ModToolsTicketsViewProps> = props =>
const [ issueInfoWindows, setIssueInfoWindows ] = useState<number[]>([]); const [ issueInfoWindows, setIssueInfoWindows ] = useState<number[]>([]);
const { tickets = [] } = useModTools(); const { tickets = [] } = useModTools();
const openIssues = tickets.filter(issue => issue.state === IssueMessageData.STATE_OPEN); const { openIssues, myIssues, pickedIssues } = useMemo(() =>
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 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) => const closeIssue = (issueId: number) =>
{ {
@@ -56,32 +83,34 @@ export const ModToolsTicketsView: FC<ModToolsTicketsViewProps> = props =>
}); });
}; };
const CurrentTabComponent = () => const renderTab = () =>
{ {
switch(currentTab) switch(currentTab)
{ {
case 0: return <ModToolsOpenIssuesTabView openIssues={ openIssues }/>; case 0: return <ModToolsOpenIssuesTabView openIssues={ openIssues } />;
case 1: return <ModToolsMyIssuesTabView handleIssue={ handleIssue } myIssues={ myIssues }/>; case 1: return <ModToolsMyIssuesTabView handleIssue={ handleIssue } myIssues={ myIssues } />;
case 2: return <ModToolsPickedIssuesTabView pickedIssues={ pickedIssues }/>; case 2: return <ModToolsPickedIssuesTabView pickedIssues={ pickedIssues } />;
} }
return null; return null;
}; };
return ( 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 } /> <NitroCardHeaderView headerText={ LocalizeText('modtools.tickets.title') } onCloseClick={ onCloseClick } />
<NitroCardTabsView> <NitroCardTabsView>
{ TABS.map((tab, index) => <NitroCardTabsItemView isActive={ currentTab === 0 } onClick={ () => setCurrentTab(0) }>
{ <TabLabel label={ LocalizeText('modtools.tickets.tab.open') } count={ openIssues.length } icon={ <FaListUl size={ 10 } /> } tone="amber" />
return (<NitroCardTabsItemView key={ index } isActive={ (currentTab === index) } onClick={ event => setCurrentTab(index) }> </NitroCardTabsItemView>
{ tab } <NitroCardTabsItemView isActive={ currentTab === 1 } onClick={ () => setCurrentTab(1) }>
</NitroCardTabsItemView>); <TabLabel label={ LocalizeText('modtools.tickets.tab.mine') } count={ myIssues.length } icon={ <FaUserCheck size={ 10 } /> } tone="sky" />
}) } </NitroCardTabsItemView>
<NitroCardTabsItemView isActive={ currentTab === 2 } onClick={ () => setCurrentTab(2) }>
<TabLabel label={ LocalizeText('modtools.tickets.tab.picked') } count={ pickedIssues.length } icon={ <FaCheckSquare size={ 10 } /> } tone="zinc" />
</NitroCardTabsItemView>
</NitroCardTabsView> </NitroCardTabsView>
<NitroCardContentView gap={ 1 }> <NitroCardContentView gap={ 1 }>
<CurrentTabComponent /> { renderTab() }
</NitroCardContentView> </NitroCardContentView>
</NitroCardView> </NitroCardView>
{ issueInfoWindows && (issueInfoWindows.length > 0) && issueInfoWindows.map(issueId => <ModToolsIssueInfoView key={ issueId } issueId={ issueId } onIssueInfoClosed={ closeIssue } />) } { issueInfoWindows && (issueInfoWindows.length > 0) && issueInfoWindows.map(issueId => <ModToolsIssueInfoView key={ issueId } issueId={ issueId } onIssueInfoClosed={ closeIssue } />) }
@@ -1,6 +1,7 @@
import { ChatRecordData, GetUserChatlogMessageComposer, UserChatlogEvent } from '@nitrots/nitro-renderer'; import { ChatRecordData, GetUserChatlogMessageComposer, UserChatlogEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react'; import { FC, useEffect, useState } from 'react';
import { SendMessageComposer } from '../../../../api'; import { FaSpinner } from 'react-icons/fa';
import { LocalizeText, SendMessageComposer } from '../../../../api';
import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useMessageEvent } from '../../../../hooks'; import { useMessageEvent } from '../../../../hooks';
import { ChatlogView } from '../chatlog/ChatlogView'; import { ChatlogView } from '../chatlog/ChatlogView';
@@ -33,11 +34,15 @@ export const ModToolsUserChatlogView: FC<ModToolsUserChatlogViewProps> = props =
}, [ userId ]); }, [ userId ]);
return ( return (
<NitroCardView className="nitro-mod-tools-chatlog" 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={ `User Chatlog: ${ username || '' }` } onCloseClick={ onCloseClick } /> <NitroCardHeaderView headerText={ username ? LocalizeText('modtools.user.chatlog.title.with', [ 'username' ], [ username ]) : LocalizeText('modtools.user.chatlog.title') } onCloseClick={ onCloseClick } />
<NitroCardContentView className="text-black h-full"> <NitroCardContentView className="text-black h-full" gap={ 1 }>
{ userChatlog && { userChatlog
<ChatlogView records={ 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>{ LocalizeText('modtools.user.chatlog.loading') }</span>
</div> }
</NitroCardContentView> </NitroCardContentView>
</NitroCardView> </NitroCardView>
); );
@@ -1,7 +1,8 @@
import { CallForHelpTopicData, DefaultSanctionMessageComposer, ModAlertMessageComposer, ModBanMessageComposer, ModKickMessageComposer, ModMessageMessageComposer, ModMuteMessageComposer, ModTradingLockMessageComposer } from '@nitrots/nitro-renderer'; import { CallForHelpTopicData, DefaultSanctionMessageComposer, ModAlertMessageComposer, ModBanMessageComposer, ModKickMessageComposer, ModMessageMessageComposer, ModMuteMessageComposer, ModTradingLockMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useMemo, useRef, useState } from 'react'; 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 { 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'; import { useModTools, useNotification } from '../../../../hooks';
interface ModToolsUserModActionViewProps interface ModToolsUserModActionViewProps
@@ -25,6 +26,24 @@ const MOD_ACTION_DEFINITIONS = [
new ModActionDefinition(105, 'Message', ModActionDefinition.MESSAGE, 0, 0), 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 => export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = props =>
{ {
const { user = null, onCloseClick = null } = props; const { user = null, onCloseClick = null } = props;
@@ -50,26 +69,20 @@ export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = pro
return values; return values;
}, [ cfhCategories ]); }, [ 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 = () => const sendDefaultSanction = () =>
{ {
if(isSendingRef.current) return; if(isSendingRef.current) return;
let errorMessage: string = null;
const category = topics[selectedTopic]; const category = topics[selectedTopic];
if(selectedTopic === -1) errorMessage = 'You must select a CFH topic'; if(selectedTopic === -1) return sendAlert(LocalizeText('modtools.user.modaction.error.no.topic'));
if(errorMessage) return sendAlert(errorMessage);
const messageOrDefault = (message.trim().length === 0) ? LocalizeText(`help.cfh.topic.${ category.id }`) : message; const messageOrDefault = (message.trim().length === 0) ? LocalizeText(`help.cfh.topic.${ category.id }`) : message;
isSendingRef.current = true; isSendingRef.current = true;
SendMessageComposer(new DefaultSanctionMessageComposer(user.userId, selectedTopic, messageOrDefault)); SendMessageComposer(new DefaultSanctionMessageComposer(user.userId, selectedTopic, messageOrDefault));
onCloseClick(); onCloseClick();
}; };
@@ -78,34 +91,22 @@ export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = pro
if(isSendingRef.current) return; if(isSendingRef.current) return;
let errorMessage: string = null; let errorMessage: string = null;
const category = topics[selectedTopic]; const category = topics[selectedTopic];
const sanction = MOD_ACTION_DEFINITIONS[selectedAction]; const sanction = MOD_ACTION_DEFINITIONS[selectedAction];
if((selectedTopic === -1) || (selectedAction === -1)) errorMessage = 'You must select a CFH topic and Sanction'; if((selectedTopic === -1) || (selectedAction === -1)) errorMessage = LocalizeText('modtools.user.modaction.error.no.action');
else if(!settings || !settings.cfhPermission) errorMessage = 'You do not have permission to do this'; else if(!settings || !settings.cfhPermission) errorMessage = LocalizeText('modtools.user.modaction.error.no.permission');
else if(!category) errorMessage = 'You must select a CFH topic'; else if(!category) errorMessage = LocalizeText('modtools.user.modaction.error.no.topic');
else if(!sanction) errorMessage = 'You must select a sanction'; else if(!sanction) errorMessage = LocalizeText('modtools.user.modaction.error.no.action');
if(errorMessage) if(errorMessage) return sendAlert(errorMessage);
{
sendAlert(errorMessage);
return;
}
const messageOrDefault = (message.trim().length === 0) ? LocalizeText(`help.cfh.topic.${ category.id }`) : message; const messageOrDefault = (message.trim().length === 0) ? LocalizeText(`help.cfh.topic.${ category.id }`) : message;
switch(sanction.actionType) switch(sanction.actionType)
{ {
case ModActionDefinition.ALERT: { case ModActionDefinition.ALERT: {
if(!settings.alertPermission) if(!settings.alertPermission) return sendAlert(LocalizeText('modtools.user.modaction.error.no.permission.alert'));
{
sendAlert('You have insufficient permissions');
return;
}
SendMessageComposer(new ModAlertMessageComposer(user.userId, messageOrDefault, category.id)); SendMessageComposer(new ModAlertMessageComposer(user.userId, messageOrDefault, category.id));
break; break;
} }
@@ -113,72 +114,108 @@ export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = pro
SendMessageComposer(new ModMuteMessageComposer(user.userId, messageOrDefault, category.id)); SendMessageComposer(new ModMuteMessageComposer(user.userId, messageOrDefault, category.id));
break; break;
case ModActionDefinition.BAN: { case ModActionDefinition.BAN: {
if(!settings.banPermission) if(!settings.banPermission) return sendAlert(LocalizeText('modtools.user.modaction.error.no.permission.alert'));
{
sendAlert('You have insufficient permissions');
return;
}
SendMessageComposer(new ModBanMessageComposer(user.userId, messageOrDefault, category.id, selectedAction, (sanction.actionId === 106))); SendMessageComposer(new ModBanMessageComposer(user.userId, messageOrDefault, category.id, selectedAction, (sanction.actionId === 106)));
break; break;
} }
case ModActionDefinition.KICK: { case ModActionDefinition.KICK: {
if(!settings.kickPermission) if(!settings.kickPermission) return sendAlert(LocalizeText('modtools.user.modaction.error.no.permission.alert'));
{
sendAlert('You have insufficient permissions');
return;
}
SendMessageComposer(new ModKickMessageComposer(user.userId, messageOrDefault, category.id)); SendMessageComposer(new ModKickMessageComposer(user.userId, messageOrDefault, category.id));
break; break;
} }
case ModActionDefinition.TRADE_LOCK: { case ModActionDefinition.TRADE_LOCK: {
const numSeconds = (sanction.actionLengthHours * 60); const numSeconds = (sanction.actionLengthHours * 60);
SendMessageComposer(new ModTradingLockMessageComposer(user.userId, messageOrDefault, numSeconds, category.id)); SendMessageComposer(new ModTradingLockMessageComposer(user.userId, messageOrDefault, numSeconds, category.id));
break; break;
} }
case ModActionDefinition.MESSAGE: { case ModActionDefinition.MESSAGE: {
if(message.trim().length === 0) if(message.trim().length === 0) return sendAlert(LocalizeText('modtools.user.modaction.error.no.message'));
{
sendAlert('Please write a message to user');
return;
}
SendMessageComposer(new ModMessageMessageComposer(user.userId, message, category.id)); SendMessageComposer(new ModMessageMessageComposer(user.userId, message, category.id));
break; break;
} }
} }
isSendingRef.current = true; isSendingRef.current = true;
onCloseClick(); onCloseClick();
}; };
if(!user) return null; 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 ( return (
<NitroCardView className="nitro-mod-tools-user-action" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }> <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 ? user.username : '') } onCloseClick={ () => onCloseClick() } /> <NitroCardHeaderView headerText={ LocalizeText('modtools.user.modaction.title', [ 'username' ], [ user.username ]) } onCloseClick={ () => onCloseClick() } />
<NitroCardContentView className="text-black"> <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">{ LocalizeText('modtools.user.modaction.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">{ LocalizeText('modtools.user.modaction.step.topic') }</label>
<select className="form-select form-select-sm" value={ selectedTopic } onChange={ event => setSelectedTopic(parseInt(event.target.value)) }> <select className="form-select form-select-sm" value={ selectedTopic } onChange={ event => setSelectedTopic(parseInt(event.target.value)) }>
<option disabled value={ -1 }>CFH Topic</option> <option disabled value={ -1 }>{ LocalizeText('modtools.user.modaction.step.topic.placeholder') }</option>
{ topics.map((topic, index) => <option key={ index } value={ index }>{ LocalizeText('help.cfh.topic.' + topic.id) }</option>) } { topics.map((topic, index) => <option key={ index } value={ index }>{ LocalizeText('help.cfh.topic.' + topic.id) }</option>) }
</select> </select>
</div>
{/* Sanction type */}
<div className="flex flex-col gap-1">
<label className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">{ LocalizeText('modtools.user.modaction.step.sanction') }</label>
<select className="form-select form-select-sm" value={ selectedAction } onChange={ event => setSelectedAction(parseInt(event.target.value)) }> <select className="form-select form-select-sm" value={ selectedAction } onChange={ event => setSelectedAction(parseInt(event.target.value)) }>
<option disabled value={ -1 }>Sanction Type</option> <option disabled value={ -1 }>{ LocalizeText('modtools.user.modaction.step.sanction.placeholder') }</option>
{ MOD_ACTION_DEFINITIONS.map((action, index) => <option key={ index } value={ index }>{ action.name }</option>) } { MOD_ACTION_DEFINITIONS.map((action, index) => <option key={ index } value={ index }>{ action.name }</option>) }
</select> </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) } />
</div> </div>
<Flex gap={ 1 } justifyContent="between">
<Button variant="primary" onClick={ sendDefaultSanction }>Default Sanction</Button> {/* Message */}
<Button variant="success" onClick={ sendSanction }>Sanction</Button> <div className="flex flex-col gap-1">
</Flex> <label className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">
{ LocalizeText('modtools.user.modaction.step.message') } <span className="opacity-50 normal-case font-normal">{ LocalizeText('modtools.user.modaction.step.message.optional') }</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={ LocalizeText('modtools.user.modaction.message.placeholder') }
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">{ LocalizeText('modtools.user.modaction.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 } /> { LocalizeText('modtools.user.modaction.button.default') }
</Button>
<Button className="grow" disabled={ !canSubmit || selectedAction === -1 } gap={ 1 } variant="success" onClick={ sendSanction }>
<FaGavel size={ 12 } /> { LocalizeText('modtools.user.modaction.button.apply') }
</Button>
</div>
</NitroCardContentView> </NitroCardContentView>
</NitroCardView> </NitroCardView>
); );
@@ -1,7 +1,8 @@
import { GetRoomVisitsMessageComposer, RoomVisitsData, RoomVisitsEvent } from '@nitrots/nitro-renderer'; import { GetRoomVisitsMessageComposer, RoomVisitsData, RoomVisitsEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react'; import { FC, useEffect, useState } from 'react';
import { SendMessageComposer, TryVisitRoom } from '../../../../api'; import { FaClock, FaDoorOpen, FaSignInAlt } from 'react-icons/fa';
import { Column, DraggableWindowPosition, Grid, InfiniteScroll, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common'; import { LocalizeText, SendMessageComposer, TryVisitRoom } from '../../../../api';
import { DraggableWindowPosition, InfiniteScroll, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useMessageEvent } from '../../../../hooks'; import { useMessageEvent } from '../../../../hooks';
interface ModToolsUserRoomVisitsViewProps interface ModToolsUserRoomVisitsViewProps
@@ -31,29 +32,55 @@ export const ModToolsUserRoomVisitsView: FC<ModToolsUserRoomVisitsViewProps> = p
if(!userId) return null; if(!userId) return null;
const rows = roomVisitData?.rooms ?? [];
const isEmpty = rows.length === 0;
const countLabel = rows.length === 1
? LocalizeText('modtools.user.visits.entries.one', [ 'count' ], [ rows.length.toString() ])
: LocalizeText('modtools.user.visits.entries.many', [ 'count' ], [ rows.length.toString() ]);
return ( 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 } /> <NitroCardHeaderView headerText={ LocalizeText('modtools.user.visits.title') } onCloseClick={ onCloseClick } />
<NitroCardContentView className="text-black" gap={ 1 }> <NitroCardContentView className="text-black" gap={ 1 }>
<Column fullHeight gap={ 0 } overflow="hidden"> {/* Header strip */}
<Column gap={ 2 }> <div className="flex items-center gap-2 bg-gradient-to-r from-sky-50 to-transparent rounded p-2 border border-sky-100">
<Grid className="text-black font-bold border-bottom pb-1" gap={ 1 }> <FaDoorOpen className="text-sky-600 shrink-0" size={ 14 } />
<div className="col-span-2">Time</div> <div className="text-sm font-semibold leading-tight grow">{ LocalizeText('modtools.user.visits.recent') }</div>
<div className="col-span-7">Room name</div> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border bg-white border-zinc-200">
<div className="col-span-3">Visit</div> { countLabel }
</Grid> </span>
</Column> </div>
<InfiniteScroll rowRender={ row =>
{ {/* Table head */}
return ( <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">
<Grid alignItems="center" className="text-black py-1 border-bottom" fullHeight={ false } gap={ 1 }> <div className="flex items-center gap-1"><FaClock size={ 10 } /> { LocalizeText('modtools.user.visits.time') }</div>
<Text className="col-span-2">{ row.enterHour.toString().padStart(2, '0') }: { row.enterMinute.toString().padStart(2, '0') }</Text> <div>{ LocalizeText('modtools.user.visits.room') }</div>
<Text className="col-span-7">{ row.roomName }</Text> <div className="text-right">{ LocalizeText('modtools.user.visits.action') }</div>
<Text bold pointer underline className="col-span-3" variant="primary" onClick={ event => TryVisitRoom(row.roomId) }>Visit Room</Text> </div>
</Grid>
); {/* Rows */}
} } rows={ roomVisitData?.rooms ?? [] } /> { isEmpty
</Column> ? <div className="flex flex-col items-center justify-center gap-1 py-6 opacity-50 text-sm">
<FaDoorOpen size={ 22 } />
<span>{ LocalizeText('modtools.user.visits.empty') }</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={ LocalizeText('modtools.user.visits.visit.title') }>
<FaSignInAlt size={ 10 } /> { LocalizeText('modtools.user.visits.visit') }
</button>
</div>
) } rows={ rows } />
</div> }
</NitroCardContentView> </NitroCardContentView>
</NitroCardView> </NitroCardView>
); );
@@ -1,7 +1,8 @@
import { ModMessageMessageComposer } from '@nitrots/nitro-renderer'; import { ModMessageMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useState } from 'react'; import { FC, useState } from 'react';
import { ISelectedUser, SendMessageComposer } from '../../../../api'; import { FaEnvelope, FaPaperPlane, FaUser } from 'react-icons/fa';
import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common'; import { ISelectedUser, LocalizeText, SendMessageComposer } from '../../../../api';
import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useNotification } from '../../../../hooks'; import { useNotification } from '../../../../hooks';
interface ModToolsUserSendMessageViewProps interface ModToolsUserSendMessageViewProps
@@ -18,27 +19,55 @@ export const ModToolsUserSendMessageView: FC<ModToolsUserSendMessageViewProps> =
if(!user) return null; if(!user) return null;
const trimmed = message.trim();
const canSend = trimmed.length > 0;
const sendMessage = () => const sendMessage = () =>
{ {
if(message.trim().length === 0) if(!canSend)
{ {
simpleAlert('Please write a message to user.', null, null, null, 'Error', null); simpleAlert('Please write a message to user.', null, null, null, 'Error', null);
return; return;
} }
SendMessageComposer(new ModMessageMessageComposer(user.userId, message, -999)); SendMessageComposer(new ModMessageMessageComposer(user.userId, message, -999));
onCloseClick(); onCloseClick();
}; };
return ( 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() } /> <NitroCardHeaderView headerText={ LocalizeText('modtools.user.message.title') } onCloseClick={ () => onCloseClick() } />
<NitroCardContentView className="text-black"> <NitroCardContentView className="text-black" gap={ 2 }>
<Text>Message To: { user.username }</Text> {/* Recipient header */}
<textarea className="min-h-[calc(1.5em+ .5rem+2px)] px-[.5rem] py-[.25rem] rounded-[.2rem]" value={ message } onChange={ event => setMessage(event.target.value) }></textarea> <div className="flex items-center gap-2 bg-gradient-to-r from-sky-50 to-transparent rounded p-2 border border-sky-100">
<Button fullWidth onClick={ sendMessage }>Send message</Button> <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">{ LocalizeText('modtools.user.message.recipient') }</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">{ LocalizeText('modtools.user.message.label') }</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={ LocalizeText('modtools.user.message.placeholder') }
value={ message }
onChange={ event => setMessage(event.target.value) }
/>
<div className="flex justify-between text-xs opacity-60">
<span>{ canSend ? LocalizeText('modtools.user.message.chars', [ 'count' ], [ trimmed.length.toString() ]) : LocalizeText('modtools.user.message.empty') }</span>
</div>
</div>
<Button disabled={ !canSend } fullWidth gap={ 1 } variant="primary" onClick={ sendMessage }>
<FaPaperPlane size={ 12 } /> { LocalizeText('modtools.user.message.send') }
</Button>
</NitroCardContentView> </NitroCardContentView>
</NitroCardView> </NitroCardView>
); );
@@ -1,8 +1,9 @@
import { CreateLinkEvent, GetModeratorUserInfoMessageComposer, ModeratorUserInfoData, ModeratorUserInfoEvent } from '@nitrots/nitro-renderer'; import { CreateLinkEvent, GetModeratorUserInfoMessageComposer, ModeratorActionResultMessageEvent, ModeratorUserInfoData, ModeratorUserInfoEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useState } from 'react'; 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 { 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 } from '../../../../hooks'; import { useMessageEvent, useRoomUserListSnapshot } from '../../../../hooks';
import { ModToolsUserModActionView } from './ModToolsUserModActionView'; import { ModToolsUserModActionView } from './ModToolsUserModActionView';
import { ModToolsUserRoomVisitsView } from './ModToolsUserRoomVisitsView'; import { ModToolsUserRoomVisitsView } from './ModToolsUserRoomVisitsView';
import { ModToolsUserSendMessageView } from './ModToolsUserSendMessageView'; import { ModToolsUserSendMessageView } from './ModToolsUserSendMessageView';
@@ -13,6 +14,52 @@ interface ModToolsUserViewProps
onCloseClick: () => void; 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 || value === 0) ? value : <span className="opacity-40">-</span> }</dd>
</>
);
export const ModToolsUserView: FC<ModToolsUserViewProps> = props => export const ModToolsUserView: FC<ModToolsUserViewProps> = props =>
{ {
const { onCloseClick = null, userId = null } = props; const { onCloseClick = null, userId = null } = props;
@@ -20,71 +67,33 @@ export const ModToolsUserView: FC<ModToolsUserViewProps> = props =>
const [ sendMessageVisible, setSendMessageVisible ] = useState(false); const [ sendMessageVisible, setSendMessageVisible ] = useState(false);
const [ modActionVisible, setModActionVisible ] = useState(false); const [ modActionVisible, setModActionVisible ] = useState(false);
const [ roomVisitsVisible, setRoomVisitsVisible ] = useState(false); const [ roomVisitsVisible, setRoomVisitsVisible ] = useState(false);
// Reactive presence: if the target user is currently in the room
// we're observing, they're online — irrespective of what the
// one-shot ModeratorUserInfoData.online said when the panel opened.
const roomUserList = useRoomUserListSnapshot();
const isPresentInCurrentRoom = useMemo(
() => roomUserList.some(user => user && (user.webID === userId)),
[ roomUserList, userId ]
);
const isOnline = isPresentInCurrentRoom || !!(userInfo && userInfo.online);
const presenceLabel = isPresentInCurrentRoom
? LocalizeText('modtools.userinfo.presence.in_room')
: (isOnline ? LocalizeText('modtools.userinfo.presence.online') : LocalizeText('modtools.userinfo.presence.offline'));
const presenceTitle = isPresentInCurrentRoom
? LocalizeText('modtools.userinfo.presence.in_room.title')
: (isOnline ? LocalizeText('modtools.userinfo.presence.online.title') : LocalizeText('modtools.userinfo.presence.offline.title'));
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(() => const refresh = () => SendMessageComposer(new GetModeratorUserInfoMessageComposer(userId));
{
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 ]);
useMessageEvent<ModeratorUserInfoEvent>(ModeratorUserInfoEvent, event => useMessageEvent<ModeratorUserInfoEvent>(ModeratorUserInfoEvent, event =>
{ {
@@ -95,6 +104,19 @@ export const ModToolsUserView: FC<ModToolsUserViewProps> = props =>
setUserInfo(parser.data); setUserInfo(parser.data);
}); });
// Refresh counters (cfhCount / banCount / cautionCount /
// lastSanctionTime) after the moderator applies a sanction on THIS
// user — otherwise the table stays frozen on the values at panel
// open. Parser carries userId so we can filter precisely.
useMessageEvent<ModeratorActionResultMessageEvent>(ModeratorActionResultMessageEvent, event =>
{
const parser = event.getParser();
if(!parser || !parser.success || parser.userId !== userId) return;
refresh();
});
useEffect(() => useEffect(() =>
{ {
SendMessageComposer(new GetModeratorUserInfoMessageComposer(userId)); SendMessageComposer(new GetModeratorUserInfoMessageComposer(userId));
@@ -104,45 +126,73 @@ export const ModToolsUserView: FC<ModToolsUserViewProps> = props =>
return ( 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() } /> <NitroCardHeaderView headerText={ LocalizeText('modtools.userinfo.title', [ 'username' ], [ userInfo.userName ]) } onCloseClick={ () => onCloseClick() } />
<NitroCardContentView className="text-black"> <NitroCardContentView className="text-black" gap={ 2 }>
<Grid overflow="hidden"> {/* Identity header: name + presence pill + manual refresh */}
<Column overflow="auto" size={ 8 }> <div className="flex items-center gap-2 bg-gradient-to-r from-sky-50 to-transparent rounded p-2 border border-sky-100">
<table className="table table-striped table-sm table-text-small text-black m-0"> <div className="flex flex-col grow min-w-0">
<tbody> <Text bold className="truncate text-base leading-tight">{ userInfo.userName }</Text>
{ userProperties.map( (property, index) => <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={ presenceTitle }>
<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={ LocalizeText('modtools.userinfo.refresh') }>
<FaSync size={ 12 } />
</button>
</div>
return ( {/* Moderation stat strip */}
<tr key={ index }> <div className="flex gap-1.5">
<th scope="row">{ LocalizeText(property.localeKey) }</th> <StatCard icon={ <FaExclamationTriangle size={ 10 } /> } label={ LocalizeText('modtools.userinfo.stat.cfh') } tone="warn" value={ userInfo.cfhCount } />
<td> <StatCard icon={ <FaGavel size={ 10 } /> } label={ LocalizeText('modtools.userinfo.stat.cautions') } tone="warn" value={ userInfo.cautionCount } />
{ property.value } <StatCard icon={ <FaBan size={ 10 } /> } label={ LocalizeText('modtools.userinfo.stat.bans') } tone="danger" value={ userInfo.banCount } />
{ property.showOnline && <StatCard icon={ <FaExchangeAlt size={ 10 } /> } label={ LocalizeText('modtools.userinfo.stat.trade.locks') } tone="danger" value={ userInfo.tradingLockCount } />
<i className={ `icon icon-pf-${ userInfo.online ? 'online' : 'offline' } ms-2` } /> } </div>
</td>
</tr> {/* Body sections */}
); <div className="flex flex-col gap-2 max-h-[300px] overflow-auto pr-1">
}) } <Section title={ LocalizeText('modtools.userinfo.section.account') }>
</tbody> <Field label={ LocalizeText('modtools.userinfo.primaryEmailAddress') } value={ userInfo.primaryEmailAddress } />
</table> <Field label={ LocalizeText('modtools.userinfo.registrationAgeInMinutes') } value={ FriendlyTime.format(userInfo.registrationAgeInMinutes * 60, '.ago', 2) } />
</Column> <Field label={ LocalizeText('modtools.userinfo.userClassification') } value={ userInfo.userClassification } />
<Column gap={ 1 } size={ 4 }> </Section>
<Button onClick={ event => CreateLinkEvent(`mod-tools/open-user-chatlog/${ userId }`) }> <Section title={ LocalizeText('modtools.userinfo.section.activity') }>
Room Chat <Field label={ LocalizeText('modtools.userinfo.minutesSinceLastLogin') } value={ FriendlyTime.format(userInfo.minutesSinceLastLogin * 60, '.ago', 2) } />
<Field label={ LocalizeText('modtools.userinfo.lastPurchaseDate') } value={ userInfo.lastPurchaseDate } />
</Section>
<Section title={ LocalizeText('modtools.userinfo.section.sanctions') }>
<Field label={ LocalizeText('modtools.userinfo.abusiveCfhCount') } value={ userInfo.abusiveCfhCount } />
<Field label={ LocalizeText('modtools.userinfo.lastSanctionTime') } value={ userInfo.lastSanctionTime } />
<Field label={ LocalizeText('modtools.userinfo.identityRelatedBanCount') } value={ userInfo.identityRelatedBanCount } />
</Section>
<Section title={ LocalizeText('modtools.userinfo.section.trading') }>
<Field label={ LocalizeText('modtools.userinfo.tradingExpiryDate') } 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 } /> { LocalizeText('modtools.userinfo.button.room.chat') }
</Button> </Button>
<Button onClick={ event => setSendMessageVisible(!sendMessageVisible) }> <Button gap={ 1 } variant="secondary" onClick={ () => setSendMessageVisible(prev => !prev) }>
Send Message <FaEnvelope size={ 12 } /> { LocalizeText('modtools.userinfo.button.send.message') }
</Button> </Button>
<Button onClick={ event => setRoomVisitsVisible(!roomVisitsVisible) }> <Button gap={ 1 } variant="secondary" onClick={ () => setRoomVisitsVisible(prev => !prev) }>
Room Visits <FaDoorOpen size={ 12 } /> { LocalizeText('modtools.userinfo.button.room.visits') }
</Button> </Button>
<Button onClick={ event => setModActionVisible(!modActionVisible) }> <Button gap={ 1 } variant="danger" onClick={ () => setModActionVisible(prev => !prev) }>
Mod Action <FaGavel size={ 12 } /> { LocalizeText('modtools.userinfo.button.mod.action') }
</Button> </Button>
</Column> </div>
</Grid>
</NitroCardContentView> </NitroCardContentView>
</NitroCardView> </NitroCardView>
{ sendMessageVisible && { sendMessageVisible &&
+16 -4
View File
@@ -1,9 +1,9 @@
import { CreateLinkEvent, Dispose, DropBounce, EaseOut, JumpBy, Motions, NitroToolbarAnimateIconEvent, PerkAllowancesMessageEvent, PerkEnum, Queue, Wait, YouTubeRoomSettingsEvent } from '@nitrots/nitro-renderer'; import { CreateLinkEvent, Dispose, DropBounce, EaseOut, JumpBy, Motions, NitroToolbarAnimateIconEvent, PerkAllowancesMessageEvent, PerkEnum, Queue, Wait, YouTubeRoomSettingsEvent } from '@nitrots/nitro-renderer';
import { AnimatePresence, motion, Variants } from 'framer-motion'; import { AnimatePresence, motion, Variants } from 'framer-motion';
import { FC, useEffect, useState } from 'react'; import { FC, useEffect, useMemo, useState } from 'react';
import { GetConfigurationValue, MessengerIconState, OpenMessengerChat, setYoutubeRoomEnabled, VisitDesktop } from '../../api'; import { GetConfigurationValue, MessengerIconState, OpenMessengerChat, setYoutubeRoomEnabled, VisitDesktop } from '../../api';
import { Flex, LayoutAvatarImageView, LayoutItemCountView } from '../../common'; import { Flex, LayoutAvatarImageView, LayoutItemCountView } from '../../common';
import { useAchievements, useFriends, useHasPermission, useInventoryUnseenTracker, useMessageEvent, useMessenger, useNitroEvent, useSessionInfo, useWiredTools } from '../../hooks'; import { useAchievements, useFriends, useHasPermission, useInventoryUnseenTracker, useMessageEvent, useMessenger, useModTools, useNitroEvent, useSessionInfo, useWiredTools } from '../../hooks';
import { ToolbarItemView } from './ToolbarItemView'; import { ToolbarItemView } from './ToolbarItemView';
import { ToolbarMeView } from './ToolbarMeView'; import { ToolbarMeView } from './ToolbarMeView';
import { YouTubePlayerView } from './YouTubePlayerView'; import { YouTubePlayerView } from './YouTubePlayerView';
@@ -50,6 +50,14 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
const { iconState = MessengerIconState.HIDDEN } = useMessenger(); const { iconState = MessengerIconState.HIDDEN } = useMessenger();
const { openMonitor, showToolbarButton } = useWiredTools(); const { openMonitor, showToolbarButton } = useWiredTools();
const isMod = useHasPermission('acc_supporttool'); const isMod = useHasPermission('acc_supporttool');
// Surface the open-ticket count on the toolbar ModTools button so a
// new CFH pings the mod even when the launcher itself is closed.
// useBetween-shared state — no extra subscription cost.
const { tickets = [] } = useModTools();
const openTicketsCount = useMemo(
() => isMod ? tickets.filter(ticket => ticket && (ticket.state === 1)).length : 0,
[ isMod, tickets ]
);
const isVisible = (isToolbarOpen || !isInRoom); const isVisible = (isToolbarOpen || !isInRoom);
const visibilityVariant = isVisible ? 'visible' : 'hidden'; const visibilityVariant = isVisible ? 'visible' : 'hidden';
@@ -260,8 +268,10 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
<ToolbarItemView icon="youtube" onClick={ openYouTubePlayer } className="tb-icon" /> <ToolbarItemView icon="youtube" onClick={ openYouTubePlayer } className="tb-icon" />
</motion.div> } </motion.div> }
{ isMod && { isMod &&
<motion.div variants={ itemVariants }> <motion.div variants={ itemVariants } className="relative">
<ToolbarItemView icon="modtools" onClick={ () => CreateLinkEvent('mod-tools/toggle') } className="tb-icon" /> <ToolbarItemView icon="modtools" onClick={ () => CreateLinkEvent('mod-tools/toggle') } className="tb-icon" />
{ (openTicketsCount > 0) &&
<LayoutItemCountView count={ openTicketsCount } className="pointer-events-none absolute -right-1 -top-1 z-10" /> }
</motion.div> } </motion.div> }
{ isMod && { isMod &&
<motion.div variants={ itemVariants }> <motion.div variants={ itemVariants }>
@@ -370,8 +380,10 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
<ToolbarItemView icon="youtube" onClick={ openYouTubePlayer } className="tb-icon" /> <ToolbarItemView icon="youtube" onClick={ openYouTubePlayer } className="tb-icon" />
</motion.div> } </motion.div> }
{ isMod && { isMod &&
<motion.div variants={ itemVariants }> <motion.div variants={ itemVariants } className="relative">
<ToolbarItemView icon="modtools" onClick={ () => CreateLinkEvent('mod-tools/toggle') } className="tb-icon" /> <ToolbarItemView icon="modtools" onClick={ () => CreateLinkEvent('mod-tools/toggle') } className="tb-icon" />
{ (openTicketsCount > 0) &&
<LayoutItemCountView count={ openTicketsCount } className="pointer-events-none absolute -right-1 -top-1 z-10" /> }
</motion.div> } </motion.div> }
{ isMod && { isMod &&
<motion.div variants={ itemVariants }> <motion.div variants={ itemVariants }>