mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 23:16:21 +00:00
refactor(mod-tools): redesign all related windows with shared visual language
Applies the visual language introduced in ModToolsUserView yesterday to every other ModTools window. The design tokens used consistently: emerald — present in current room / positive state sky — online / informational / current selection zinc — neutral / disabled amber — warn-level (CFH, alerts, cautions) rose — danger (bans, releases, abusive) Files redesigned: ModToolsRoomView Identity header with FaDoorOpen, room name + ID, owner-present pill (emerald/zinc), manual refresh button. Stat strip: user count (sky) + clickable owner name (zinc) opening user info. Quick actions (Visit / Chatlog) in a 2-col grid. Moderate panel collapsed into an amber-tinted card with the 3 toggles + textarea + two CTAs (Send Caution=danger, Send Alert=warning). CTAs disabled until a message is typed AND the room info has loaded. ModToolsUserModActionView Numbered 3-step form (CFH topic → sanction → optional message). Live preview row showing the chosen topic + sanction as tone-coded pills (amber/sky/rose/orange/fuchsia/zinc by action type). Primary CTA = Default Sanction, success CTA = Apply Sanction, both disabled until the required selections are made. ModToolsUserSendMessageView Recipient header with FaEnvelope and the username, autofocused textarea, char counter, single full-width Send button gated on non-empty message. ModToolsUserRoomVisitsView Header strip with entry count badge, three-column grid (time / room name / visit button), monospace timestamps, hover row highlight, empty state with FaDoorOpen icon. ModToolsUserChatlogView / ModToolsChatlogView / CfhChatlogView Loading state with spinner instead of returning null. Cards grow to min-w-[460px] max-w-[520px] max-h-[500px] for usable chatlog area. ChatlogView Replace Bootstrap-ish striped table with a CSS grid (60px / 120px / 1fr). Room-info separator rendered as a sky card with Visit/Tools pill buttons. Per-row hover + even-row tint; highlighted rows (hasHighlighting) get an amber wash. Username is a button opening user info via existing link event. Empty state with FaCommentDots. ModToolsTicketsView Tabs get icons (FaListUl / FaUserCheck / FaCheckSquare) and inline count badges (amber/sky/zinc) so the moderator sees the queue size at a glance. ticket bucket filtering memoized off the tickets array. ModToolsOpenIssuesTabView / MyIssuesTabView / PickedIssuesTabView Same CSS grid table style. Category renders as a tone-coded pill (Open=amber, Mine=sky, All picked=zinc). Action buttons get icons (FaHandPointer Pick, FaTools Handle, FaSignOutAlt Release). Empty state with FaInbox. ModToolsIssueInfoView Card header with category + topic pills. Details rendered as a dl grid instead of a striped table. Caller / Reported names as inline link buttons with external-link icon. Chatlog toggle is full-width secondary. Resolution buttons in a 3-col grid with intent colours (success=Resolved, dark=Useless, danger=Abusive) + a separate Release-to-queue button on its own row so it isn't confused with the resolutions. No behaviour changes — all composers, message events, parent state hookups, and sanction validation paths are unchanged. This is purely a presentation pass. typecheck + vitest 214/214 + lint:hooks all clean.
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { CallForHelpTopicData, DefaultSanctionMessageComposer, ModAlertMessageComposer, ModBanMessageComposer, ModKickMessageComposer, ModMessageMessageComposer, ModMuteMessageComposer, ModTradingLockMessageComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useMemo, useRef, useState } from 'react';
|
||||
import { FaBan, FaBolt, FaEnvelope, FaExclamationTriangle, FaGavel, FaUserSlash, FaVolumeMute } from 'react-icons/fa';
|
||||
import { ISelectedUser, LocalizeText, ModActionDefinition, NotificationAlertType, SendMessageComposer } from '../../../../api';
|
||||
import { Button, DraggableWindowPosition, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
|
||||
import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
|
||||
import { useModTools, useNotification } from '../../../../hooks';
|
||||
|
||||
interface ModToolsUserModActionViewProps
|
||||
@@ -25,6 +26,24 @@ const MOD_ACTION_DEFINITIONS = [
|
||||
new ModActionDefinition(105, 'Message', ModActionDefinition.MESSAGE, 0, 0),
|
||||
];
|
||||
|
||||
const ACTION_ICONS: Record<number, React.ReactNode> = {
|
||||
[ModActionDefinition.ALERT]: <FaExclamationTriangle size={ 10 } />,
|
||||
[ModActionDefinition.MUTE]: <FaVolumeMute size={ 10 } />,
|
||||
[ModActionDefinition.BAN]: <FaBan size={ 10 } />,
|
||||
[ModActionDefinition.KICK]: <FaUserSlash size={ 10 } />,
|
||||
[ModActionDefinition.TRADE_LOCK]: <FaGavel size={ 10 } />,
|
||||
[ModActionDefinition.MESSAGE]: <FaEnvelope size={ 10 } />,
|
||||
};
|
||||
|
||||
const ACTION_TONE: Record<number, string> = {
|
||||
[ModActionDefinition.ALERT]: 'bg-amber-100 text-amber-800 border-amber-200',
|
||||
[ModActionDefinition.MUTE]: 'bg-sky-100 text-sky-800 border-sky-200',
|
||||
[ModActionDefinition.BAN]: 'bg-rose-100 text-rose-800 border-rose-200',
|
||||
[ModActionDefinition.KICK]: 'bg-orange-100 text-orange-800 border-orange-200',
|
||||
[ModActionDefinition.TRADE_LOCK]: 'bg-fuchsia-100 text-fuchsia-800 border-fuchsia-200',
|
||||
[ModActionDefinition.MESSAGE]: 'bg-zinc-100 text-zinc-800 border-zinc-200',
|
||||
};
|
||||
|
||||
export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = props =>
|
||||
{
|
||||
const { user = null, onCloseClick = null } = props;
|
||||
@@ -50,26 +69,20 @@ export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = pro
|
||||
return values;
|
||||
}, [ cfhCategories ]);
|
||||
|
||||
const sendAlert = (message: string) => simpleAlert(message, NotificationAlertType.DEFAULT, null, null, 'Error');
|
||||
const sendAlert = (m: string) => simpleAlert(m, NotificationAlertType.DEFAULT, null, null, 'Error');
|
||||
|
||||
const sendDefaultSanction = () =>
|
||||
{
|
||||
if(isSendingRef.current) return;
|
||||
|
||||
let errorMessage: string = null;
|
||||
|
||||
const category = topics[selectedTopic];
|
||||
|
||||
if(selectedTopic === -1) errorMessage = 'You must select a CFH topic';
|
||||
|
||||
if(errorMessage) return sendAlert(errorMessage);
|
||||
if(selectedTopic === -1) return sendAlert('You must select a CFH topic');
|
||||
|
||||
const messageOrDefault = (message.trim().length === 0) ? LocalizeText(`help.cfh.topic.${ category.id }`) : message;
|
||||
|
||||
isSendingRef.current = true;
|
||||
|
||||
SendMessageComposer(new DefaultSanctionMessageComposer(user.userId, selectedTopic, messageOrDefault));
|
||||
|
||||
onCloseClick();
|
||||
};
|
||||
|
||||
@@ -78,7 +91,6 @@ export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = pro
|
||||
if(isSendingRef.current) return;
|
||||
|
||||
let errorMessage: string = null;
|
||||
|
||||
const category = topics[selectedTopic];
|
||||
const sanction = MOD_ACTION_DEFINITIONS[selectedAction];
|
||||
|
||||
@@ -87,25 +99,14 @@ export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = pro
|
||||
else if(!category) errorMessage = 'You must select a CFH topic';
|
||||
else if(!sanction) errorMessage = 'You must select a sanction';
|
||||
|
||||
if(errorMessage)
|
||||
{
|
||||
sendAlert(errorMessage);
|
||||
|
||||
return;
|
||||
}
|
||||
if(errorMessage) return sendAlert(errorMessage);
|
||||
|
||||
const messageOrDefault = (message.trim().length === 0) ? LocalizeText(`help.cfh.topic.${ category.id }`) : message;
|
||||
|
||||
switch(sanction.actionType)
|
||||
{
|
||||
case ModActionDefinition.ALERT: {
|
||||
if(!settings.alertPermission)
|
||||
{
|
||||
sendAlert('You have insufficient permissions');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if(!settings.alertPermission) return sendAlert('You have insufficient permissions');
|
||||
SendMessageComposer(new ModAlertMessageComposer(user.userId, messageOrDefault, category.id));
|
||||
break;
|
||||
}
|
||||
@@ -113,72 +114,106 @@ export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = pro
|
||||
SendMessageComposer(new ModMuteMessageComposer(user.userId, messageOrDefault, category.id));
|
||||
break;
|
||||
case ModActionDefinition.BAN: {
|
||||
if(!settings.banPermission)
|
||||
{
|
||||
sendAlert('You have insufficient permissions');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if(!settings.banPermission) return sendAlert('You have insufficient permissions');
|
||||
SendMessageComposer(new ModBanMessageComposer(user.userId, messageOrDefault, category.id, selectedAction, (sanction.actionId === 106)));
|
||||
break;
|
||||
}
|
||||
case ModActionDefinition.KICK: {
|
||||
if(!settings.kickPermission)
|
||||
{
|
||||
sendAlert('You have insufficient permissions');
|
||||
return;
|
||||
}
|
||||
|
||||
if(!settings.kickPermission) return sendAlert('You have insufficient permissions');
|
||||
SendMessageComposer(new ModKickMessageComposer(user.userId, messageOrDefault, category.id));
|
||||
break;
|
||||
}
|
||||
case ModActionDefinition.TRADE_LOCK: {
|
||||
const numSeconds = (sanction.actionLengthHours * 60);
|
||||
|
||||
SendMessageComposer(new ModTradingLockMessageComposer(user.userId, messageOrDefault, numSeconds, category.id));
|
||||
break;
|
||||
}
|
||||
case ModActionDefinition.MESSAGE: {
|
||||
if(message.trim().length === 0)
|
||||
{
|
||||
sendAlert('Please write a message to user');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if(message.trim().length === 0) return sendAlert('Please write a message to user');
|
||||
SendMessageComposer(new ModMessageMessageComposer(user.userId, message, category.id));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
isSendingRef.current = true;
|
||||
|
||||
onCloseClick();
|
||||
};
|
||||
|
||||
if(!user) return null;
|
||||
|
||||
const selectedSanction = selectedAction >= 0 ? MOD_ACTION_DEFINITIONS[selectedAction] : null;
|
||||
const selectedTopicName = selectedTopic >= 0 && topics[selectedTopic] ? LocalizeText('help.cfh.topic.' + topics[selectedTopic].id) : null;
|
||||
const sanctionTone = selectedSanction ? ACTION_TONE[selectedSanction.actionType] : '';
|
||||
const sanctionIcon = selectedSanction ? ACTION_ICONS[selectedSanction.actionType] : null;
|
||||
const canSubmit = (selectedTopic !== -1);
|
||||
|
||||
return (
|
||||
<NitroCardView className="nitro-mod-tools-user-action" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
|
||||
<NitroCardHeaderView headerText={ 'Mod Action: ' + (user ? user.username : '') } onCloseClick={ () => onCloseClick() } />
|
||||
<NitroCardContentView className="text-black">
|
||||
<select className="form-select form-select-sm" value={ selectedTopic } onChange={ event => setSelectedTopic(parseInt(event.target.value)) }>
|
||||
<option disabled value={ -1 }>CFH Topic</option>
|
||||
{ topics.map((topic, index) => <option key={ index } value={ index }>{ LocalizeText('help.cfh.topic.' + topic.id) }</option>) }
|
||||
</select>
|
||||
<select className="form-select form-select-sm" value={ selectedAction } onChange={ event => setSelectedAction(parseInt(event.target.value)) }>
|
||||
<option disabled value={ -1 }>Sanction Type</option>
|
||||
{ MOD_ACTION_DEFINITIONS.map((action, index) => <option key={ index } value={ index }>{ action.name }</option>) }
|
||||
</select>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Text small>Optional message type, overrides default</Text>
|
||||
<textarea className="min-h-[calc(1.5em+ .5rem+2px)] px-[.5rem] py-[.25rem] rounded-[.2rem]" value={ message } onChange={ event => setMessage(event.target.value) } />
|
||||
<NitroCardView className="nitro-mod-tools-user-action min-w-[420px] max-w-[460px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
|
||||
<NitroCardHeaderView headerText={ `Mod Action: ${ user.username }` } onCloseClick={ () => onCloseClick() } />
|
||||
<NitroCardContentView className="text-black" gap={ 2 }>
|
||||
{/* Target header */}
|
||||
<div className="flex items-center gap-2 bg-gradient-to-r from-rose-50 to-transparent rounded p-2 border border-rose-100">
|
||||
<FaGavel className="text-rose-600 shrink-0" size={ 16 } />
|
||||
<div className="flex flex-col grow min-w-0">
|
||||
<div className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">Sanctioning</div>
|
||||
<div className="font-semibold leading-tight truncate">{ user.username }</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CFH topic */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">1. CFH Topic</label>
|
||||
<select className="form-select form-select-sm" value={ selectedTopic } onChange={ event => setSelectedTopic(parseInt(event.target.value)) }>
|
||||
<option disabled value={ -1 }>Select a topic…</option>
|
||||
{ topics.map((topic, index) => <option key={ index } value={ index }>{ LocalizeText('help.cfh.topic.' + topic.id) }</option>) }
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Sanction type */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">2. Sanction</label>
|
||||
<select className="form-select form-select-sm" value={ selectedAction } onChange={ event => setSelectedAction(parseInt(event.target.value)) }>
|
||||
<option disabled value={ -1 }>Select a sanction…</option>
|
||||
{ MOD_ACTION_DEFINITIONS.map((action, index) => <option key={ index } value={ index }>{ action.name }</option>) }
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">3. Custom message <span className="opacity-50 normal-case font-normal">(optional — overrides default)</span></label>
|
||||
<textarea
|
||||
className="min-h-[60px] px-2 py-1.5 rounded text-sm border border-zinc-300 focus:outline-none focus:ring-2 focus:ring-rose-300"
|
||||
placeholder="Leave empty to use the default topic message"
|
||||
value={ message }
|
||||
onChange={ event => setMessage(event.target.value) }
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{ (selectedSanction || selectedTopicName) &&
|
||||
<div className="flex flex-col gap-1 bg-zinc-50 border border-zinc-200 rounded p-2">
|
||||
<div className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">Preview</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{ selectedTopicName &&
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border bg-white border-zinc-200">
|
||||
{ selectedTopicName }
|
||||
</span> }
|
||||
{ selectedSanction &&
|
||||
<span className={ `inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${ sanctionTone }` }>
|
||||
{ sanctionIcon } { selectedSanction.name }
|
||||
</span> }
|
||||
</div>
|
||||
</div> }
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-1.5 pt-1 border-t border-zinc-200">
|
||||
<Button className="grow" disabled={ !canSubmit } gap={ 1 } variant="primary" onClick={ sendDefaultSanction }>
|
||||
<FaBolt size={ 12 } /> Default Sanction
|
||||
</Button>
|
||||
<Button className="grow" disabled={ !canSubmit || selectedAction === -1 } gap={ 1 } variant="success" onClick={ sendSanction }>
|
||||
<FaGavel size={ 12 } /> Apply Sanction
|
||||
</Button>
|
||||
</div>
|
||||
<Flex gap={ 1 } justifyContent="between">
|
||||
<Button variant="primary" onClick={ sendDefaultSanction }>Default Sanction</Button>
|
||||
<Button variant="success" onClick={ sendSanction }>Sanction</Button>
|
||||
</Flex>
|
||||
</NitroCardContentView>
|
||||
</NitroCardView>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user