mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 23:16:21 +00:00
feat(housekeeping): in-client admin panel
Adds the Housekeeping in-client admin panel — a Modtools-adjacent
surface that runs entirely inside the React client, talking to the
emulator over the existing wire instead of a separate REST/CMS layer.
Surface:
- `src/components/housekeeping/` — panel shell + 5 tabs (Dashboard,
Users, Rooms, Economy, Audit). Each tab drives one domain of the
matching emulator handlers (find/sanction/admin/economy/catalog/
hotel-wide).
- `src/api/housekeeping/` — composer/parser orchestration:
`HousekeepingApi.ts` exposes 30+ typed actions, each one running
through `runHkAction()` which awaits the shared
`HousekeepingActionResultEvent` correlated by action key.
- `src/hooks/housekeeping/` — `useHousekeeping` (the public hook),
`useHousekeepingStore` (useBetween singleton: shared selection +
audit polling + sanction templates), `useHousekeepingActions`,
`useHousekeepingConfirm`.
- `src/api/nitro/awaitMessageEvent.ts` — Promise adapter over
`CommunicationManager.subscribeMessage` with a sync `select`
callback that snapshots the parser INSIDE the subscribe handler
before the renderer recycles the parser instance after the
Promise resolves.
- `public/configuration/housekeeping-texts-{en,it}.example` —
149 EN + 149 IT i18n keys under `housekeeping.*` for every panel
string + every server-side error slug the emulator may emit.
Wiring (additive only):
- `src/components/MainView.tsx` — `<HousekeepingView />` mounted
alongside `<ModToolsView />`.
- `src/api/index.ts`, `src/hooks/index.ts`, `src/api/nitro/index.ts`
— added the `housekeeping` and `awaitMessageEvent` re-exports.
Wire contract: pairs against the Arcturus PR (#120 on
duckietm/Arcturus-Morningstar-Extended) and the renderer PR (#77 on
duckietm/Nitro_Render_V3). Incoming events 9100..9129, outgoing
composers 9200..9207. Permission gate `acc_housekeeping` enforced
server-side; the panel is hidden client-side via
`housekeeping.enabled` in the runtime ui-config.
This commit is contained in:
@@ -22,6 +22,7 @@ import { GuideToolView } from './guide-tool/GuideToolView';
|
||||
import { HcCenterView } from './hc-center/HcCenterView';
|
||||
import { HelpView } from './help/HelpView';
|
||||
import { HotelView } from './hotel-view/HotelView';
|
||||
import { HousekeepingView } from './housekeeping/HousekeepingView';
|
||||
import { InventoryView } from './inventory/InventoryView';
|
||||
import { ModToolsView } from './mod-tools/ModToolsView';
|
||||
import { NavigatorView } from './navigator/NavigatorView';
|
||||
@@ -141,6 +142,7 @@ export const MainView: FC<{}> = props =>
|
||||
<TranslationBootstrap />
|
||||
<GoogleAdsView />
|
||||
<ModToolsView />
|
||||
<HousekeepingView />
|
||||
<WiredCreatorToolsView />
|
||||
<RoomView />
|
||||
<ChatHistoryView />
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { FC, useEffect } from 'react';
|
||||
import { FaCheckCircle, FaExclamationTriangle, FaTimes } from 'react-icons/fa';
|
||||
import { LocalizeText } from '../../api';
|
||||
import { useHousekeepingStore } from '../../hooks';
|
||||
|
||||
const localizeOrPassthrough = (key: string | null): string =>
|
||||
{
|
||||
if(!key) return '';
|
||||
if(!key.includes('.')) return key;
|
||||
|
||||
const localized = LocalizeText(key);
|
||||
|
||||
return (localized === key) ? key : localized;
|
||||
};
|
||||
|
||||
const AUTO_DISMISS_MS = 4000;
|
||||
|
||||
export const HousekeepingStatusBanner: FC = () =>
|
||||
{
|
||||
const { lastError, lastSuccess, clearStatus, isActionPending } = useHousekeepingStore();
|
||||
const visible = !!(lastError || lastSuccess);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!lastSuccess) return;
|
||||
|
||||
const handle = window.setTimeout(() => clearStatus(), AUTO_DISMISS_MS);
|
||||
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [ lastSuccess, clearStatus ]);
|
||||
|
||||
if(!visible && !isActionPending) return null;
|
||||
|
||||
if(isActionPending && !visible)
|
||||
{
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2 py-1 text-xs bg-zinc-100 border-y border-zinc-200 text-zinc-700">
|
||||
<span className="inline-block h-3 w-3 rounded-full border-2 border-zinc-400 border-t-transparent animate-spin" />
|
||||
<span>{ LocalizeText('housekeeping.action.pending') }</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isError = !!lastError;
|
||||
const message = localizeOrPassthrough(lastError ?? lastSuccess);
|
||||
const tone = isError
|
||||
? 'bg-rose-100 border-rose-200 text-rose-900'
|
||||
: 'bg-emerald-100 border-emerald-200 text-emerald-900';
|
||||
const Icon = isError ? FaExclamationTriangle : FaCheckCircle;
|
||||
|
||||
return (
|
||||
<div className={ `flex items-center gap-2 px-2 py-1 text-xs border-y ${ tone }` } role="status">
|
||||
<Icon size={ 12 } />
|
||||
<span className="grow truncate">{ message }</span>
|
||||
<button
|
||||
className="inline-flex items-center justify-center w-5 h-5 rounded hover:bg-black/10"
|
||||
onClick={ () => clearStatus() }
|
||||
title={ LocalizeText('housekeeping.status.dismiss') }>
|
||||
<FaTimes size={ 10 } />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,207 @@
|
||||
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useMemo } from 'react';
|
||||
import { getHousekeepingMode, HousekeepingTabId, isHousekeepingEnabled, isHousekeepingTabAvailable, LocalizeText } from '../../api';
|
||||
import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, WidgetErrorBoundary } from '../../common';
|
||||
import { useHasPermission, useHousekeepingStore } from '../../hooks';
|
||||
import { HousekeepingStatusBanner } from './HousekeepingStatusBanner';
|
||||
import { HousekeepingAuditTab } from './views/audit/HousekeepingAuditTab';
|
||||
import { HousekeepingDashboardTab } from './views/dashboard/HousekeepingDashboardTab';
|
||||
import { HousekeepingEconomyTab } from './views/economy/HousekeepingEconomyTab';
|
||||
import { HousekeepingRoomsTab } from './views/rooms/HousekeepingRoomsTab';
|
||||
import { HousekeepingUsersTab } from './views/users/HousekeepingUsersTab';
|
||||
|
||||
const TAB_IDS: HousekeepingTabId[] = [
|
||||
HousekeepingTabId.DASHBOARD,
|
||||
HousekeepingTabId.USERS,
|
||||
HousekeepingTabId.ROOMS,
|
||||
HousekeepingTabId.ECONOMY,
|
||||
HousekeepingTabId.AUDIT
|
||||
];
|
||||
|
||||
const isHkTabId = (value: string): value is HousekeepingTabId =>
|
||||
(TAB_IDS as string[]).includes(value);
|
||||
|
||||
export const HousekeepingView: FC = () =>
|
||||
{
|
||||
const { isVisible, setIsVisible, togglePanel, activeTab, setActiveTab, closePanel, lookupUserById, seedUserFromAvatar } = useHousekeepingStore();
|
||||
// Gate behind a dedicated HK permission so the panel stays hidden
|
||||
// for plain users/mods on servers that haven't granted it. Reactive
|
||||
// — promote/demote takes effect on the next render without a relog.
|
||||
const isHk = useHasPermission('acc_housekeeping');
|
||||
// Two-layer config gate on top of the permission:
|
||||
// - `housekeeping.enabled` (boolean, default false): master kill
|
||||
// switch for the whole module
|
||||
// - `housekeeping.mode` ("light" | "full", default "full"):
|
||||
// "light" exposes only Users + Rooms (essential moderation),
|
||||
// "full" exposes all six tabs
|
||||
// Config is read after `await GetConfiguration().init()` in
|
||||
// bootstrap.ts, so by the time React mounts we're reading a
|
||||
// populated value — no Suspense needed.
|
||||
const hkEnabled = useMemo(() => isHousekeepingEnabled(), []);
|
||||
const hkMode = useMemo(() => getHousekeepingMode(), []);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const linkTracker: ILinkEventTracker = {
|
||||
linkReceived: (url: string) =>
|
||||
{
|
||||
const parts = url.split('/');
|
||||
|
||||
if(parts.length < 2) return;
|
||||
|
||||
switch(parts[1])
|
||||
{
|
||||
case 'show':
|
||||
setIsVisible(true);
|
||||
return;
|
||||
case 'hide':
|
||||
closePanel();
|
||||
return;
|
||||
case 'toggle':
|
||||
togglePanel();
|
||||
return;
|
||||
case 'tab':
|
||||
if(parts.length > 2)
|
||||
{
|
||||
const candidate = parts[2];
|
||||
|
||||
if(isHkTabId(candidate) && isHousekeepingTabAvailable(candidate, getHousekeepingMode()))
|
||||
{
|
||||
setActiveTab(candidate);
|
||||
setIsVisible(true);
|
||||
}
|
||||
}
|
||||
return;
|
||||
case 'user':
|
||||
// housekeeping/user/<id>[/<name>/<figure>] — used by the
|
||||
// in-room context menu to push a target into the HK
|
||||
// panel and jump to the Users tab. When the optional
|
||||
// name + figure segments are present the panel paints
|
||||
// them synchronously (so the operator sees the target
|
||||
// even if the find-by-id packet is slow / unhandled),
|
||||
// and the background lookup enriches the rest. The
|
||||
// segments are URI-encoded so usernames with spaces or
|
||||
// figures with special chars survive the link round-trip.
|
||||
if(parts.length > 2)
|
||||
{
|
||||
const userId = parseInt(parts[2]);
|
||||
|
||||
if(Number.isFinite(userId) && userId > 0)
|
||||
{
|
||||
setActiveTab(HousekeepingTabId.USERS);
|
||||
setIsVisible(true);
|
||||
|
||||
if(parts.length > 4)
|
||||
{
|
||||
let name = '';
|
||||
let figure = '';
|
||||
|
||||
try { name = decodeURIComponent(parts[3] || ''); } catch { name = parts[3] || ''; }
|
||||
try { figure = decodeURIComponent(parts[4] || ''); } catch { figure = parts[4] || ''; }
|
||||
|
||||
seedUserFromAvatar(userId, name, figure);
|
||||
}
|
||||
|
||||
lookupUserById(userId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
},
|
||||
eventUrlPrefix: 'housekeeping/'
|
||||
};
|
||||
|
||||
AddLinkEventTracker(linkTracker);
|
||||
|
||||
return () => RemoveLinkEventTracker(linkTracker);
|
||||
}, [ setIsVisible, togglePanel, closePanel, setActiveTab, lookupUserById, seedUserFromAvatar ]);
|
||||
|
||||
// When the panel is gated off (perm revoked mid-session, or
|
||||
// `housekeeping.enabled` is false) make sure it isn't left visible.
|
||||
useEffect(() =>
|
||||
{
|
||||
if((!isHk || !hkEnabled) && isVisible) closePanel();
|
||||
}, [ isHk, hkEnabled, isVisible, closePanel ]);
|
||||
|
||||
// If light mode is active and the user is parked on a tab that
|
||||
// light doesn't expose (e.g. they switched modes between
|
||||
// sessions), bounce them to Users — the canonical default for
|
||||
// the trimmed layout.
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!isHousekeepingTabAvailable(activeTab, hkMode)) setActiveTab(HousekeepingTabId.USERS);
|
||||
}, [ activeTab, hkMode, setActiveTab ]);
|
||||
|
||||
const activeView = useMemo(() =>
|
||||
{
|
||||
switch(activeTab)
|
||||
{
|
||||
case HousekeepingTabId.ROOMS: return <HousekeepingRoomsTab />;
|
||||
case HousekeepingTabId.ECONOMY: return <HousekeepingEconomyTab />;
|
||||
case HousekeepingTabId.AUDIT: return <HousekeepingAuditTab />;
|
||||
case HousekeepingTabId.USERS: return <HousekeepingUsersTab />;
|
||||
case HousekeepingTabId.DASHBOARD:
|
||||
default:
|
||||
return <HousekeepingDashboardTab />;
|
||||
}
|
||||
}, [ activeTab ]);
|
||||
|
||||
if(!hkEnabled || !isHk || !isVisible) return null;
|
||||
|
||||
const showDashboard = isHousekeepingTabAvailable(HousekeepingTabId.DASHBOARD, hkMode);
|
||||
const showEconomy = isHousekeepingTabAvailable(HousekeepingTabId.ECONOMY, hkMode);
|
||||
const showAudit = isHousekeepingTabAvailable(HousekeepingTabId.AUDIT, hkMode);
|
||||
const isLight = hkMode === 'light';
|
||||
const headerSuffix = isLight ? ` · ${ LocalizeText('housekeeping.mode.light') }` : '';
|
||||
// Light mode is narrower because there are only 2 tabs and the
|
||||
// content density is lower — gives the operator more screen real
|
||||
// estate without a 600px-wide panel for two tabs.
|
||||
const sizeClass = isLight ? 'min-w-[420px] max-w-[480px]' : 'min-w-[520px] max-w-[600px]';
|
||||
|
||||
return (
|
||||
<WidgetErrorBoundary name="HousekeepingView">
|
||||
<NitroCardView className={ `nitro-housekeeping ${ sizeClass }` } theme="primary-slim" uniqueKey="housekeeping" windowPosition={ DraggableWindowPosition.TOP_CENTER }>
|
||||
<NitroCardHeaderView headerText={ `${ LocalizeText('housekeeping.title') }${ headerSuffix }` } onCloseClick={ () => closePanel() } />
|
||||
<NitroCardTabsView>
|
||||
{ showDashboard &&
|
||||
<NitroCardTabsItemView isActive={ activeTab === HousekeepingTabId.DASHBOARD } onClick={ () => setActiveTab(HousekeepingTabId.DASHBOARD) }>
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="nitro-icon nitro-icon-hk-tab icon-housekeeping" />
|
||||
<span>{ LocalizeText('housekeeping.tab.dashboard') }</span>
|
||||
</div>
|
||||
</NitroCardTabsItemView> }
|
||||
<NitroCardTabsItemView isActive={ activeTab === HousekeepingTabId.USERS } onClick={ () => setActiveTab(HousekeepingTabId.USERS) }>
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="nitro-icon nitro-icon-hk-tab icon-modtools" />
|
||||
<span>{ LocalizeText('housekeeping.tab.users') }</span>
|
||||
</div>
|
||||
</NitroCardTabsItemView>
|
||||
<NitroCardTabsItemView isActive={ activeTab === HousekeepingTabId.ROOMS } onClick={ () => setActiveTab(HousekeepingTabId.ROOMS) }>
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="nitro-icon nitro-icon-hk-tab icon-rooms" />
|
||||
<span>{ LocalizeText('housekeeping.tab.rooms') }</span>
|
||||
</div>
|
||||
</NitroCardTabsItemView>
|
||||
{ showEconomy &&
|
||||
<NitroCardTabsItemView isActive={ activeTab === HousekeepingTabId.ECONOMY } onClick={ () => setActiveTab(HousekeepingTabId.ECONOMY) }>
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="nitro-icon nitro-icon-hk-tab icon-catalog" />
|
||||
<span>{ LocalizeText('housekeeping.tab.economy') }</span>
|
||||
</div>
|
||||
</NitroCardTabsItemView> }
|
||||
{ showAudit &&
|
||||
<NitroCardTabsItemView isActive={ activeTab === HousekeepingTabId.AUDIT } onClick={ () => setActiveTab(HousekeepingTabId.AUDIT) }>
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="nitro-icon nitro-icon-hk-tab icon-message" />
|
||||
<span>{ LocalizeText('housekeeping.tab.audit') }</span>
|
||||
</div>
|
||||
</NitroCardTabsItemView> }
|
||||
</NitroCardTabsView>
|
||||
<HousekeepingStatusBanner />
|
||||
<NitroCardContentView className="text-black" gap={ 2 }>
|
||||
{ activeView }
|
||||
</NitroCardContentView>
|
||||
</NitroCardView>
|
||||
</WidgetErrorBoundary>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,224 @@
|
||||
import { FC, useMemo, useState } from 'react';
|
||||
import { FaCaretDown, FaCaretRight, FaCheck, FaExclamationCircle, FaFilter, FaStopwatch, FaSync, FaTrash } from 'react-icons/fa';
|
||||
import { formatRelativePast, GetConfigurationValue, IHousekeepingActionLogEntry, LocalizeText, sampleToMetric } from '../../../../api';
|
||||
import { Button } from '../../../../common';
|
||||
import { useHousekeepingStore, useLocalStorage } from '../../../../hooks';
|
||||
|
||||
type TargetFilter = 'all' | 'user' | 'room' | 'hotel';
|
||||
type SuccessFilter = 'all' | 'success' | 'failure';
|
||||
|
||||
const FILTER_LABELS: Record<TargetFilter, string> = {
|
||||
all: 'housekeeping.audit.filter.all',
|
||||
user: 'housekeeping.audit.filter.users',
|
||||
room: 'housekeeping.audit.filter.rooms',
|
||||
hotel: 'housekeeping.audit.filter.hotel'
|
||||
};
|
||||
|
||||
const passesFilter = (entry: IHousekeepingActionLogEntry, target: TargetFilter, success: SuccessFilter, query: string): boolean =>
|
||||
{
|
||||
if(target !== 'all' && entry.targetType !== target) return false;
|
||||
if(success === 'success' && !entry.success) return false;
|
||||
if(success === 'failure' && entry.success) return false;
|
||||
|
||||
if(query.length > 0)
|
||||
{
|
||||
const haystack = `${ entry.actorName } ${ entry.targetLabel } ${ entry.action } ${ entry.detail }`.toLowerCase();
|
||||
|
||||
if(!haystack.includes(query)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const HousekeepingAuditTab: FC = () =>
|
||||
{
|
||||
const { actionLog, refreshAuditLog, metricsByAction, resetActionMetrics } = useHousekeepingStore();
|
||||
// Gated behind a UI-config flag (off by default) so non-debug
|
||||
// operators don't see internal latency stats. The flag is read
|
||||
// once at mount — flipping the config requires a reload, same
|
||||
// as every other UI-config gate.
|
||||
const telemetryEnabled = useMemo(() => GetConfigurationValue<boolean>('housekeeping.telemetry.enabled', false) === true, []);
|
||||
const [ isTelemetryExpanded, setIsTelemetryExpanded ] = useState(false);
|
||||
// Filter state is persisted per user so an HK who's iterating on
|
||||
// "show me failures only, filtered to 'spam'" doesn't reset every
|
||||
// time they switch tabs or close the panel.
|
||||
const [ targetFilter, setTargetFilter ] = useLocalStorage<TargetFilter>('nitro.housekeeping.audit.target_filter', 'all');
|
||||
const [ successFilter, setSuccessFilter ] = useLocalStorage<SuccessFilter>('nitro.housekeeping.audit.success_filter', 'all');
|
||||
const [ query, setQuery ] = useLocalStorage<string>('nitro.housekeeping.audit.query', '');
|
||||
const [ isRefreshing, setIsRefreshing ] = useState(false);
|
||||
|
||||
const filtered = useMemo(() =>
|
||||
{
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
|
||||
return actionLog.filter(entry => passesFilter(entry, targetFilter, successFilter, normalizedQuery));
|
||||
}, [ actionLog, targetFilter, successFilter, query ]);
|
||||
|
||||
const refresh = async () =>
|
||||
{
|
||||
setIsRefreshing(true);
|
||||
|
||||
try
|
||||
{
|
||||
await refreshAuditLog();
|
||||
}
|
||||
finally
|
||||
{
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const successCount = useMemo(() => actionLog.filter(e => e.success).length, [ actionLog ]);
|
||||
const failureCount = actionLog.length - successCount;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{ /* Header w/ counts + refresh */ }
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-xs uppercase tracking-wider font-semibold opacity-60 flex items-center gap-1">
|
||||
<FaFilter size={ 10 } />
|
||||
{ LocalizeText('housekeeping.audit.title') }
|
||||
{ actionLog.length > 0 &&
|
||||
<span className="ml-1 inline-flex items-center gap-1 text-[10px] font-normal opacity-80 normal-case">
|
||||
<span className="inline-flex items-center gap-0.5 px-1 rounded bg-emerald-50 border border-emerald-200 text-emerald-700"><FaCheck size={ 6 } />{ successCount }</span>
|
||||
{ failureCount > 0 &&
|
||||
<span className="inline-flex items-center gap-0.5 px-1 rounded bg-rose-50 border border-rose-200 text-rose-700"><FaExclamationCircle size={ 6 } />{ failureCount }</span> }
|
||||
</span> }
|
||||
</h3>
|
||||
<Button size="sm" variant="secondary" disabled={ isRefreshing } onClick={ refresh }>
|
||||
<FaSync size={ 9 } className={ isRefreshing ? 'animate-spin' : '' } />
|
||||
<span className="ml-1">{ LocalizeText('housekeeping.audit.refresh') }</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{ /* Filter row */ }
|
||||
<div className="flex flex-wrap gap-1 items-center rounded-md border border-zinc-200 bg-zinc-50/50 px-1.5 py-1">
|
||||
{ (Object.keys(FILTER_LABELS) as TargetFilter[]).map(filter => (
|
||||
<button
|
||||
key={ filter }
|
||||
className={ `px-2 py-0.5 rounded-full text-[10px] font-medium border transition-colors ${
|
||||
targetFilter === filter
|
||||
? 'bg-sky-100 border-sky-300 text-sky-800 shadow-sm'
|
||||
: 'bg-white border-zinc-200 text-zinc-600 hover:border-zinc-400 hover:bg-zinc-50'
|
||||
}` }
|
||||
onClick={ () => setTargetFilter(filter) }>
|
||||
{ LocalizeText(FILTER_LABELS[filter]) }
|
||||
</button>
|
||||
)) }
|
||||
<span className="mx-1 h-3 w-px bg-zinc-300" />
|
||||
<button
|
||||
className={ `inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border transition-colors ${
|
||||
successFilter === 'success'
|
||||
? 'bg-emerald-100 border-emerald-300 text-emerald-800 shadow-sm'
|
||||
: 'bg-white border-zinc-200 text-zinc-600 hover:border-zinc-400'
|
||||
}` }
|
||||
onClick={ () => setSuccessFilter(successFilter === 'success' ? 'all' : 'success') }>
|
||||
<FaCheck size={ 7 } />ok
|
||||
</button>
|
||||
<button
|
||||
className={ `inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border transition-colors ${
|
||||
successFilter === 'failure'
|
||||
? 'bg-rose-100 border-rose-300 text-rose-800 shadow-sm'
|
||||
: 'bg-white border-zinc-200 text-zinc-600 hover:border-zinc-400'
|
||||
}` }
|
||||
onClick={ () => setSuccessFilter(successFilter === 'failure' ? 'all' : 'failure') }>
|
||||
<FaExclamationCircle size={ 7 } />err
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
className="px-2 py-1 rounded-md border border-zinc-300 bg-white text-xs focus:outline-none focus:ring-1 focus:ring-sky-300 focus:border-sky-400 transition-colors placeholder:text-zinc-400"
|
||||
placeholder={ LocalizeText('housekeeping.audit.search.placeholder') }
|
||||
value={ query }
|
||||
onChange={ event => setQuery(event.target.value) } />
|
||||
|
||||
{ filtered.length === 0
|
||||
? (
|
||||
<div className="flex flex-col items-center gap-1 rounded-lg border border-dashed border-zinc-300 bg-zinc-50/50 py-4 text-xs text-zinc-500">
|
||||
<FaFilter size={ 14 } className="opacity-40" />
|
||||
<span>{ actionLog.length === 0 ? LocalizeText('housekeeping.audit.empty') : LocalizeText('housekeeping.audit.no_match') }</span>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<ul className="flex flex-col gap-0.5 max-h-[300px] overflow-y-auto pr-1">
|
||||
{ filtered.map(entry => (
|
||||
<li
|
||||
key={ entry.id }
|
||||
className={ `flex items-center gap-2 text-[11px] px-2 py-1 rounded border transition-colors ${
|
||||
entry.success ? 'border-zinc-200 bg-white hover:bg-zinc-50' : 'border-rose-200 bg-rose-50/60 hover:bg-rose-50'
|
||||
}` }>
|
||||
<span className="text-zinc-400 tabular-nums w-14 shrink-0">
|
||||
{ formatRelativePast(entry.timestamp) }
|
||||
</span>
|
||||
<span className="font-semibold truncate w-24 shrink-0" title={ entry.actorName }>
|
||||
{ entry.actorName }
|
||||
</span>
|
||||
<span className="text-zinc-400 shrink-0">→</span>
|
||||
<span className="truncate grow" title={ entry.targetLabel }>
|
||||
<span className={ `inline-block px-1 mr-1 rounded text-[9px] uppercase font-bold ${ entry.targetType === 'user' ? 'bg-sky-100 text-sky-700' : entry.targetType === 'room' ? 'bg-violet-100 text-violet-700' : 'bg-amber-100 text-amber-700' }` }>{ entry.targetType }</span>
|
||||
{ entry.targetLabel }
|
||||
</span>
|
||||
<span className={ `shrink-0 px-1.5 py-0.5 rounded text-[10px] font-medium ${ entry.success ? 'bg-zinc-100 text-zinc-700' : 'bg-rose-100 text-rose-700' }` }>
|
||||
{ entry.action }
|
||||
</span>
|
||||
</li>
|
||||
)) }
|
||||
</ul>
|
||||
) }
|
||||
|
||||
{ telemetryEnabled &&
|
||||
<div className="rounded border border-zinc-200 bg-zinc-50">
|
||||
<button
|
||||
className="w-full flex items-center gap-1.5 px-2 py-1 text-[11px] uppercase font-semibold opacity-70 hover:bg-zinc-100"
|
||||
onClick={ () => setIsTelemetryExpanded(value => !value) }>
|
||||
{ isTelemetryExpanded ? <FaCaretDown size={ 10 } /> : <FaCaretRight size={ 10 } /> }
|
||||
<FaStopwatch size={ 10 } />
|
||||
<span className="grow text-left">{ LocalizeText('housekeeping.telemetry.title') }</span>
|
||||
<span className="text-zinc-500 tabular-nums">{ metricsByAction.size }</span>
|
||||
</button>
|
||||
{ isTelemetryExpanded &&
|
||||
<div className="px-2 py-1 border-t border-zinc-200">
|
||||
{ metricsByAction.size === 0
|
||||
? <div className="text-[10px] text-zinc-500 italic py-1">{ LocalizeText('housekeeping.telemetry.empty') }</div>
|
||||
: (
|
||||
<table className="w-full text-[10px] tabular-nums">
|
||||
<thead>
|
||||
<tr className="text-zinc-500 uppercase">
|
||||
<th className="text-left font-medium">action</th>
|
||||
<th className="text-right font-medium">n</th>
|
||||
<th className="text-right font-medium">err</th>
|
||||
<th className="text-right font-medium">last</th>
|
||||
<th className="text-right font-medium">p50</th>
|
||||
<th className="text-right font-medium">p95</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ [ ...metricsByAction.entries() ]
|
||||
.map(([ action, sample ]) => sampleToMetric(action, sample))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.map(metric => (
|
||||
<tr key={ metric.action } className="border-t border-zinc-100">
|
||||
<td className="text-left truncate" title={ metric.action }>{ metric.action }</td>
|
||||
<td className="text-right">{ metric.count }</td>
|
||||
<td className={ `text-right ${ metric.errors > 0 ? 'text-rose-700 font-semibold' : 'text-zinc-500' }` }>{ metric.errors }</td>
|
||||
<td className="text-right">{ Math.round(metric.lastMs) }ms</td>
|
||||
<td className="text-right">{ Math.round(metric.p50Ms) }ms</td>
|
||||
<td className="text-right">{ Math.round(metric.p95Ms) }ms</td>
|
||||
</tr>
|
||||
)) }
|
||||
</tbody>
|
||||
</table>
|
||||
) }
|
||||
<div className="flex items-center justify-end pt-1">
|
||||
<button
|
||||
className="inline-flex items-center gap-1 text-[10px] text-zinc-500 hover:text-rose-700"
|
||||
onClick={ resetActionMetrics }>
|
||||
<FaTrash size={ 8 } />
|
||||
{ LocalizeText('housekeeping.telemetry.reset') }
|
||||
</button>
|
||||
</div>
|
||||
</div> }
|
||||
</div> }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { HousekeepingDashboardTab } from './HousekeepingDashboardTab';
|
||||
|
||||
const storeState: any = {
|
||||
dashboard: null,
|
||||
isDashboardLoading: false,
|
||||
refreshDashboard: vi.fn(),
|
||||
actionLog: [],
|
||||
recentLookups: [],
|
||||
lookupUserById: vi.fn(),
|
||||
lookupRoomById: vi.fn(),
|
||||
setActiveTab: vi.fn()
|
||||
};
|
||||
|
||||
const notificationState: any = {
|
||||
simpleAlert: vi.fn()
|
||||
};
|
||||
|
||||
vi.mock('../../../../hooks', () => ({
|
||||
useHousekeepingStore: () => storeState,
|
||||
useNotification: () => notificationState
|
||||
}));
|
||||
|
||||
vi.mock('../../../../api', () =>
|
||||
{
|
||||
return {
|
||||
LocalizeText: (key: string) => key,
|
||||
formatCompactNumber: (value: number) => Number.isFinite(value) ? String(value) : '—',
|
||||
formatRelativePast: () => 'now',
|
||||
formatUptime: (value: number) => Number.isFinite(value) ? `${ value }s` : '—',
|
||||
HousekeepingApi: {
|
||||
sendHotelAlert: vi.fn(() => Promise.resolve({ ok: true, actionId: null, message: '' }))
|
||||
},
|
||||
HousekeepingTabId: { DASHBOARD: 'dashboard', USERS: 'users', ROOMS: 'rooms', ECONOMY: 'economy', AUDIT: 'audit' },
|
||||
NotificationBubbleType: { INFO: 'INFO' }
|
||||
};
|
||||
});
|
||||
|
||||
const resetStore = () =>
|
||||
{
|
||||
storeState.dashboard = null;
|
||||
storeState.isDashboardLoading = false;
|
||||
storeState.refreshDashboard = vi.fn();
|
||||
storeState.actionLog = [];
|
||||
storeState.recentLookups = [];
|
||||
storeState.lookupUserById = vi.fn();
|
||||
storeState.lookupRoomById = vi.fn();
|
||||
storeState.setActiveTab = vi.fn();
|
||||
};
|
||||
|
||||
describe('HousekeepingDashboardTab', () =>
|
||||
{
|
||||
beforeEach(() => resetStore());
|
||||
|
||||
afterEach(() =>
|
||||
{
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders skeleton placeholders when loading with no data yet', () =>
|
||||
{
|
||||
storeState.isDashboardLoading = true;
|
||||
|
||||
const { container } = render(<HousekeepingDashboardTab />);
|
||||
|
||||
expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('renders the unavailable banner when not loading and no data', () =>
|
||||
{
|
||||
render(<HousekeepingDashboardTab />);
|
||||
|
||||
expect(screen.getByText('housekeeping.dashboard.unavailable')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders the hero + stat grid when dashboard data is present', () =>
|
||||
{
|
||||
storeState.dashboard = {
|
||||
onlineUsers: 42,
|
||||
totalUsers: 1000,
|
||||
activeRooms: 7,
|
||||
totalRooms: 200,
|
||||
peakOnlineToday: 80,
|
||||
peakOnlineAllTime: 250,
|
||||
pendingTickets: 3,
|
||||
sanctionsLast24h: 5,
|
||||
serverUptimeSeconds: 3600,
|
||||
serverVersion: 'arcturus-x'
|
||||
};
|
||||
|
||||
render(<HousekeepingDashboardTab />);
|
||||
|
||||
expect(screen.getByText('42')).toBeTruthy();
|
||||
expect(screen.getByText('7')).toBeTruthy();
|
||||
expect(screen.getByText('80')).toBeTruthy();
|
||||
expect(screen.getByText('3')).toBeTruthy();
|
||||
expect(screen.getByText('arcturus-x')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders the recent-sanctions section when audit log has successful user actions', () =>
|
||||
{
|
||||
storeState.dashboard = {
|
||||
onlineUsers: 1, totalUsers: 1, activeRooms: 1, totalRooms: 1, peakOnlineToday: 1, peakOnlineAllTime: 1,
|
||||
pendingTickets: 0, sanctionsLast24h: 0, serverUptimeSeconds: 0, serverVersion: 'x'
|
||||
};
|
||||
storeState.actionLog = [
|
||||
{ id: 1, timestamp: 1, actorId: 1, actorName: 'admin', targetType: 'user', targetId: 2, targetLabel: 'alice', action: 'ban', detail: '', success: true },
|
||||
{ id: 2, timestamp: 2, actorId: 1, actorName: 'admin', targetType: 'room', targetId: 3, targetLabel: 'room#3', action: 'close', detail: '', success: true },
|
||||
{ id: 3, timestamp: 3, actorId: 1, actorName: 'admin', targetType: 'user', targetId: 4, targetLabel: 'bob', action: 'mute', detail: '', success: false }
|
||||
];
|
||||
|
||||
render(<HousekeepingDashboardTab />);
|
||||
|
||||
expect(screen.getByText('housekeeping.dashboard.recent_sanctions')).toBeTruthy();
|
||||
expect(screen.getByText('alice')).toBeTruthy();
|
||||
expect(screen.getByText('ban')).toBeTruthy();
|
||||
expect(screen.queryByText('bob')).toBeNull();
|
||||
expect(screen.queryByText('room#3')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the recent-lookups chips when there are entries', () =>
|
||||
{
|
||||
storeState.recentLookups = [
|
||||
{ kind: 'user', id: 1, label: 'alice', at: 1 },
|
||||
{ kind: 'room', id: 2, label: 'lobby', at: 2 }
|
||||
];
|
||||
|
||||
render(<HousekeepingDashboardTab />);
|
||||
|
||||
expect(screen.getByText('housekeeping.dashboard.recent_lookups')).toBeTruthy();
|
||||
expect(screen.getByText('alice')).toBeTruthy();
|
||||
expect(screen.getByText('lobby')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
import { FC, FormEvent, ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { FaBolt, FaChartLine, FaCircle, FaCrown, FaExclamationTriangle, FaHome, FaPaperPlane, FaServer, FaSync, FaTicketAlt, FaUsers } from 'react-icons/fa';
|
||||
import { formatCompactNumber, formatRelativePast, formatUptime, HousekeepingApi, HousekeepingTabId, LocalizeText, NotificationBubbleType } from '../../../../api';
|
||||
import { Button } from '../../../../common';
|
||||
import { useHousekeepingStore, useNotification } from '../../../../hooks';
|
||||
|
||||
const AUTO_REFRESH_MS = 30_000;
|
||||
const STALE_AFTER_MS = 60_000;
|
||||
|
||||
interface StatCardProps
|
||||
{
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
subtle?: string;
|
||||
tone?: 'sky' | 'emerald' | 'amber' | 'rose' | 'violet';
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const TONE_BG: Record<NonNullable<StatCardProps['tone']>, string> = {
|
||||
sky: 'from-sky-50 to-transparent border-sky-200',
|
||||
emerald: 'from-emerald-50 to-transparent border-emerald-200',
|
||||
amber: 'from-amber-50 to-transparent border-amber-200',
|
||||
rose: 'from-rose-50 to-transparent border-rose-200',
|
||||
violet: 'from-violet-50 to-transparent border-violet-200'
|
||||
};
|
||||
|
||||
const TONE_ICON: Record<NonNullable<StatCardProps['tone']>, string> = {
|
||||
sky: 'text-sky-600 bg-sky-100',
|
||||
emerald: 'text-emerald-600 bg-emerald-100',
|
||||
amber: 'text-amber-600 bg-amber-100',
|
||||
rose: 'text-rose-600 bg-rose-100',
|
||||
violet: 'text-violet-600 bg-violet-100'
|
||||
};
|
||||
|
||||
const StatCard: FC<StatCardProps> = ({ icon, label, value, subtle, tone = 'sky', onClick }) =>
|
||||
{
|
||||
const interactive = !!onClick;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={ onClick }
|
||||
className={ `flex items-center gap-2.5 rounded-lg p-2.5 border bg-gradient-to-br shadow-sm ${ TONE_BG[tone] } ${ interactive ? 'cursor-pointer hover:shadow-md transition-shadow' : '' }` }>
|
||||
<div className={ `${ TONE_ICON[tone] } shrink-0 rounded-md p-1.5` }>{ icon }</div>
|
||||
<div className="flex flex-col grow min-w-0">
|
||||
<span className="text-[10px] uppercase tracking-wide opacity-60 font-semibold">{ label }</span>
|
||||
<span className="text-xl font-bold leading-tight tabular-nums">{ value }</span>
|
||||
{ subtle &&
|
||||
<span className="text-[10px] text-zinc-500 truncate">{ subtle }</span> }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const HousekeepingDashboardTab: FC = () =>
|
||||
{
|
||||
const {
|
||||
dashboard,
|
||||
isDashboardLoading,
|
||||
refreshDashboard,
|
||||
actionLog,
|
||||
recentLookups,
|
||||
lookupUserById,
|
||||
lookupRoomById,
|
||||
setActiveTab
|
||||
} = useHousekeepingStore();
|
||||
const { simpleAlert = null } = useNotification();
|
||||
const [ alertMessage, setAlertMessage ] = useState('');
|
||||
const [ isSendingAlert, setIsSendingAlert ] = useState(false);
|
||||
const [ now, setNow ] = useState(() => Date.now());
|
||||
const [ refreshedAt, setRefreshedAt ] = useState<number | null>(null);
|
||||
|
||||
// Tag every successful dashboard payload with a local timestamp.
|
||||
// Triggered when the `dashboard` reference flips post-refresh.
|
||||
useEffect(() =>
|
||||
{
|
||||
if(dashboard) setRefreshedAt(Date.now());
|
||||
}, [ dashboard ]);
|
||||
|
||||
// Wall-clock tick — drives the "live X seconds ago" copy + the
|
||||
// stale-banner trigger. Cheap (1 setState/s) and only runs while
|
||||
// the tab is mounted.
|
||||
useEffect(() =>
|
||||
{
|
||||
const id = setInterval(() => setNow(Date.now()), 1_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Auto-refresh: polls getDashboard every AUTO_REFRESH_MS while the
|
||||
// tab is mounted. The HK panel's parent already kicks off an
|
||||
// initial refresh on open, so this only handles the steady-state
|
||||
// re-fetch; closing the tab unmounts the effect and stops the loop.
|
||||
const refreshRef = useRef(refreshDashboard);
|
||||
refreshRef.current = refreshDashboard;
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const id = setInterval(() =>
|
||||
{
|
||||
const ctrl = new AbortController();
|
||||
refreshRef.current?.(ctrl.signal);
|
||||
}, AUTO_REFRESH_MS);
|
||||
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const ageMs = refreshedAt ? now - refreshedAt : null;
|
||||
const isStale = ageMs !== null && ageMs > STALE_AFTER_MS;
|
||||
const ageLabel = ageMs === null
|
||||
? '—'
|
||||
: (ageMs < 5_000 ? 'now' : `${ Math.floor(ageMs / 1000) }s ago`);
|
||||
|
||||
const recentSanctions = useMemo(
|
||||
() => actionLog
|
||||
.filter(entry => entry && entry.success && entry.targetType === 'user')
|
||||
.slice(0, 5),
|
||||
[ actionLog ]
|
||||
);
|
||||
|
||||
const trimmedAlert = alertMessage.trim();
|
||||
const canSendAlert = trimmedAlert.length > 0 && !isSendingAlert;
|
||||
|
||||
const onSubmitAlert = async (event: FormEvent) =>
|
||||
{
|
||||
event.preventDefault();
|
||||
if(!canSendAlert) return;
|
||||
|
||||
setIsSendingAlert(true);
|
||||
|
||||
try
|
||||
{
|
||||
const result = await HousekeepingApi.sendHotelAlert(trimmedAlert);
|
||||
|
||||
if(simpleAlert)
|
||||
{
|
||||
if(result.ok) simpleAlert(LocalizeText('housekeeping.action.success'), NotificationBubbleType.INFO);
|
||||
else simpleAlert(result.message || LocalizeText('housekeeping.action.error'), NotificationBubbleType.INFO);
|
||||
}
|
||||
|
||||
if(result.ok) setAlertMessage('');
|
||||
}
|
||||
finally
|
||||
{
|
||||
setIsSendingAlert(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onRecentClick = (entry: { kind: 'user' | 'room'; id: number; label: string }) =>
|
||||
{
|
||||
if(entry.kind === 'user')
|
||||
{
|
||||
setActiveTab(HousekeepingTabId.USERS);
|
||||
lookupUserById?.(entry.id);
|
||||
}
|
||||
else
|
||||
{
|
||||
setActiveTab(HousekeepingTabId.ROOMS);
|
||||
lookupRoomById?.(entry.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{ /* Header row: title + live status badge + refresh */ }
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs uppercase tracking-wider font-semibold opacity-60 flex items-center gap-1">
|
||||
<FaChartLine size={ 10 } />
|
||||
{ LocalizeText('housekeeping.dashboard.title') }
|
||||
</h3>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
title={ refreshedAt ? new Date(refreshedAt).toLocaleTimeString() : '' }
|
||||
className={ `inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-medium border ${ isStale ? 'bg-amber-50 border-amber-200 text-amber-700' : 'bg-emerald-50 border-emerald-200 text-emerald-700' }` }>
|
||||
<FaCircle size={ 6 } className={ isStale ? '' : 'animate-pulse' } />
|
||||
{ isStale ? `stale · ${ ageLabel }` : `live · ${ ageLabel }` }
|
||||
</span>
|
||||
<Button size="sm" variant="secondary" disabled={ isDashboardLoading } onClick={ () => refreshDashboard() }>
|
||||
<FaSync size={ 9 } className={ isDashboardLoading ? 'animate-spin' : '' } />
|
||||
<span className="ml-1">{ LocalizeText('housekeeping.dashboard.refresh') }</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ !dashboard && isDashboardLoading &&
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{ Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={ i } className="rounded-lg border bg-zinc-50 animate-pulse h-16" />
|
||||
)) }
|
||||
</div> }
|
||||
|
||||
{ !dashboard && !isDashboardLoading &&
|
||||
<div className="flex items-center gap-2 rounded border border-dashed border-zinc-300 bg-zinc-50/50 p-3 text-xs text-zinc-500 italic">
|
||||
<FaExclamationTriangle size={ 12 } />
|
||||
{ LocalizeText('housekeeping.dashboard.unavailable') }
|
||||
</div> }
|
||||
|
||||
{ dashboard &&
|
||||
<>
|
||||
{ /* Hero card: BIG online count + pulsing dot + peak today */ }
|
||||
<div className="relative overflow-hidden rounded-lg border border-emerald-200 bg-gradient-to-br from-emerald-50 via-white to-sky-50 p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-full bg-emerald-100 p-2 flex items-center justify-center">
|
||||
<span className="nitro-icon nitro-icon-hk-hero icon-modtools" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider opacity-60 font-semibold">
|
||||
<FaCircle size={ 6 } className="text-emerald-500 animate-pulse" />
|
||||
{ LocalizeText('housekeeping.dashboard.online') }
|
||||
</div>
|
||||
<div className="text-3xl font-bold leading-none tabular-nums">{ formatCompactNumber(dashboard.onlineUsers) }</div>
|
||||
<div className="text-[11px] text-zinc-500">
|
||||
{ LocalizeText('housekeeping.dashboard.total_users', [ 'count' ], [ dashboard.totalUsers.toLocaleString() ]) }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] uppercase tracking-wider opacity-60 font-semibold flex items-center gap-1 justify-end">
|
||||
<FaCrown size={ 9 } className="text-amber-500" />
|
||||
{ LocalizeText('housekeeping.dashboard.peak_today') }
|
||||
</div>
|
||||
<div className="text-xl font-semibold tabular-nums">{ formatCompactNumber(dashboard.peakOnlineToday) }</div>
|
||||
<div className="text-[10px] text-zinc-500">
|
||||
{ LocalizeText('housekeeping.dashboard.peak_alltime', [ 'count' ], [ formatCompactNumber(dashboard.peakOnlineAllTime) ]) }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ /* 4-card grid */ }
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<StatCard
|
||||
icon={ <FaHome size={ 14 } /> }
|
||||
label={ LocalizeText('housekeeping.dashboard.rooms_active') }
|
||||
value={ formatCompactNumber(dashboard.activeRooms) }
|
||||
subtle={ LocalizeText('housekeeping.dashboard.total_rooms', [ 'count' ], [ dashboard.totalRooms.toLocaleString() ]) }
|
||||
tone="sky"
|
||||
onClick={ () => setActiveTab(HousekeepingTabId.ROOMS) } />
|
||||
<StatCard
|
||||
icon={ <FaTicketAlt size={ 14 } /> }
|
||||
label={ LocalizeText('housekeeping.dashboard.pending_tickets') }
|
||||
value={ formatCompactNumber(dashboard.pendingTickets) }
|
||||
subtle={ LocalizeText('housekeeping.dashboard.sanctions_24h', [ 'count' ], [ String(dashboard.sanctionsLast24h) ]) }
|
||||
tone={ dashboard.pendingTickets > 0 ? 'rose' : 'emerald' } />
|
||||
<div className="col-span-2">
|
||||
<StatCard
|
||||
icon={ <FaServer size={ 14 } /> }
|
||||
label={ LocalizeText('housekeeping.dashboard.server') }
|
||||
value={ formatUptime(dashboard.serverUptimeSeconds) }
|
||||
subtle={ dashboard.serverVersion }
|
||||
tone="violet" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ /* Quick hotel-alert inline */ }
|
||||
<form onSubmit={ onSubmitAlert } className="flex flex-col gap-1.5 rounded-lg border border-amber-200 bg-amber-50/40 p-2.5">
|
||||
<label className="text-[10px] uppercase tracking-wider font-semibold opacity-60 flex items-center gap-1">
|
||||
<FaBolt size={ 9 } className="text-amber-500" />
|
||||
{ LocalizeText('housekeeping.hotel.alert.label') }
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={ alertMessage }
|
||||
onChange={ e => setAlertMessage(e.target.value) }
|
||||
onKeyDown={ e => { if(e.key === 'Enter' && canSendAlert) { e.preventDefault(); onSubmitAlert(e as unknown as FormEvent); } } }
|
||||
placeholder={ LocalizeText('housekeeping.hotel.alert.placeholder') }
|
||||
className="grow rounded border border-amber-200 bg-white px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-amber-400 placeholder:text-zinc-400"
|
||||
maxLength={ 280 } />
|
||||
<Button size="sm" variant="primary" disabled={ !canSendAlert } onClick={ () => onSubmitAlert({ preventDefault: () => {} } as FormEvent) }>
|
||||
<FaPaperPlane size={ 9 } className={ isSendingAlert ? 'animate-pulse' : '' } />
|
||||
<span className="ml-1">{ LocalizeText('housekeeping.hotel.alert.send') }</span>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</> }
|
||||
|
||||
{ /* Recent sanctions */ }
|
||||
{ recentSanctions.length > 0 &&
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-[10px] uppercase tracking-wider font-semibold opacity-60 pt-1">
|
||||
{ LocalizeText('housekeeping.dashboard.recent_sanctions') }
|
||||
</h4>
|
||||
<ul className="flex flex-col gap-0.5 rounded-lg border bg-white/50 divide-y divide-zinc-100">
|
||||
{ recentSanctions.map(entry => (
|
||||
<li key={ entry.id } className="flex items-center gap-2 text-[11px] px-2 py-1 hover:bg-zinc-50">
|
||||
<span className="text-zinc-400 tabular-nums w-14 shrink-0">{ formatRelativePast(entry.timestamp) }</span>
|
||||
<span className="font-semibold truncate" title={ entry.actorName }>{ entry.actorName }</span>
|
||||
<span className="text-zinc-400">→</span>
|
||||
<span className="truncate" title={ entry.targetLabel }>{ entry.targetLabel }</span>
|
||||
<span className="ml-auto px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 font-medium shrink-0 text-[10px]">{ entry.action }</span>
|
||||
</li>
|
||||
)) }
|
||||
</ul>
|
||||
</div> }
|
||||
|
||||
{ /* Recent lookups — clickable pills that re-select the target */ }
|
||||
{ recentLookups.length > 0 &&
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-[10px] uppercase tracking-wider font-semibold opacity-60 pt-1">
|
||||
{ LocalizeText('housekeeping.dashboard.recent_lookups') }
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ recentLookups.map((entry, index) => (
|
||||
<button
|
||||
key={ `${ entry.kind }-${ entry.id }-${ index }` }
|
||||
type="button"
|
||||
onClick={ () => onRecentClick(entry) }
|
||||
className={ `inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] border transition-colors ${ entry.kind === 'user' ? 'bg-sky-50 border-sky-200 text-sky-700 hover:bg-sky-100' : 'bg-violet-50 border-violet-200 text-violet-700 hover:bg-violet-100' }` }
|
||||
title={ `${ entry.kind } #${ entry.id }` }>
|
||||
<span className="opacity-60 font-bold">{ entry.kind === 'user' ? 'U' : 'R' }</span>
|
||||
<span className="font-medium">{ entry.label }</span>
|
||||
</button>
|
||||
)) }
|
||||
</div>
|
||||
</div> }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
import { FC, useState } from 'react';
|
||||
import { FaBullhorn, FaCrown, FaExclamationTriangle, FaGift, FaPiggyBank } from 'react-icons/fa';
|
||||
import { LocalizeText } from '../../../../api';
|
||||
import { Button, LayoutCurrencyIcon } from '../../../../common';
|
||||
import { useHousekeeping, useHousekeepingConfirm } from '../../../../hooks';
|
||||
|
||||
const HOTEL_ALERT_CONFIRM_THRESHOLD = 200;
|
||||
|
||||
export const HousekeepingEconomyTab: FC = () =>
|
||||
{
|
||||
const confirm = useHousekeepingConfirm();
|
||||
const {
|
||||
selectedUser, isActionPending,
|
||||
giveCredits, giveDuckets, giveDiamonds, grantItem, setHcSubscription, sendHotelAlert
|
||||
} = useHousekeeping();
|
||||
|
||||
const [ creditsAmount, setCreditsAmount ] = useState<number>(1000);
|
||||
const [ ducketsAmount, setDucketsAmount ] = useState<number>(100);
|
||||
const [ diamondsAmount, setDiamondsAmount ] = useState<number>(10);
|
||||
const [ itemId, setItemId ] = useState<number>(0);
|
||||
const [ itemQuantity, setItemQuantity ] = useState<number>(1);
|
||||
const [ hcDays, setHcDays ] = useState<number>(31);
|
||||
const [ alertText, setAlertText ] = useState('');
|
||||
|
||||
const disableUserActions = !selectedUser || isActionPending;
|
||||
const disableHotelActions = isActionPending;
|
||||
const trimmedAlert = alertText.trim();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{ /* Target banner */ }
|
||||
{ !selectedUser
|
||||
? (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-dashed border-amber-300 bg-amber-50/50 p-2.5 text-xs text-amber-700">
|
||||
<FaExclamationTriangle size={ 12 } />
|
||||
{ LocalizeText('housekeeping.economy.select_user') }
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="rounded-lg border border-emerald-200 bg-gradient-to-r from-emerald-50 to-transparent px-2.5 py-1.5">
|
||||
<div className="text-[10px] uppercase tracking-wider font-semibold opacity-60">
|
||||
{ LocalizeText('housekeeping.economy.target', [ 'username', 'id' ], [ '', '' ]).replace(/[a-z]+:\s*/i, '').trim() || 'Target' }
|
||||
</div>
|
||||
<div className="text-sm font-semibold tabular-nums">{ selectedUser.username } <span className="text-zinc-400 font-normal">#{ selectedUser.id }</span></div>
|
||||
</div>
|
||||
) }
|
||||
|
||||
{ /* Currency grants — tone-coded surfaces */ }
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{ /* Credits — amber */ }
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-amber-200 bg-amber-50/40 px-2 py-1.5">
|
||||
<LayoutCurrencyIcon type={ -1 } classNames={ [ 'shrink-0' ] } />
|
||||
<input
|
||||
type="number"
|
||||
min={ 1 }
|
||||
className="w-24 px-1.5 py-0.5 rounded border border-amber-200 bg-white text-xs tabular-nums focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
value={ creditsAmount }
|
||||
onChange={ event => setCreditsAmount(parseInt(event.target.value) || 0) } />
|
||||
<Button variant="success" disabled={ disableUserActions } className="grow ml-auto" onClick={ () => giveCredits(selectedUser.id, creditsAmount) }>
|
||||
<FaPiggyBank size={ 10 } />
|
||||
<span className="ml-1">{ LocalizeText('housekeeping.economy.give_credits') }</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{ /* Duckets — orange */ }
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-orange-200 bg-orange-50/40 px-2 py-1.5">
|
||||
<LayoutCurrencyIcon type={ 0 } classNames={ [ 'shrink-0' ] } />
|
||||
<input
|
||||
type="number"
|
||||
min={ 1 }
|
||||
className="w-24 px-1.5 py-0.5 rounded border border-orange-200 bg-white text-xs tabular-nums focus:outline-none focus:ring-1 focus:ring-orange-400"
|
||||
value={ ducketsAmount }
|
||||
onChange={ event => setDucketsAmount(parseInt(event.target.value) || 0) } />
|
||||
<Button variant="success" disabled={ disableUserActions } className="grow ml-auto" onClick={ () => giveDuckets(selectedUser.id, ducketsAmount) }>
|
||||
<FaPiggyBank size={ 10 } />
|
||||
<span className="ml-1">{ LocalizeText('housekeeping.economy.give_duckets') }</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{ /* Diamonds — sky */ }
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-sky-200 bg-sky-50/40 px-2 py-1.5">
|
||||
<LayoutCurrencyIcon type={ 5 } classNames={ [ 'shrink-0' ] } />
|
||||
<input
|
||||
type="number"
|
||||
min={ 1 }
|
||||
className="w-24 px-1.5 py-0.5 rounded border border-sky-200 bg-white text-xs tabular-nums focus:outline-none focus:ring-1 focus:ring-sky-400"
|
||||
value={ diamondsAmount }
|
||||
onChange={ event => setDiamondsAmount(parseInt(event.target.value) || 0) } />
|
||||
<Button variant="success" disabled={ disableUserActions } className="grow ml-auto" onClick={ () => giveDiamonds(selectedUser.id, diamondsAmount) }>
|
||||
<FaPiggyBank size={ 10 } />
|
||||
<span className="ml-1">{ LocalizeText('housekeeping.economy.give_diamonds') }</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ /* Grant item card */ }
|
||||
<div className="flex flex-col gap-1.5 rounded-md border border-violet-200 bg-violet-50/40 p-2">
|
||||
<label className="text-[10px] uppercase tracking-wider font-semibold opacity-60 flex items-center gap-1">
|
||||
<FaGift size={ 8 } className="text-violet-500" />
|
||||
{ LocalizeText('housekeeping.economy.grant_item.label') }
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="number"
|
||||
min={ 1 }
|
||||
className="w-24 px-1.5 py-1 rounded border border-violet-200 bg-white text-xs tabular-nums focus:outline-none focus:ring-1 focus:ring-violet-400"
|
||||
placeholder={ LocalizeText('housekeeping.economy.item_id') }
|
||||
value={ itemId || '' }
|
||||
onChange={ event => setItemId(parseInt(event.target.value) || 0) } />
|
||||
<input
|
||||
type="number"
|
||||
min={ 1 }
|
||||
className="w-16 px-1.5 py-1 rounded border border-violet-200 bg-white text-xs tabular-nums focus:outline-none focus:ring-1 focus:ring-violet-400"
|
||||
placeholder={ LocalizeText('housekeeping.economy.item_quantity') }
|
||||
value={ itemQuantity }
|
||||
onChange={ event => setItemQuantity(parseInt(event.target.value) || 0) } />
|
||||
<Button variant="primary" disabled={ disableUserActions || !itemId } className="grow" onClick={ () => grantItem(selectedUser.id, itemId, itemQuantity) }>
|
||||
<FaGift size={ 10 } />
|
||||
<span className="ml-1">{ LocalizeText('housekeeping.economy.grant_item') }</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ /* HC subscription */ }
|
||||
<div className="flex items-center gap-1.5 rounded-md border border-amber-200 bg-gradient-to-r from-amber-50 to-yellow-50 px-2 py-1.5">
|
||||
<FaCrown size={ 13 } className="text-amber-600 shrink-0" />
|
||||
<input
|
||||
type="number"
|
||||
min={ 0 }
|
||||
className="w-20 px-1.5 py-0.5 rounded border border-amber-200 bg-white text-xs tabular-nums focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
value={ hcDays }
|
||||
onChange={ event => setHcDays(parseInt(event.target.value) || 0) } />
|
||||
<span className="text-[11px] text-zinc-600">days</span>
|
||||
<Button variant="warning" disabled={ disableUserActions } className="grow ml-auto" onClick={ () => setHcSubscription(selectedUser.id, hcDays) }>
|
||||
<FaCrown size={ 10 } />
|
||||
<span className="ml-1">{ LocalizeText('housekeeping.economy.set_hc_days') }</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{ /* Hotel-wide alert */ }
|
||||
<div className="flex flex-col gap-1.5 rounded-md border border-rose-200 bg-rose-50/40 p-2">
|
||||
<label className="text-[10px] uppercase tracking-wider font-semibold opacity-60 flex items-center gap-1">
|
||||
<FaBullhorn size={ 9 } className="text-rose-500" />
|
||||
{ LocalizeText('housekeeping.hotel.alert.label') }
|
||||
</label>
|
||||
<textarea
|
||||
className="min-h-[60px] px-2 py-1 rounded text-sm border border-rose-200 bg-white focus:outline-none focus:ring-1 focus:ring-rose-400 placeholder:text-zinc-400"
|
||||
placeholder={ LocalizeText('housekeeping.hotel.alert.placeholder') }
|
||||
value={ alertText }
|
||||
onChange={ event => setAlertText(event.target.value) } />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-zinc-500 tabular-nums">{ trimmedAlert.length } chars</span>
|
||||
<Button variant="danger" disabled={ disableHotelActions || !trimmedAlert.length } onClick={ () =>
|
||||
{
|
||||
const dispatch = () => sendHotelAlert(trimmedAlert);
|
||||
|
||||
// Lungo alert hotel-wide → conferma esplicita.
|
||||
if(trimmedAlert.length >= HOTEL_ALERT_CONFIRM_THRESHOLD)
|
||||
{
|
||||
confirm(LocalizeText('housekeeping.hotel.alert.confirm', [ 'count' ], [ String(trimmedAlert.length) ]), dispatch);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch();
|
||||
} }>
|
||||
<FaBullhorn size={ 10 } />
|
||||
<span className="ml-1">{ LocalizeText('housekeeping.hotel.alert.send') }</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,198 @@
|
||||
import { FC, useState } from 'react';
|
||||
import { FaCrown, FaDoorOpen, FaExchangeAlt, FaHome, FaLock, FaMapMarkerAlt, FaSearch, FaTimes, FaTrash, FaUserSlash, FaUsers, FaVolumeMute } from 'react-icons/fa';
|
||||
import { LocalizeText } from '../../../../api';
|
||||
import { Button } from '../../../../common';
|
||||
import { useHousekeeping, useHousekeepingConfirm, useRoom } from '../../../../hooks';
|
||||
|
||||
const DEFAULT_MUTE_MINUTES = 10;
|
||||
|
||||
export const HousekeepingRoomsTab: FC = () =>
|
||||
{
|
||||
const {
|
||||
selectedRoom, setSelectedRoom, lookupRoomById, isRoomLoading, isActionPending,
|
||||
openRoom, closeRoom, muteRoom, kickAllFromRoom, transferRoomOwnership, deleteRoom
|
||||
} = useHousekeeping();
|
||||
const { roomSession = null } = useRoom();
|
||||
const [ query, setQuery ] = useState('');
|
||||
const [ muteMinutes, setMuteMinutes ] = useState<number>(DEFAULT_MUTE_MINUTES);
|
||||
const [ newOwnerId, setNewOwnerId ] = useState<number>(0);
|
||||
|
||||
const confirm = useHousekeepingConfirm();
|
||||
|
||||
const currentRoomId = roomSession && roomSession.roomId > 0 ? roomSession.roomId : 0;
|
||||
|
||||
// Empty query + Cerca → fall back to the room the operator is
|
||||
// currently standing in. Saves a copy-paste of the room id from
|
||||
// navigator just to inspect "this room". Mirrors how /ban / /kick
|
||||
// in chat default to the active room.
|
||||
const submitLookup = () =>
|
||||
{
|
||||
const trimmed = query.trim();
|
||||
const idFromQuery = parseInt(trimmed);
|
||||
const id = (Number.isFinite(idFromQuery) && idFromQuery > 0) ? idFromQuery : currentRoomId;
|
||||
|
||||
if(id <= 0) return;
|
||||
|
||||
lookupRoomById(id);
|
||||
};
|
||||
|
||||
const useCurrentRoom = () =>
|
||||
{
|
||||
if(currentRoomId <= 0) return;
|
||||
setQuery(String(currentRoomId));
|
||||
lookupRoomById(currentRoomId);
|
||||
};
|
||||
|
||||
const disableActions = !selectedRoom || isActionPending;
|
||||
|
||||
const confirmAndRun = (key: string, fn: () => void) => confirm(LocalizeText(key), fn);
|
||||
|
||||
const occupancyPct = selectedRoom && selectedRoom.maxUsers > 0
|
||||
? Math.min(100, Math.round((selectedRoom.userCount / selectedRoom.maxUsers) * 100))
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{ /* Lookup bar */ }
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<div className="flex items-center gap-1 grow rounded-md border border-zinc-300 bg-white px-2 py-1 shadow-sm focus-within:ring-1 focus-within:ring-sky-300 focus-within:border-sky-400 transition-colors">
|
||||
<FaSearch className="text-zinc-400 shrink-0" size={ 11 } />
|
||||
<input
|
||||
type="number"
|
||||
min={ 1 }
|
||||
className="grow text-sm bg-transparent outline-none placeholder:text-zinc-400"
|
||||
placeholder={ currentRoomId > 0
|
||||
? `${ LocalizeText('housekeeping.room.search.placeholder') } · empty → current #${ currentRoomId }`
|
||||
: LocalizeText('housekeeping.room.search.placeholder') }
|
||||
value={ query }
|
||||
onChange={ event => setQuery(event.target.value) }
|
||||
onKeyDown={ event => { if(event.key === 'Enter') submitLookup(); } } />
|
||||
</div>
|
||||
{ currentRoomId > 0 && currentRoomId !== selectedRoom?.id &&
|
||||
<Button
|
||||
gap={ 1 }
|
||||
variant="secondary"
|
||||
disabled={ isRoomLoading }
|
||||
title={ `Lookup current room #${ currentRoomId }` }
|
||||
onClick={ useCurrentRoom }>
|
||||
<FaMapMarkerAlt size={ 10 } className="text-sky-500" />
|
||||
<span>here</span>
|
||||
</Button> }
|
||||
<Button gap={ 1 } disabled={ isRoomLoading } onClick={ submitLookup }>
|
||||
<FaSearch size={ 10 } className={ isRoomLoading ? 'animate-pulse' : '' } />
|
||||
<span>{ LocalizeText('housekeeping.room.search.button') }</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{ /* Selected room hero card */ }
|
||||
{ selectedRoom
|
||||
? (
|
||||
<div className="relative overflow-hidden rounded-lg border border-sky-200 bg-gradient-to-br from-sky-50 via-white to-violet-50 p-3 shadow-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-full bg-sky-100 p-2 shrink-0 flex items-center justify-center">
|
||||
<span className="nitro-icon nitro-icon-hk-hero icon-rooms" />
|
||||
</div>
|
||||
<div className="grow min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="font-bold text-base truncate">{ selectedRoom.name }</span>
|
||||
<span className="text-[10px] text-zinc-500 tabular-nums">#{ selectedRoom.id }</span>
|
||||
{ selectedRoom.isPublic &&
|
||||
<span className="text-[9px] uppercase font-semibold px-1.5 py-0.5 rounded-full bg-emerald-100 border border-emerald-200 text-emerald-800">public</span> }
|
||||
{ selectedRoom.isLocked &&
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-rose-100 border border-rose-200 text-rose-700"><FaLock size={ 8 } /> closed</span> }
|
||||
{ selectedRoom.isMuted &&
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-amber-100 border border-amber-200 text-amber-700"><FaVolumeMute size={ 8 } /> muted</span> }
|
||||
</div>
|
||||
<div className="text-xs text-zinc-600 truncate mt-0.5">{ selectedRoom.description || '—' }</div>
|
||||
<div className="flex items-center gap-3 text-[11px] text-zinc-700 mt-1.5">
|
||||
<span className="inline-flex items-center gap-1" title={ `${ selectedRoom.userCount } / ${ selectedRoom.maxUsers }` }>
|
||||
<FaUsers size={ 10 } className="text-sky-600" />
|
||||
<span className="tabular-nums font-semibold">{ selectedRoom.userCount }</span>
|
||||
<span className="text-zinc-400">/</span>
|
||||
<span className="tabular-nums">{ selectedRoom.maxUsers }</span>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 truncate" title={ selectedRoom.ownerName }>
|
||||
<FaCrown size={ 10 } className="text-amber-500" />
|
||||
<span className="truncate">{ selectedRoom.ownerName }</span>
|
||||
<span className="text-zinc-400 tabular-nums">#{ selectedRoom.ownerId }</span>
|
||||
</span>
|
||||
</div>
|
||||
{ selectedRoom.maxUsers > 0 &&
|
||||
<div className="h-1 mt-1.5 rounded-full bg-zinc-100 overflow-hidden">
|
||||
<div
|
||||
className={ `h-full transition-all ${ occupancyPct > 85 ? 'bg-rose-500' : occupancyPct > 60 ? 'bg-amber-500' : 'bg-emerald-500' }` }
|
||||
style={ { width: `${ occupancyPct }%` } } />
|
||||
</div> }
|
||||
</div>
|
||||
<button
|
||||
className="text-zinc-400 hover:text-rose-600 transition-colors p-1"
|
||||
onClick={ () => setSelectedRoom(null) }
|
||||
title={ LocalizeText('housekeeping.room.clear') }>
|
||||
<FaTimes size={ 12 } />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-dashed border-zinc-300 bg-zinc-50/50 p-3 text-xs text-zinc-500 italic">
|
||||
<FaHome size={ 14 } />
|
||||
{ LocalizeText('housekeeping.room.none') }
|
||||
</div>
|
||||
) }
|
||||
|
||||
{ /* Open / Close + Mute */ }
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<Button variant="success" disabled={ disableActions || !selectedRoom?.isLocked } onClick={ () => openRoom(selectedRoom.id) }>
|
||||
<FaDoorOpen size={ 10 } />
|
||||
<span className="ml-1">{ LocalizeText('housekeeping.room.open') }</span>
|
||||
</Button>
|
||||
<Button variant="danger" disabled={ disableActions || selectedRoom?.isLocked } onClick={ () => closeRoom(selectedRoom.id) }>
|
||||
<FaLock size={ 10 } />
|
||||
<span className="ml-1">{ LocalizeText('housekeeping.room.close') }</span>
|
||||
</Button>
|
||||
<div className="col-span-2 flex items-center gap-1.5 rounded-md border border-amber-200 bg-amber-50/40 px-2 py-1.5">
|
||||
<FaVolumeMute size={ 11 } className="text-amber-600" />
|
||||
<input
|
||||
type="number"
|
||||
min={ 1 }
|
||||
className="w-14 px-1.5 py-0.5 rounded border border-amber-200 bg-white text-xs tabular-nums focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
value={ muteMinutes }
|
||||
onChange={ event => setMuteMinutes(parseInt(event.target.value) || 0) } />
|
||||
<span className="text-[11px] text-zinc-600">min</span>
|
||||
<Button variant="warning" disabled={ disableActions } className="ml-auto" onClick={ () => muteRoom(selectedRoom.id, muteMinutes) }>
|
||||
<span>{ LocalizeText('housekeeping.room.mute_min', [ 'm' ], [ String(muteMinutes) ]) }</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="warning" disabled={ disableActions } onClick={ () => confirmAndRun('housekeeping.room.kick_all.confirm', () => kickAllFromRoom(selectedRoom.id)) }>
|
||||
<FaUserSlash size={ 10 } />
|
||||
<span className="ml-1">{ LocalizeText('housekeeping.room.kick_all') }</span>
|
||||
</Button>
|
||||
<Button variant="danger" disabled={ disableActions } onClick={ () => confirmAndRun('housekeeping.room.delete.confirm', () => deleteRoom(selectedRoom.id)) }>
|
||||
<FaTrash size={ 10 } />
|
||||
<span className="ml-1">{ LocalizeText('housekeeping.room.delete') }</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{ /* Transfer ownership card */ }
|
||||
<div className="flex flex-col gap-1.5 rounded-md border border-violet-200 bg-violet-50/40 p-2">
|
||||
<label className="text-[10px] uppercase tracking-wider font-semibold opacity-60 flex items-center gap-1">
|
||||
<FaExchangeAlt size={ 8 } className="text-violet-500" />
|
||||
{ LocalizeText('housekeeping.room.transfer.label') }
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="number"
|
||||
min={ 1 }
|
||||
className="w-24 px-1.5 py-1 rounded border border-violet-200 bg-white text-xs tabular-nums focus:outline-none focus:ring-1 focus:ring-violet-400"
|
||||
placeholder={ LocalizeText('housekeeping.room.transfer.new_owner') }
|
||||
value={ newOwnerId || '' }
|
||||
onChange={ event => setNewOwnerId(parseInt(event.target.value) || 0) } />
|
||||
<Button variant="primary" disabled={ disableActions || !newOwnerId } className="grow" onClick={ () => transferRoomOwnership(selectedRoom.id, newOwnerId) }>
|
||||
<FaExchangeAlt size={ 10 } />
|
||||
<span className="ml-1">{ LocalizeText('housekeeping.room.transfer') }</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,439 @@
|
||||
import { FC, useEffect, useRef, useState } from 'react';
|
||||
import { FaBan, FaBolt, FaCircle, FaCoins, FaEnvelope, FaExclamationTriangle, FaIdBadge, FaKey, FaLock, FaPlug, FaSearch, FaTimes, FaUser, FaUserShield, FaUserSlash, FaVolumeMute } from 'react-icons/fa';
|
||||
import { findTemplateById, HK_SANCTION_TEMPLATES, HousekeepingSanctionType, LocalizeText } from '../../../../api';
|
||||
import { Button, LayoutAvatarImageView, LayoutCurrencyIcon } from '../../../../common';
|
||||
import { useHousekeeping, useHousekeepingConfirm } from '../../../../hooks';
|
||||
|
||||
const DEFAULT_BAN_HOURS = 18;
|
||||
const DEFAULT_MUTE_MINUTES = 60;
|
||||
const DEFAULT_TRADE_LOCK_HOURS = 168;
|
||||
const BULK_CONFIRM_THRESHOLD = 5;
|
||||
|
||||
export const HousekeepingUsersTab: FC = () =>
|
||||
{
|
||||
const {
|
||||
selectedUser, setSelectedUser, lookupUserByName, lookupUserById, isUserLoading, isActionPending,
|
||||
banUser, unbanUser, kickUser, muteUser, forceDisconnectUser, resetUserPassword, setUserRank, tradeLockUser,
|
||||
userSuggestions, requestUserSuggestions, recentLookups,
|
||||
kickFromCurrentRoom, banFromCurrentRoom, muteInCurrentRoom,
|
||||
selectedUserIds, toggleUserSelection, clearUserSelection,
|
||||
banUsersBulk, kickUsersBulk, muteUsersBulk
|
||||
} = useHousekeeping();
|
||||
const confirm = useHousekeepingConfirm();
|
||||
const [ query, setQuery ] = useState('');
|
||||
const [ isFocused, setIsFocused ] = useState(false);
|
||||
const [ reason, setReason ] = useState('');
|
||||
const [ banHours, setBanHours ] = useState<number>(DEFAULT_BAN_HOURS);
|
||||
const [ muteMinutes, setMuteMinutes ] = useState<number>(DEFAULT_MUTE_MINUTES);
|
||||
const [ tradeLockHours, setTradeLockHours ] = useState<number>(DEFAULT_TRADE_LOCK_HOURS);
|
||||
const [ rankDraft, setRankDraft ] = useState<number>(1);
|
||||
const [ templateId, setTemplateId ] = useState<string>('');
|
||||
const blurTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => () =>
|
||||
{
|
||||
if(blurTimerRef.current) clearTimeout(blurTimerRef.current);
|
||||
}, []);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
requestUserSuggestions(query);
|
||||
}, [ query, requestUserSuggestions ]);
|
||||
|
||||
const submitLookup = () =>
|
||||
{
|
||||
const trimmed = query.trim();
|
||||
|
||||
if(!trimmed.length) return;
|
||||
|
||||
lookupUserByName(trimmed);
|
||||
setIsFocused(false);
|
||||
};
|
||||
|
||||
const recentUsers = recentLookups.filter(entry => entry.kind === 'user').slice(0, 5);
|
||||
const showSuggestionPanel = isFocused && (userSuggestions.length > 0 || (recentUsers.length > 0 && query.trim().length < 2));
|
||||
|
||||
const disableActions = !selectedUser || isActionPending;
|
||||
const reasonOrDefault = reason.trim().length ? reason.trim() : LocalizeText('housekeeping.reason.default');
|
||||
|
||||
const applyTemplate = (id: string) =>
|
||||
{
|
||||
setTemplateId(id);
|
||||
|
||||
const template = findTemplateById(id);
|
||||
|
||||
if(!template) return;
|
||||
|
||||
// Pre-fill the duration inputs the template targets, and seed
|
||||
// the reason textarea — operator can still tweak before
|
||||
// hitting the per-action button.
|
||||
setReason(template.defaultReason);
|
||||
|
||||
if(template.type === HousekeepingSanctionType.BAN) setBanHours(template.durationValue);
|
||||
if(template.type === HousekeepingSanctionType.MUTE) setMuteMinutes(template.durationValue);
|
||||
if(template.type === HousekeepingSanctionType.TRADE_LOCK) setTradeLockHours(template.durationValue);
|
||||
};
|
||||
|
||||
const runBulkWithGate = (kind: 'ban' | 'kick' | 'mute', actionLabel: string, runner: () => void) =>
|
||||
{
|
||||
if(selectedUserIds.length === 0) return;
|
||||
|
||||
// Big bulks need a confirm — a stray click can sanction
|
||||
// dozens of users in one shot otherwise.
|
||||
if(selectedUserIds.length >= BULK_CONFIRM_THRESHOLD)
|
||||
{
|
||||
confirm(
|
||||
LocalizeText('housekeeping.bulk.confirm', [ 'action', 'count' ], [ actionLabel, String(selectedUserIds.length) ]),
|
||||
runner
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
runner();
|
||||
};
|
||||
|
||||
const bulkBan = () => runBulkWithGate('ban', LocalizeText('housekeeping.action.ban_h', [ 'h' ], [ String(banHours) ]),
|
||||
() => banUsersBulk(selectedUserIds, reasonOrDefault, banHours));
|
||||
const bulkKick = () => runBulkWithGate('kick', LocalizeText('housekeeping.action.kick'),
|
||||
() => kickUsersBulk(selectedUserIds, reasonOrDefault));
|
||||
const bulkMute = () => runBulkWithGate('mute', LocalizeText('housekeeping.action.mute_min', [ 'm' ], [ String(muteMinutes) ]),
|
||||
() => muteUsersBulk(selectedUserIds, reasonOrDefault, muteMinutes));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Lookup with autocomplete */}
|
||||
<div className="relative">
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<div className="flex items-center gap-1 grow rounded border border-zinc-300 bg-white px-2 py-1">
|
||||
<FaSearch className="text-zinc-400 shrink-0" size={ 11 } />
|
||||
<input
|
||||
className="grow text-sm bg-transparent outline-none"
|
||||
placeholder={ LocalizeText('housekeeping.user.search.placeholder') }
|
||||
value={ query }
|
||||
onChange={ event => setQuery(event.target.value) }
|
||||
onFocus={ () => setIsFocused(true) }
|
||||
onBlur={ () =>
|
||||
{
|
||||
// Defer hide so onClick on a suggestion fires first
|
||||
if(blurTimerRef.current) clearTimeout(blurTimerRef.current);
|
||||
blurTimerRef.current = setTimeout(() => setIsFocused(false), 120);
|
||||
} }
|
||||
onKeyDown={ event =>
|
||||
{
|
||||
if(event.key === 'Enter') submitLookup();
|
||||
if(event.key === 'Escape') setIsFocused(false);
|
||||
} } />
|
||||
</div>
|
||||
<Button gap={ 1 } disabled={ isUserLoading } onClick={ submitLookup }>
|
||||
<FaSearch size={ 10 } />
|
||||
<span>{ LocalizeText('housekeeping.user.search.button') }</span>
|
||||
</Button>
|
||||
</div>
|
||||
{ showSuggestionPanel &&
|
||||
<div className="absolute left-0 right-0 top-full mt-1 z-30 rounded border border-zinc-200 bg-white shadow-lg max-h-[200px] overflow-y-auto">
|
||||
{ userSuggestions.length > 0
|
||||
? userSuggestions.map(entry =>
|
||||
{
|
||||
const isChecked = selectedUserIds.includes(entry.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ entry.id }
|
||||
className="w-full flex items-center gap-2 px-2 py-1 text-xs hover:bg-sky-50 border-b border-zinc-100 last:border-b-0"
|
||||
onMouseDown={ event => event.preventDefault() }>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ isChecked }
|
||||
onChange={ () => toggleUserSelection(entry.id) }
|
||||
title={ isChecked ? LocalizeText('housekeeping.bulk.clear') : LocalizeText('housekeeping.bulk.apply') }
|
||||
className="shrink-0" />
|
||||
<button
|
||||
className="flex items-center gap-2 grow text-left"
|
||||
onClick={ () =>
|
||||
{
|
||||
setQuery(entry.username);
|
||||
setIsFocused(false);
|
||||
lookupUserById(entry.id);
|
||||
} }>
|
||||
<FaCircle size={ 6 } className={ entry.online ? 'text-emerald-500' : 'text-zinc-400' } />
|
||||
<span className="font-medium truncate grow">{ entry.username }</span>
|
||||
<span className="text-[10px] text-zinc-500 shrink-0">#{ entry.id } · r{ entry.rank }</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: recentUsers.map(entry => (
|
||||
<button
|
||||
key={ entry.id }
|
||||
className="w-full flex items-center gap-2 px-2 py-1 text-left text-xs hover:bg-sky-50 border-b border-zinc-100 last:border-b-0"
|
||||
onMouseDown={ event => event.preventDefault() }
|
||||
onClick={ () =>
|
||||
{
|
||||
setQuery(entry.label);
|
||||
setIsFocused(false);
|
||||
lookupUserById(entry.id);
|
||||
} }>
|
||||
<span className="text-[10px] text-zinc-400 uppercase shrink-0">recent</span>
|
||||
<span className="font-medium truncate grow">{ entry.label }</span>
|
||||
<span className="text-[10px] text-zinc-500 shrink-0">#{ entry.id }</span>
|
||||
</button>
|
||||
)) }
|
||||
</div> }
|
||||
</div>
|
||||
|
||||
{/* Bulk selection footer — appears whenever at least one user
|
||||
is checked in the autocomplete dropdown. Applies the same
|
||||
sanction (using the current reason + duration controls
|
||||
below) to every selected user; ≥5 selected triggers a
|
||||
themed confirm modal. */}
|
||||
{ selectedUserIds.length > 0 &&
|
||||
<div className="flex items-center gap-1 flex-wrap rounded border border-sky-300 bg-sky-50 p-1.5">
|
||||
<span className="text-[10px] uppercase tracking-wide font-semibold text-sky-800 mr-1">
|
||||
{ LocalizeText('housekeeping.bulk.label', [ 'count' ], [ String(selectedUserIds.length) ]) }
|
||||
</span>
|
||||
<Button size="sm" variant="danger" disabled={ isActionPending } onClick={ bulkBan }>
|
||||
<FaBan size={ 10 } />
|
||||
<span className="ml-1">{ LocalizeText('housekeeping.action.ban_h', [ 'h' ], [ String(banHours) ]) }</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="warning" disabled={ isActionPending } onClick={ bulkMute }>
|
||||
<FaVolumeMute size={ 10 } />
|
||||
<span className="ml-1">{ LocalizeText('housekeeping.action.mute_min', [ 'm' ], [ String(muteMinutes) ]) }</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="warning" disabled={ isActionPending } onClick={ bulkKick }>
|
||||
<FaUserSlash size={ 10 } />
|
||||
<span className="ml-1">{ LocalizeText('housekeeping.action.kick') }</span>
|
||||
</Button>
|
||||
<button
|
||||
className="ml-auto text-zinc-500 hover:text-rose-600 px-1"
|
||||
onClick={ clearUserSelection }
|
||||
title={ LocalizeText('housekeeping.bulk.clear') }>
|
||||
<FaTimes size={ 10 } />
|
||||
</button>
|
||||
</div> }
|
||||
|
||||
{ /* Selected user hero card */ }
|
||||
{ selectedUser
|
||||
? (
|
||||
<div className="relative overflow-hidden rounded-lg border border-sky-200 bg-gradient-to-br from-sky-50 via-white to-emerald-50 p-3 shadow-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
{ /* Live avatar head — renders the selected user's
|
||||
in-game figure as a head crop. Falls back to
|
||||
the modtools sprite when figure is empty (e.g.
|
||||
a never-logged-in account).
|
||||
|
||||
Implementation note: `LayoutAvatarImageView`
|
||||
is internally a fixed 90x130 background-image
|
||||
box anchored at left:-2px (see
|
||||
`src/common/layout/LayoutAvatarImageView.tsx`).
|
||||
The earlier approach of forcing the box to
|
||||
28x28 + transform:scale(0.42) was wrong on
|
||||
two counts: clamping width/height clipped
|
||||
the head BEFORE the transform ran, and the
|
||||
transform-origin centered the residual on
|
||||
a 28x28 layout that no longer contained
|
||||
anything visible — the bubble rendered empty.
|
||||
|
||||
Correct pattern (same one `GroupMembersView`
|
||||
uses for its 40x50 head bubbles): leave the
|
||||
avatar element at its natural 90x130 size,
|
||||
position it absolutely with negative offsets
|
||||
so the head sits centered in the viewport,
|
||||
and let `overflow-hidden` on the parent crop
|
||||
the rest. No scale, no width override. */ }
|
||||
<div className="relative rounded-full bg-sky-100 ring-2 ring-sky-200 shrink-0 w-[50px] h-[50px] overflow-hidden">
|
||||
{ selectedUser.figure
|
||||
? <LayoutAvatarImageView classNames={ [ '!absolute', '!-left-[20px]', '!-top-[20px]' ] } direction={ 2 } figure={ selectedUser.figure } headOnly={ true } />
|
||||
: <span className="absolute inset-0 m-auto nitro-icon nitro-icon-hk-hero icon-modtools" /> }
|
||||
</div>
|
||||
<div className="grow min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="font-bold text-base truncate">{ selectedUser.username }</span>
|
||||
<span className="text-[10px] text-zinc-500 tabular-nums">#{ selectedUser.id }</span>
|
||||
<span className={ `inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-semibold border ${ selectedUser.online ? 'bg-emerald-50 border-emerald-200 text-emerald-700' : 'bg-zinc-100 border-zinc-200 text-zinc-500' }` }>
|
||||
<FaCircle size={ 6 } className={ selectedUser.online ? 'animate-pulse' : '' } />
|
||||
{ selectedUser.online ? 'online' : 'offline' }
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-violet-100 border border-violet-200 text-violet-700">
|
||||
<FaIdBadge size={ 8 } />
|
||||
{ selectedUser.rankName } · r{ selectedUser.rank }
|
||||
</span>
|
||||
{ selectedUser.isBanned &&
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-rose-100 border border-rose-200 text-rose-700"><FaBan size={ 8 } /> banned</span> }
|
||||
{ selectedUser.isMuted &&
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-amber-100 border border-amber-200 text-amber-700"><FaVolumeMute size={ 8 } /> muted</span> }
|
||||
{ selectedUser.isTradeLocked &&
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-fuchsia-100 border border-fuchsia-200 text-fuchsia-700"><FaLock size={ 8 } /> trade-lock</span> }
|
||||
</div>
|
||||
<div className="text-xs text-zinc-600 truncate mt-0.5 italic">{ selectedUser.motto || '—' }</div>
|
||||
<div className="grid grid-cols-3 gap-1 text-[10px] mt-2">
|
||||
{ /* LayoutCurrencyIcon resolves `currency.asset.icon.url`
|
||||
(`${images.url}/wallet/%type%.png`) — type=-1 is the
|
||||
credits coin, type=0 the ducket/pixel, type=5 the
|
||||
diamond. Sizes default to 15x15 which fits the badge
|
||||
row without extra overrides. */ }
|
||||
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-50 border border-amber-200" title={ LocalizeText('housekeeping.user.credits') }>
|
||||
<LayoutCurrencyIcon type={ -1 } />
|
||||
<span className="tabular-nums font-semibold text-amber-800">{ selectedUser.creditsBalance.toLocaleString() }</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-orange-50 border border-orange-200" title={ LocalizeText('housekeeping.user.duckets') }>
|
||||
<LayoutCurrencyIcon type={ 0 } />
|
||||
<span className="tabular-nums font-semibold text-orange-800">{ selectedUser.ducketsBalance.toLocaleString() }</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-sky-50 border border-sky-200" title={ LocalizeText('housekeeping.user.diamonds') }>
|
||||
<LayoutCurrencyIcon type={ 5 } />
|
||||
<span className="tabular-nums font-semibold text-sky-800">{ selectedUser.diamondsBalance.toLocaleString() }</span>
|
||||
</div>
|
||||
</div>
|
||||
{ selectedUser.email &&
|
||||
<div className="flex items-center gap-1 text-[10px] text-zinc-500 mt-1.5 truncate" title={ selectedUser.email }>
|
||||
<FaEnvelope size={ 8 } />
|
||||
<span className="truncate">{ selectedUser.email }</span>
|
||||
</div> }
|
||||
</div>
|
||||
<button
|
||||
className="text-zinc-400 hover:text-rose-600 transition-colors p-1"
|
||||
onClick={ () => setSelectedUser(null) }
|
||||
title={ LocalizeText('housekeeping.user.clear') }>
|
||||
<FaTimes size={ 12 } />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-dashed border-zinc-300 bg-zinc-50/50 p-3 text-xs text-zinc-500 italic">
|
||||
<FaUserSlash size={ 14 } />
|
||||
{ LocalizeText('housekeeping.user.none') }
|
||||
</div>
|
||||
) }
|
||||
|
||||
{ /* Live in-room actions */ }
|
||||
{ selectedUser && selectedUser.online &&
|
||||
<div className="rounded-md border border-amber-200 bg-gradient-to-r from-amber-50 to-orange-50 p-1.5 flex items-center gap-1 flex-wrap shadow-sm">
|
||||
<span className="text-[10px] uppercase tracking-wider font-bold text-amber-800 mr-1 flex items-center gap-1">
|
||||
<FaBolt size={ 9 } className="text-amber-500" />
|
||||
{ LocalizeText('housekeeping.user.live.label') }
|
||||
</span>
|
||||
<Button size="sm" variant="warning" disabled={ isActionPending } onClick={ () => kickFromCurrentRoom(selectedUser.id) }>
|
||||
{ LocalizeText('housekeeping.user.live.kick') }
|
||||
</Button>
|
||||
<Button size="sm" variant="warning" disabled={ isActionPending } onClick={ () => muteInCurrentRoom(selectedUser.id, 2) }>
|
||||
{ LocalizeText('housekeeping.user.live.mute_2m') }
|
||||
</Button>
|
||||
<Button size="sm" variant="warning" disabled={ isActionPending } onClick={ () => muteInCurrentRoom(selectedUser.id, 10) }>
|
||||
{ LocalizeText('housekeeping.user.live.mute_10m') }
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" disabled={ isActionPending } onClick={ () => banFromCurrentRoom(selectedUser.id, 'hour') }>
|
||||
{ LocalizeText('housekeeping.user.live.ban_h') }
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" disabled={ isActionPending } onClick={ () => banFromCurrentRoom(selectedUser.id, 'day') }>
|
||||
{ LocalizeText('housekeeping.user.live.ban_d') }
|
||||
</Button>
|
||||
</div> }
|
||||
|
||||
{ /* Sanction template + Reason — grouped surface */ }
|
||||
<div className="flex flex-col gap-1.5 rounded-md border border-zinc-200 bg-zinc-50/50 p-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<label className="text-[10px] uppercase tracking-wider font-semibold opacity-60 shrink-0">Template</label>
|
||||
<select
|
||||
className="grow px-1.5 py-0.5 rounded border border-zinc-300 bg-white text-xs focus:outline-none focus:ring-1 focus:ring-sky-400"
|
||||
value={ templateId }
|
||||
onChange={ event => applyTemplate(event.target.value) }>
|
||||
<option value="">—</option>
|
||||
{ HK_SANCTION_TEMPLATES.map(template => (
|
||||
<option key={ template.id } value={ template.id }>{ template.name }</option>
|
||||
)) }
|
||||
</select>
|
||||
</div>
|
||||
<label className="text-[10px] uppercase tracking-wider font-semibold opacity-60">{ LocalizeText('housekeeping.field.reason') }</label>
|
||||
<textarea
|
||||
className="min-h-[48px] px-2 py-1 rounded text-sm border border-zinc-300 bg-white focus:outline-none focus:ring-1 focus:ring-sky-400 placeholder:text-zinc-400"
|
||||
placeholder={ LocalizeText('housekeeping.field.reason.placeholder') }
|
||||
value={ reason }
|
||||
onChange={ event => setReason(event.target.value) } />
|
||||
</div>
|
||||
|
||||
{ /* Sanctions */ }
|
||||
<label className="text-[10px] uppercase tracking-wider font-semibold opacity-60 -mb-0.5">{ LocalizeText('housekeeping.field.duration') }</label>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<div className="flex items-center gap-1 rounded-md border border-rose-200 bg-rose-50/40 px-1.5 py-1">
|
||||
<input
|
||||
type="number"
|
||||
min={ 1 }
|
||||
className="w-14 px-1 py-0.5 rounded border border-rose-200 bg-white text-xs tabular-nums focus:outline-none focus:ring-1 focus:ring-rose-400"
|
||||
value={ banHours }
|
||||
onChange={ event => setBanHours(parseInt(event.target.value) || 0) } />
|
||||
<span className="text-[10px] text-rose-700">h</span>
|
||||
<Button variant="danger" disabled={ disableActions } className="grow ml-auto" onClick={ () => banUser(selectedUser.id, reasonOrDefault, banHours) }>
|
||||
<FaBan size={ 10 } />
|
||||
<span className="ml-1">{ LocalizeText('housekeeping.action.ban_h', [ 'h' ], [ String(banHours) ]) }</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-md border border-amber-200 bg-amber-50/40 px-1.5 py-1">
|
||||
<input
|
||||
type="number"
|
||||
min={ 1 }
|
||||
className="w-14 px-1 py-0.5 rounded border border-amber-200 bg-white text-xs tabular-nums focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
value={ muteMinutes }
|
||||
onChange={ event => setMuteMinutes(parseInt(event.target.value) || 0) } />
|
||||
<span className="text-[10px] text-amber-700">m</span>
|
||||
<Button variant="warning" disabled={ disableActions } className="grow ml-auto" onClick={ () => muteUser(selectedUser.id, reasonOrDefault, muteMinutes) }>
|
||||
<FaVolumeMute size={ 10 } />
|
||||
<span className="ml-1">{ LocalizeText('housekeeping.action.mute_min', [ 'm' ], [ String(muteMinutes) ]) }</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-md border border-fuchsia-200 bg-fuchsia-50/40 px-1.5 py-1">
|
||||
<input
|
||||
type="number"
|
||||
min={ 1 }
|
||||
className="w-14 px-1 py-0.5 rounded border border-fuchsia-200 bg-white text-xs tabular-nums focus:outline-none focus:ring-1 focus:ring-fuchsia-400"
|
||||
value={ tradeLockHours }
|
||||
onChange={ event => setTradeLockHours(parseInt(event.target.value) || 0) } />
|
||||
<span className="text-[10px] text-fuchsia-700">h</span>
|
||||
<Button variant="secondary" disabled={ disableActions } className="grow ml-auto" onClick={ () => tradeLockUser(selectedUser.id, tradeLockHours, reasonOrDefault) }>
|
||||
<FaLock size={ 10 } />
|
||||
<span className="ml-1">{ LocalizeText('housekeeping.action.trade_lock_h', [ 'h' ], [ String(tradeLockHours) ]) }</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="warning" disabled={ disableActions } onClick={ () => kickUser(selectedUser.id, reasonOrDefault) }>
|
||||
<FaUserSlash size={ 10 } />
|
||||
<span className="ml-1">{ LocalizeText('housekeeping.action.kick') }</span>
|
||||
</Button>
|
||||
<Button variant="danger" disabled={ disableActions || !selectedUser?.isBanned } onClick={ () => unbanUser(selectedUser.id) }>
|
||||
<FaExclamationTriangle size={ 10 } />
|
||||
<span className="ml-1">{ LocalizeText('housekeeping.action.unban') }</span>
|
||||
</Button>
|
||||
<Button variant="danger" disabled={ disableActions } onClick={ () => forceDisconnectUser(selectedUser.id, reasonOrDefault) }>
|
||||
<FaPlug size={ 10 } />
|
||||
<span className="ml-1">{ LocalizeText('housekeeping.action.force_disconnect') }</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{ /* Rank + password — privileged actions card */ }
|
||||
<div className="grid grid-cols-2 gap-1.5 rounded-md border border-violet-200 bg-violet-50/40 p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="number"
|
||||
min={ 1 }
|
||||
max={ 12 }
|
||||
className="w-14 px-1.5 py-0.5 rounded border border-violet-200 bg-white text-xs tabular-nums focus:outline-none focus:ring-1 focus:ring-violet-400"
|
||||
value={ rankDraft }
|
||||
onChange={ event => setRankDraft(parseInt(event.target.value) || 0) } />
|
||||
<Button variant="primary" disabled={ disableActions } className="grow" onClick={ () => setUserRank(selectedUser.id, rankDraft) }>
|
||||
<FaUserShield size={ 10 } />
|
||||
<span className="ml-1">{ LocalizeText('housekeeping.action.set_rank') }</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="dark" disabled={ disableActions } onClick={ () => resetUserPassword(selectedUser.id) }>
|
||||
<FaKey size={ 10 } />
|
||||
<span className="ml-1">{ LocalizeText('housekeeping.action.reset_password') }</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] text-zinc-500 italic pt-1 border-t border-zinc-200 flex items-center gap-1">
|
||||
<FaEnvelope size={ 9 } className="opacity-50" />
|
||||
{ LocalizeText('housekeeping.user.audit_hint') }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user