mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-20 15:36:18 +00:00
eeab548917
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.
199 lines
12 KiB
TypeScript
199 lines
12 KiB
TypeScript
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>
|
|
);
|
|
};
|