Files
Nitro-V3/src/components/mod-tools/views/user/ModToolsUserModActionView.tsx
T
simoleo89 75815fa022 i18n(mod-tools): route every label/title/placeholder through LocalizeText
The ModTools template refresh introduced ~80 hardcoded English strings
(labels, placeholders, tooltips, empty-state copy, button text). Move
every one of them onto the modtools.* namespace and read via
LocalizeText so the panels translate alongside the rest of the client.

UITexts.example (versioned template) extended with the full set:

  modtools.window.*            Launcher box (toolbar item, tools,
                               selected-user state, ticket count)
  modtools.userinfo.*          User info card — already had the
                               legacy modtools.userinfo.{userName,
                               cfhCount, …} keys from before; added
                               refresh tooltip, presence pill labels
                               (in_room / online / offline with
                               matching .title tooltips), section
                               headings, action button labels, stat
                               card labels
  modtools.roominfo.*          Room info card — title, refresh, loading,
                               owner pill (here/away + tooltips), stat
                               labels, action buttons, moderate panel
                               heading + checkboxes + textarea
                               placeholder + caution/alert CTAs
  modtools.user.message.*      Send-message dialog (recipient label,
                               body label, placeholder, char counter,
                               empty state, send button)
  modtools.user.modaction.*    Mod Action form — header, sanctioning
                               label, 3-step section titles, select
                               placeholders, message label + optional
                               note, message placeholder, preview
                               heading, default/apply buttons, every
                               sendAlert error message
  modtools.user.visits.*       Room visits — title, header strip
                               heading, entry count (singular/plural),
                               empty state, column headers, visit
                               button + tooltip
  modtools.user.chatlog.*      User chatlog — title (with username
                               variant), loading state
  modtools.room.chatlog.*      Room chatlog title
  modtools.chatlog.*           Shared ChatlogView — column headers,
                               empty state, room-separator Visit/Tools
                               buttons
  modtools.tickets.*           Tickets window — title, tab labels
                               (open/mine/picked), column headers,
                               empty states, action buttons (pick/
                               handle/release), issue resolution
                               window (title, label, details heading,
                               field labels, chatlog toggle, resolve-as
                               heading, resolution buttons, release
                               back to queue), CFH chatlog title

The same 130 entries land in Nitro-Files/.../UITexts.json (runtime).
Both files validate as JSON. The runtime additions take effect on
next client reload; the template additions ship the strings to any
fresh deploy.

Notes:
  - The MOD_ACTION_DEFINITIONS sanction names ("Alert", "Mute 1h",
    "Ban 18h" …) stay hardcoded for now since they're keyed off
    server-side action IDs that don't have an existing locale key
    convention. Worth a follow-up if needed.
  - help.cfh.topic.* keys (CFH topic display names) are already in
    ExternalTexts.json and were already read via LocalizeText, so
    they didn't need changes.

typecheck + vitest 214/214 + lint:hooks all clean.
2026-05-20 21:41:52 +02:00

223 lines
13 KiB
TypeScript

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, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useModTools, useNotification } from '../../../../hooks';
interface ModToolsUserModActionViewProps
{
user: ISelectedUser;
onCloseClick: () => void;
}
const MOD_ACTION_DEFINITIONS = [
new ModActionDefinition(1, 'Alert', ModActionDefinition.ALERT, 1, 0),
new ModActionDefinition(2, 'Mute 1h', ModActionDefinition.MUTE, 2, 0),
new ModActionDefinition(3, 'Ban 18h', ModActionDefinition.BAN, 3, 0),
new ModActionDefinition(4, 'Ban 7 days', ModActionDefinition.BAN, 4, 0),
new ModActionDefinition(5, 'Ban 30 days (step 1)', ModActionDefinition.BAN, 5, 0),
new ModActionDefinition(7, 'Ban 30 days (step 2)', ModActionDefinition.BAN, 7, 0),
new ModActionDefinition(6, 'Ban 100 years', ModActionDefinition.BAN, 6, 0),
new ModActionDefinition(106, 'Ban avatar-only 100 years', ModActionDefinition.BAN, 6, 0),
new ModActionDefinition(101, 'Kick', ModActionDefinition.KICK, 0, 0),
new ModActionDefinition(102, 'Lock trade 1 week', ModActionDefinition.TRADE_LOCK, 0, 168),
new ModActionDefinition(104, 'Lock trade permanent', ModActionDefinition.TRADE_LOCK, 0, 876000),
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;
const [ selectedTopic, setSelectedTopic ] = useState(-1);
const [ selectedAction, setSelectedAction ] = useState(-1);
const [ message, setMessage ] = useState<string>('');
const { cfhCategories = null, settings = null } = useModTools();
const { simpleAlert = null } = useNotification();
const isSendingRef = useRef<boolean>(false);
const topics = useMemo(() =>
{
const values: CallForHelpTopicData[] = [];
if(cfhCategories && cfhCategories.length)
{
for(const category of cfhCategories)
{
for(const topic of category.topics) values.push(topic);
}
}
return values;
}, [ cfhCategories ]);
const sendAlert = (m: string) => simpleAlert(m, NotificationAlertType.DEFAULT, null, null, 'Error');
const sendDefaultSanction = () =>
{
if(isSendingRef.current) return;
const category = topics[selectedTopic];
if(selectedTopic === -1) return sendAlert(LocalizeText('modtools.user.modaction.error.no.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();
};
const sendSanction = () =>
{
if(isSendingRef.current) return;
let errorMessage: string = null;
const category = topics[selectedTopic];
const sanction = MOD_ACTION_DEFINITIONS[selectedAction];
if((selectedTopic === -1) || (selectedAction === -1)) errorMessage = LocalizeText('modtools.user.modaction.error.no.action');
else if(!settings || !settings.cfhPermission) errorMessage = LocalizeText('modtools.user.modaction.error.no.permission');
else if(!category) errorMessage = LocalizeText('modtools.user.modaction.error.no.topic');
else if(!sanction) errorMessage = LocalizeText('modtools.user.modaction.error.no.action');
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) return sendAlert(LocalizeText('modtools.user.modaction.error.no.permission.alert'));
SendMessageComposer(new ModAlertMessageComposer(user.userId, messageOrDefault, category.id));
break;
}
case ModActionDefinition.MUTE:
SendMessageComposer(new ModMuteMessageComposer(user.userId, messageOrDefault, category.id));
break;
case ModActionDefinition.BAN: {
if(!settings.banPermission) return sendAlert(LocalizeText('modtools.user.modaction.error.no.permission.alert'));
SendMessageComposer(new ModBanMessageComposer(user.userId, messageOrDefault, category.id, selectedAction, (sanction.actionId === 106)));
break;
}
case ModActionDefinition.KICK: {
if(!settings.kickPermission) return sendAlert(LocalizeText('modtools.user.modaction.error.no.permission.alert'));
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) return sendAlert(LocalizeText('modtools.user.modaction.error.no.message'));
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 min-w-[420px] max-w-[460px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ LocalizeText('modtools.user.modaction.title', [ 'username' ], [ 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">{ 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)) }>
<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>) }
</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)) }>
<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>) }
</select>
</div>
{/* Message */}
<div className="flex flex-col gap-1">
<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>
</NitroCardView>
);
};