mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
@@ -40,18 +40,27 @@ jobs:
|
||||
path: Nitro-V3
|
||||
|
||||
# Pick the renderer ref dynamically based on the client context.
|
||||
# Renderer repo is always upstream `duckietm/Nitro_Render_V3` —
|
||||
# the two repos must stay wire-aligned (composer/parser
|
||||
# The two repos must stay wire-aligned (composer/parser
|
||||
# signatures); pairing `main` with a stale branch is what
|
||||
# produced the "Expected 14-15 arguments, but got 16" failure on
|
||||
# the catalog edit composer.
|
||||
#
|
||||
# This branch (`feat/housekeeping-panel`) references HK composers
|
||||
# /events that live on the renderer PR branch
|
||||
# (simoleo89/Nitro_Render_V3 @ feat/housekeeping-packets) — they
|
||||
# haven't been merged upstream yet. Pair against the fork branch
|
||||
# for this PR so the typecheck step can resolve the imports;
|
||||
# once the renderer PR lands on duckietm:Dev this whole
|
||||
# special-case block can be dropped.
|
||||
#
|
||||
# Mapping:
|
||||
# client `main` → duckietm/Nitro_Render_V3 @ main
|
||||
# client `feat/**` → duckietm/Nitro_Render_V3 @ Dev
|
||||
# PR base `main` → duckietm/Nitro_Render_V3 @ main
|
||||
# PR base `Dev` (upstream) → duckietm/Nitro_Render_V3 @ Dev
|
||||
# PR base `feat/**` → duckietm/Nitro_Render_V3 @ Dev
|
||||
# client `main` → duckietm/Nitro_Render_V3 @ main
|
||||
# client `feat/housekeeping-panel` → simoleo89/Nitro_Render_V3 @ feat/housekeeping-packets
|
||||
# client `feat/**` (other) → duckietm/Nitro_Render_V3 @ Dev
|
||||
# PR base `main` → duckietm/Nitro_Render_V3 @ main
|
||||
# PR head `feat/housekeeping-panel` → simoleo89/Nitro_Render_V3 @ feat/housekeeping-packets
|
||||
# PR base `Dev` (upstream) → duckietm/Nitro_Render_V3 @ Dev
|
||||
# PR base `feat/**` → duckietm/Nitro_Render_V3 @ Dev
|
||||
#
|
||||
# Override via workflow_dispatch inputs when you need an ad-hoc
|
||||
# pairing.
|
||||
@@ -62,21 +71,34 @@ jobs:
|
||||
REF="${{ github.event.inputs.renderer_ref }}"
|
||||
|
||||
if [ -z "$REPO" ] || [ -z "$REF" ]; then
|
||||
# For PRs we usually pair against the base ref, but the HK
|
||||
# PR specifically needs to pair against its OWN head ref —
|
||||
# the renderer companion PR is named identically
|
||||
# (`feat/housekeeping-packets`) and lives on the same fork.
|
||||
case "${GITHUB_EVENT_NAME}" in
|
||||
pull_request)
|
||||
CTX="${GITHUB_BASE_REF}"
|
||||
if [ "${GITHUB_HEAD_REF}" = "feat/housekeeping-panel" ]; then
|
||||
CTX="${GITHUB_HEAD_REF}"
|
||||
else
|
||||
CTX="${GITHUB_BASE_REF}"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
CTX="${GITHUB_REF_NAME}"
|
||||
;;
|
||||
esac
|
||||
|
||||
AUTO_REPO="duckietm/Nitro_Render_V3"
|
||||
case "$CTX" in
|
||||
main)
|
||||
AUTO_REPO="duckietm/Nitro_Render_V3"
|
||||
AUTO_REF="main"
|
||||
;;
|
||||
feat/housekeeping-panel)
|
||||
AUTO_REPO="simoleo89/Nitro_Render_V3"
|
||||
AUTO_REF="feat/housekeeping-packets"
|
||||
;;
|
||||
*)
|
||||
AUTO_REPO="duckietm/Nitro_Render_V3"
|
||||
AUTO_REF="Dev"
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
{
|
||||
"housekeeping.title": "Housekeeping",
|
||||
"housekeeping.mode.light": "Light",
|
||||
|
||||
"housekeeping.tab.dashboard": "Dashboard",
|
||||
"housekeeping.tab.users": "Users",
|
||||
"housekeeping.tab.rooms": "Rooms",
|
||||
"housekeeping.tab.economy": "Economy",
|
||||
"housekeeping.tab.audit": "Audit",
|
||||
|
||||
"housekeeping.confirm.title": "Confirm action",
|
||||
"housekeeping.confirm.proceed": "Proceed",
|
||||
"housekeeping.confirm.cancel": "Cancel",
|
||||
"housekeeping.status.dismiss": "Dismiss",
|
||||
|
||||
"housekeeping.action.pending": "Action pending…",
|
||||
"housekeeping.action.success": "Action completed",
|
||||
"housekeeping.action.error": "Action failed",
|
||||
"housekeeping.action.reset_password.done": "Password reset — new password shown below.",
|
||||
"housekeeping.password.title": "%username% (#%id%) · new password",
|
||||
"housekeeping.password.value_label": "Generated password",
|
||||
"housekeeping.password.copy": "Copy",
|
||||
"housekeeping.password.copied": "Copied",
|
||||
"housekeeping.password.copy_failed": "Copy failed",
|
||||
"housekeeping.password.dismiss": "Dismiss",
|
||||
"housekeeping.password.hint": "Share with the user out-of-band. This is shown once — close this card when you're done; the password is never displayed again.",
|
||||
"housekeeping.error.invalid_input": "Invalid input — check the user id and the value you provided.",
|
||||
"housekeeping.error.user_not_found": "User not found.",
|
||||
"housekeeping.error.user_offline": "User is offline — this action only works on online users.",
|
||||
"housekeeping.error.target_unkickable": "This user cannot be kicked.",
|
||||
"housekeeping.error.ban_failed": "Ban could not be applied — server refused the request.",
|
||||
"housekeeping.error.no_active_ban": "No active ban to clear for this user.",
|
||||
"housekeeping.error.rank_not_found": "Rank not found — pick a rank that exists in permission_ranks.",
|
||||
"housekeeping.error.db_failed": "Database error — see the emulator log for the SQL exception.",
|
||||
"housekeeping.error.hash_failed": "Could not hash the new password — SHA-256 unavailable on this JVM.",
|
||||
"housekeeping.error.room_not_found": "Room not found.",
|
||||
"housekeeping.error.room_action_failed": "Room action could not be applied.",
|
||||
"housekeeping.error.new_owner_not_found": "New owner not found.",
|
||||
"housekeeping.error.economy_failed": "Economy action could not be applied — check the user id and the amount.",
|
||||
"housekeeping.error.alert_empty": "Hotel alert message cannot be empty.",
|
||||
"housekeeping.action.ban_h": "Ban %h%h",
|
||||
"housekeeping.action.mute_min": "Mute %m%m",
|
||||
"housekeeping.action.trade_lock_h": "Trade lock %h%h",
|
||||
"housekeeping.action.kick": "Kick",
|
||||
"housekeeping.action.unban": "Unban",
|
||||
"housekeeping.action.force_disconnect": "Force disconnect",
|
||||
"housekeeping.action.set_rank": "Set rank",
|
||||
"housekeeping.action.reset_password": "Reset password",
|
||||
|
||||
"housekeeping.user.search.placeholder": "Search by username…",
|
||||
"housekeeping.user.search.button": "Search",
|
||||
"housekeeping.user.clear": "Clear selection",
|
||||
"housekeeping.user.none": "No user selected — search above to pick one.",
|
||||
"housekeeping.user.not_found": "User not found.",
|
||||
"housekeeping.user.credits": "Credits",
|
||||
"housekeeping.user.duckets": "Duckets / pixels",
|
||||
"housekeeping.user.diamonds": "Diamonds",
|
||||
"housekeeping.user.audit_hint": "All actions are logged in the audit tab.",
|
||||
"housekeeping.user.live.label": "Live (in current room)",
|
||||
"housekeeping.user.live.kick": "Kick",
|
||||
"housekeeping.user.live.mute_2m": "Mute 2m",
|
||||
"housekeeping.user.live.mute_10m": "Mute 10m",
|
||||
"housekeeping.user.live.ban_h": "Ban 1h",
|
||||
"housekeeping.user.live.ban_d": "Ban 1d",
|
||||
|
||||
"housekeeping.room.search.placeholder": "Room ID…",
|
||||
"housekeeping.room.search.button": "Search",
|
||||
"housekeeping.room.clear": "Clear selection",
|
||||
"housekeeping.room.none": "No room selected — enter an ID above.",
|
||||
"housekeeping.room.not_found": "Room not found.",
|
||||
"housekeeping.room.open": "Open",
|
||||
"housekeeping.room.close": "Close",
|
||||
"housekeeping.room.mute_min": "Mute %m%m",
|
||||
"housekeeping.room.kick_all": "Kick all",
|
||||
"housekeeping.room.kick_all.confirm": "Kick every user currently in the room?",
|
||||
"housekeeping.room.delete": "Delete room",
|
||||
"housekeeping.room.delete.confirm": "Delete this room and all its furniture permanently?",
|
||||
"housekeeping.room.transfer": "Transfer",
|
||||
"housekeeping.room.transfer.label": "Transfer ownership",
|
||||
"housekeeping.room.transfer.new_owner": "New owner ID",
|
||||
|
||||
"housekeeping.economy.select_user": "Pick a user in the Users tab first.",
|
||||
"housekeeping.economy.target": "Target: %username% (#%id%)",
|
||||
"housekeeping.economy.give_credits": "Give credits",
|
||||
"housekeeping.economy.give_duckets": "Give duckets",
|
||||
"housekeeping.economy.give_diamonds": "Give diamonds",
|
||||
"housekeeping.economy.grant_item": "Grant item",
|
||||
"housekeeping.economy.grant_item.label": "Grant catalog item",
|
||||
"housekeeping.economy.item_id": "Item ID",
|
||||
"housekeeping.economy.item_quantity": "Qty",
|
||||
"housekeeping.economy.set_hc_days": "Set HC days",
|
||||
|
||||
"housekeeping.hotel.alert.label": "Hotel-wide alert",
|
||||
"housekeeping.hotel.alert.placeholder": "Message broadcast to every connected user…",
|
||||
"housekeeping.hotel.alert.send": "Send to hotel",
|
||||
"housekeeping.hotel.alert.confirm": "Broadcast %count%-character alert to every connected user?",
|
||||
|
||||
"housekeeping.dashboard.title": "Overview",
|
||||
"housekeeping.dashboard.refresh": "Refresh",
|
||||
"housekeeping.dashboard.loading": "Loading dashboard…",
|
||||
"housekeeping.dashboard.unavailable": "Dashboard unavailable — check the admin endpoint.",
|
||||
"housekeeping.dashboard.online": "Online",
|
||||
"housekeeping.dashboard.total_users": "%count% total",
|
||||
"housekeeping.dashboard.rooms_active": "Active rooms",
|
||||
"housekeeping.dashboard.total_rooms": "%count% total",
|
||||
"housekeeping.dashboard.peak_today": "Peak today",
|
||||
"housekeeping.dashboard.peak_alltime": "All-time peak %count%",
|
||||
"housekeeping.dashboard.pending_tickets": "Tickets",
|
||||
"housekeeping.dashboard.sanctions_24h": "%count% sanctions / 24h",
|
||||
"housekeeping.dashboard.server": "Server",
|
||||
"housekeeping.dashboard.recent_sanctions": "Recent sanctions",
|
||||
"housekeeping.dashboard.recent_lookups": "Recent lookups",
|
||||
|
||||
"housekeeping.audit.title": "Audit log",
|
||||
"housekeeping.audit.refresh": "Refresh",
|
||||
"housekeeping.audit.filter.all": "All",
|
||||
"housekeeping.audit.filter.users": "Users",
|
||||
"housekeeping.audit.filter.rooms": "Rooms",
|
||||
"housekeeping.audit.filter.hotel": "Hotel",
|
||||
"housekeeping.audit.search.placeholder": "Search actor / target / action…",
|
||||
"housekeeping.audit.empty": "No audit entries yet.",
|
||||
"housekeeping.audit.no_match": "No entries match the current filters.",
|
||||
|
||||
"housekeeping.field.reason": "Reason",
|
||||
"housekeeping.field.reason.placeholder": "Free-text reason (optional)",
|
||||
"housekeeping.field.duration": "Duration",
|
||||
"housekeeping.reason.default": "No reason provided.",
|
||||
|
||||
"housekeeping.menu.send_to_hk": "Send to HK",
|
||||
|
||||
"housekeeping.bulk.done": "Bulk done",
|
||||
"housekeeping.bulk.success": "All bulk actions succeeded.",
|
||||
"housekeeping.bulk.partial": "Bulk completed with some failures.",
|
||||
"housekeeping.bulk.failed": "Every bulk action failed.",
|
||||
"housekeeping.bulk.confirm": "Apply %action% to %count% selected users?",
|
||||
"housekeeping.bulk.label": "%count% selected",
|
||||
"housekeeping.bulk.clear": "Clear selection",
|
||||
"housekeeping.bulk.apply": "Apply to selected",
|
||||
|
||||
"housekeeping.telemetry.title": "Telemetry",
|
||||
"housekeeping.telemetry.empty": "No actions observed yet.",
|
||||
"housekeeping.telemetry.reset": "Reset metrics",
|
||||
|
||||
"housekeeping.live.no_room": "No active room session.",
|
||||
"housekeeping.live.kicked": "Kicked from room.",
|
||||
"housekeeping.live.banned": "Banned from room.",
|
||||
"housekeeping.live.muted": "Muted in room.",
|
||||
|
||||
"housekeeping.validation.empty_username": "Username can't be empty.",
|
||||
"housekeeping.validation.invalid_user_id": "Invalid user ID.",
|
||||
"housekeeping.validation.invalid_room_id": "Invalid room ID.",
|
||||
"housekeeping.validation.invalid_amount": "Invalid amount.",
|
||||
"housekeeping.validation.amount_too_large": "Amount exceeds the safety cap.",
|
||||
"housekeeping.validation.empty_reason": "Reason can't be empty.",
|
||||
"housekeeping.validation.invalid_hours": "Invalid duration in hours.",
|
||||
"housekeeping.validation.invalid_rank": "Invalid rank — must be between 1 and 12."
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
{
|
||||
"housekeeping.title": "Housekeeping",
|
||||
"housekeeping.mode.light": "Light",
|
||||
|
||||
"housekeeping.tab.dashboard": "Dashboard",
|
||||
"housekeeping.tab.users": "Utenti",
|
||||
"housekeeping.tab.rooms": "Stanze",
|
||||
"housekeeping.tab.economy": "Economia",
|
||||
"housekeeping.tab.audit": "Audit",
|
||||
|
||||
"housekeeping.confirm.title": "Conferma azione",
|
||||
"housekeeping.confirm.proceed": "Procedi",
|
||||
"housekeeping.confirm.cancel": "Annulla",
|
||||
"housekeeping.status.dismiss": "Chiudi",
|
||||
|
||||
"housekeeping.action.pending": "Azione in corso…",
|
||||
"housekeeping.action.success": "Azione completata",
|
||||
"housekeeping.action.error": "Azione fallita",
|
||||
"housekeeping.action.reset_password.done": "Password resettata — la nuova password è mostrata sotto.",
|
||||
"housekeeping.password.title": "%username% (#%id%) · nuova password",
|
||||
"housekeeping.password.value_label": "Password generata",
|
||||
"housekeeping.password.copy": "Copia",
|
||||
"housekeeping.password.copied": "Copiata",
|
||||
"housekeeping.password.copy_failed": "Copia fallita",
|
||||
"housekeeping.password.dismiss": "Chiudi",
|
||||
"housekeeping.password.hint": "Condividila con l'utente fuori dal client. La password viene mostrata una sola volta — chiudi questa card quando hai finito, non sarà più visibile.",
|
||||
"housekeeping.error.invalid_input": "Input non valido — controlla l'id utente e il valore inserito.",
|
||||
"housekeeping.error.user_not_found": "Utente non trovato.",
|
||||
"housekeeping.error.user_offline": "Utente offline — questa azione funziona solo sugli utenti online.",
|
||||
"housekeeping.error.target_unkickable": "Questo utente non può essere espulso.",
|
||||
"housekeeping.error.ban_failed": "Impossibile applicare il ban — il server ha rifiutato la richiesta.",
|
||||
"housekeeping.error.no_active_ban": "Nessun ban attivo da rimuovere per questo utente.",
|
||||
"housekeeping.error.rank_not_found": "Rank non trovato — scegli un rank presente in permission_ranks.",
|
||||
"housekeeping.error.db_failed": "Errore database — controlla il log dell'emulatore per l'eccezione SQL.",
|
||||
"housekeeping.error.hash_failed": "Impossibile generare l'hash della nuova password — SHA-256 non disponibile sulla JVM.",
|
||||
"housekeeping.error.room_not_found": "Stanza non trovata.",
|
||||
"housekeeping.error.room_action_failed": "Impossibile applicare l'azione sulla stanza.",
|
||||
"housekeeping.error.new_owner_not_found": "Nuovo proprietario non trovato.",
|
||||
"housekeeping.error.economy_failed": "Impossibile applicare l'azione economy — controlla id utente e importo.",
|
||||
"housekeeping.error.alert_empty": "Il messaggio di hotel-alert non può essere vuoto.",
|
||||
"housekeeping.action.ban_h": "Ban %h%h",
|
||||
"housekeeping.action.mute_min": "Mute %m%m",
|
||||
"housekeeping.action.trade_lock_h": "Blocco scambi %h%h",
|
||||
"housekeeping.action.kick": "Kick",
|
||||
"housekeeping.action.unban": "Sblocca",
|
||||
"housekeeping.action.force_disconnect": "Forza disconnessione",
|
||||
"housekeeping.action.set_rank": "Imposta rank",
|
||||
"housekeeping.action.reset_password": "Reset password",
|
||||
|
||||
"housekeeping.user.search.placeholder": "Cerca per nome…",
|
||||
"housekeeping.user.search.button": "Cerca",
|
||||
"housekeeping.user.clear": "Deseleziona",
|
||||
"housekeeping.user.none": "Nessun utente selezionato — cerca uno qui sopra.",
|
||||
"housekeeping.user.not_found": "Utente non trovato.",
|
||||
"housekeeping.user.credits": "Crediti",
|
||||
"housekeeping.user.duckets": "Pixel / duckets",
|
||||
"housekeeping.user.diamonds": "Diamanti",
|
||||
"housekeeping.user.audit_hint": "Ogni azione viene registrata nel tab Audit.",
|
||||
"housekeeping.user.live.label": "Live (stanza corrente)",
|
||||
"housekeeping.user.live.kick": "Kick",
|
||||
"housekeeping.user.live.mute_2m": "Mute 2m",
|
||||
"housekeeping.user.live.mute_10m": "Mute 10m",
|
||||
"housekeeping.user.live.ban_h": "Ban 1h",
|
||||
"housekeeping.user.live.ban_d": "Ban 1g",
|
||||
|
||||
"housekeeping.room.search.placeholder": "ID stanza…",
|
||||
"housekeeping.room.search.button": "Cerca",
|
||||
"housekeeping.room.clear": "Deseleziona",
|
||||
"housekeeping.room.none": "Nessuna stanza selezionata — inserisci un ID.",
|
||||
"housekeeping.room.not_found": "Stanza non trovata.",
|
||||
"housekeeping.room.open": "Apri",
|
||||
"housekeeping.room.close": "Chiudi",
|
||||
"housekeeping.room.mute_min": "Mute %m%m",
|
||||
"housekeeping.room.kick_all": "Kick tutti",
|
||||
"housekeeping.room.kick_all.confirm": "Cacciare ogni utente attualmente nella stanza?",
|
||||
"housekeeping.room.delete": "Elimina stanza",
|
||||
"housekeeping.room.delete.confirm": "Eliminare la stanza e tutti i suoi mobili in modo permanente?",
|
||||
"housekeeping.room.transfer": "Trasferisci",
|
||||
"housekeeping.room.transfer.label": "Trasferisci proprietà",
|
||||
"housekeeping.room.transfer.new_owner": "ID nuovo owner",
|
||||
|
||||
"housekeeping.economy.select_user": "Seleziona prima un utente nel tab Utenti.",
|
||||
"housekeeping.economy.target": "Target: %username% (#%id%)",
|
||||
"housekeeping.economy.give_credits": "Dai crediti",
|
||||
"housekeeping.economy.give_duckets": "Dai duckets",
|
||||
"housekeeping.economy.give_diamonds": "Dai diamanti",
|
||||
"housekeeping.economy.grant_item": "Assegna",
|
||||
"housekeeping.economy.grant_item.label": "Assegna oggetto del catalogo",
|
||||
"housekeeping.economy.item_id": "ID oggetto",
|
||||
"housekeeping.economy.item_quantity": "Q.tà",
|
||||
"housekeeping.economy.set_hc_days": "Imposta giorni HC",
|
||||
|
||||
"housekeeping.hotel.alert.label": "Avviso a tutto l'hotel",
|
||||
"housekeeping.hotel.alert.placeholder": "Messaggio mandato a ogni utente connesso…",
|
||||
"housekeeping.hotel.alert.send": "Invia a tutti",
|
||||
"housekeeping.hotel.alert.confirm": "Trasmettere un avviso di %count% caratteri a ogni utente connesso?",
|
||||
|
||||
"housekeeping.dashboard.title": "Panoramica",
|
||||
"housekeeping.dashboard.refresh": "Aggiorna",
|
||||
"housekeeping.dashboard.loading": "Caricamento dashboard…",
|
||||
"housekeeping.dashboard.unavailable": "Dashboard non disponibile — verifica l'endpoint admin.",
|
||||
"housekeeping.dashboard.online": "Online",
|
||||
"housekeeping.dashboard.total_users": "%count% totali",
|
||||
"housekeeping.dashboard.rooms_active": "Stanze attive",
|
||||
"housekeeping.dashboard.total_rooms": "%count% totali",
|
||||
"housekeeping.dashboard.peak_today": "Picco oggi",
|
||||
"housekeeping.dashboard.peak_alltime": "Picco assoluto %count%",
|
||||
"housekeeping.dashboard.pending_tickets": "Ticket",
|
||||
"housekeeping.dashboard.sanctions_24h": "%count% sanzioni / 24h",
|
||||
"housekeeping.dashboard.server": "Server",
|
||||
"housekeeping.dashboard.recent_sanctions": "Sanzioni recenti",
|
||||
"housekeeping.dashboard.recent_lookups": "Ricerche recenti",
|
||||
|
||||
"housekeeping.audit.title": "Audit log",
|
||||
"housekeeping.audit.refresh": "Aggiorna",
|
||||
"housekeeping.audit.filter.all": "Tutto",
|
||||
"housekeeping.audit.filter.users": "Utenti",
|
||||
"housekeeping.audit.filter.rooms": "Stanze",
|
||||
"housekeeping.audit.filter.hotel": "Hotel",
|
||||
"housekeeping.audit.search.placeholder": "Cerca attore / target / azione…",
|
||||
"housekeeping.audit.empty": "Nessuna voce di audit.",
|
||||
"housekeeping.audit.no_match": "Nessuna voce corrisponde ai filtri.",
|
||||
|
||||
"housekeeping.field.reason": "Motivo",
|
||||
"housekeeping.field.reason.placeholder": "Motivo libero (opzionale)",
|
||||
"housekeeping.field.duration": "Durata",
|
||||
"housekeeping.reason.default": "Nessun motivo fornito.",
|
||||
|
||||
"housekeeping.menu.send_to_hk": "Manda all'HK",
|
||||
|
||||
"housekeeping.bulk.done": "Bulk completato",
|
||||
"housekeeping.bulk.success": "Tutte le azioni bulk sono riuscite.",
|
||||
"housekeeping.bulk.partial": "Bulk terminato con alcuni errori.",
|
||||
"housekeeping.bulk.failed": "Ogni azione bulk è fallita.",
|
||||
"housekeeping.bulk.confirm": "Applicare %action% a %count% utenti selezionati?",
|
||||
"housekeeping.bulk.label": "%count% selezionati",
|
||||
"housekeeping.bulk.clear": "Pulisci selezione",
|
||||
"housekeeping.bulk.apply": "Applica ai selezionati",
|
||||
|
||||
"housekeeping.telemetry.title": "Telemetria",
|
||||
"housekeeping.telemetry.empty": "Nessuna azione ancora osservata.",
|
||||
"housekeeping.telemetry.reset": "Resetta metriche",
|
||||
|
||||
"housekeeping.live.no_room": "Nessuna stanza attiva.",
|
||||
"housekeeping.live.kicked": "Cacciato dalla stanza.",
|
||||
"housekeeping.live.banned": "Bannato dalla stanza.",
|
||||
"housekeeping.live.muted": "Mutato nella stanza.",
|
||||
|
||||
"housekeeping.validation.empty_username": "Il nome utente non può essere vuoto.",
|
||||
"housekeeping.validation.invalid_user_id": "ID utente non valido.",
|
||||
"housekeeping.validation.invalid_room_id": "ID stanza non valido.",
|
||||
"housekeeping.validation.invalid_amount": "Quantità non valida.",
|
||||
"housekeeping.validation.amount_too_large": "Quantità oltre il limite di sicurezza.",
|
||||
"housekeeping.validation.empty_reason": "Il motivo non può essere vuoto.",
|
||||
"housekeeping.validation.invalid_hours": "Durata in ore non valida.",
|
||||
"housekeeping.validation.invalid_rank": "Rank non valido — deve essere tra 1 e 12."
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
"wired.action.mute.user.max.length": 100,
|
||||
"game.center.enabled": false,
|
||||
"guides.enabled": true,
|
||||
"housekeeping.enabled": true,
|
||||
"toolbar.hide.quests": true,
|
||||
"catalog.style.new": true,
|
||||
"show.google.ads": false,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
export const HousekeepingActionType = {
|
||||
USER_ALERT: 'user.alert',
|
||||
USER_MESSAGE: 'user.message',
|
||||
USER_KICK: 'user.kick',
|
||||
USER_MUTE: 'user.mute',
|
||||
USER_BAN: 'user.ban',
|
||||
USER_TRADE_LOCK: 'user.trade_lock',
|
||||
USER_CHANGE_RANK: 'user.change_rank',
|
||||
USER_FORCE_DISCONNECT: 'user.force_disconnect',
|
||||
USER_RESET_PASSWORD: 'user.reset_password',
|
||||
USER_UNBAN: 'user.unban',
|
||||
ROOM_OPEN: 'room.open',
|
||||
ROOM_CLOSE: 'room.close',
|
||||
ROOM_KICK_ALL: 'room.kick_all',
|
||||
ROOM_TRANSFER_OWNERSHIP: 'room.transfer_ownership',
|
||||
ROOM_DELETE: 'room.delete',
|
||||
ROOM_MUTE: 'room.mute',
|
||||
ECONOMY_GIVE_CREDITS: 'economy.give_credits',
|
||||
ECONOMY_GIVE_DUCKETS: 'economy.give_duckets',
|
||||
ECONOMY_GIVE_DIAMONDS: 'economy.give_diamonds',
|
||||
ECONOMY_GRANT_ITEM: 'economy.grant_item',
|
||||
ECONOMY_SET_HC: 'economy.set_hc',
|
||||
ECONOMY_HOTEL_ALERT: 'economy.hotel_alert'
|
||||
} as const;
|
||||
|
||||
export type HousekeepingActionType = typeof HousekeepingActionType[keyof typeof HousekeepingActionType];
|
||||
|
||||
export const HousekeepingTabId = {
|
||||
DASHBOARD: 'dashboard',
|
||||
USERS: 'users',
|
||||
ROOMS: 'rooms',
|
||||
ECONOMY: 'economy',
|
||||
AUDIT: 'audit'
|
||||
} as const;
|
||||
|
||||
export type HousekeepingTabId = typeof HousekeepingTabId[keyof typeof HousekeepingTabId];
|
||||
@@ -0,0 +1,384 @@
|
||||
import {
|
||||
HabboSearchComposer, HabboSearchResultEvent, HousekeepingActionLogEvent, HousekeepingActionResultEvent,
|
||||
HousekeepingBanUserComposer, HousekeepingDashboardEvent, HousekeepingDeleteRoomComposer,
|
||||
HousekeepingFindRoomByIdComposer, HousekeepingFindUserByIdComposer, HousekeepingFindUserByNameComposer,
|
||||
HousekeepingForceDisconnectUserComposer, HousekeepingGetDashboardComposer,
|
||||
HousekeepingGiveCreditsComposer, HousekeepingGiveCurrencyComposer, HousekeepingGrantItemComposer,
|
||||
HousekeepingKickAllFromRoomComposer, HousekeepingKickUserComposer, HousekeepingListActionLogComposer,
|
||||
HousekeepingMuteRoomComposer, HousekeepingMuteUserComposer, HousekeepingResetUserPasswordComposer,
|
||||
HousekeepingRoomData, HousekeepingRoomDetailEvent, HousekeepingRoomListEvent,
|
||||
HousekeepingRoomStateComposer, HousekeepingSearchRoomsComposer, HousekeepingSendHotelAlertComposer,
|
||||
HousekeepingSetHcSubscriptionComposer, HousekeepingSetUserRankComposer,
|
||||
HousekeepingTradeLockUserComposer, HousekeepingTransferRoomOwnershipComposer,
|
||||
HousekeepingUnbanUserComposer, HousekeepingUserDetailData, HousekeepingUserDetailEvent,
|
||||
IMessageComposer
|
||||
} from '@nitrots/nitro-renderer';
|
||||
import { awaitMessageEvent } from '../nitro/awaitMessageEvent';
|
||||
import { SendMessageComposer } from '../nitro/SendMessageComposer';
|
||||
import {
|
||||
IHousekeepingActionLogEntry, IHousekeepingActionResult, IHousekeepingDashboard,
|
||||
IHousekeepingRoom, IHousekeepingRoomSummary, IHousekeepingUser, IHousekeepingUserSummary
|
||||
} from './IHousekeepingTypes';
|
||||
|
||||
const USER_SEARCH_LIMIT = 8;
|
||||
|
||||
const searchUsersViaPacket = async (prefix: string, signal?: AbortSignal): Promise<IHousekeepingUserSummary[]> =>
|
||||
{
|
||||
SendMessageComposer(new HabboSearchComposer(prefix));
|
||||
|
||||
// Snapshot the parser inside the subscribe callback — the renderer
|
||||
// recycles parser instances after the callback returns, so any
|
||||
// post-await read of `event.getParser()` comes back null.
|
||||
return await awaitMessageEvent<HabboSearchResultEvent, IHousekeepingUserSummary[]>(HabboSearchResultEvent, {
|
||||
signal,
|
||||
timeoutMs: 8_000,
|
||||
select: event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
if(!parser) return [];
|
||||
|
||||
const combined = [ ...parser.friends, ...parser.others ];
|
||||
const summaries: IHousekeepingUserSummary[] = [];
|
||||
|
||||
for(const entry of combined)
|
||||
{
|
||||
const username = entry.avatarName || '';
|
||||
|
||||
if(!username.toLowerCase().startsWith(prefix.toLowerCase())) continue;
|
||||
|
||||
summaries.push({
|
||||
id: entry.avatarId,
|
||||
username,
|
||||
figure: entry.avatarFigure || '',
|
||||
online: entry.isAvatarOnline === true,
|
||||
rank: 0
|
||||
});
|
||||
|
||||
if(summaries.length >= USER_SEARCH_LIMIT) break;
|
||||
}
|
||||
|
||||
return summaries;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const mapUserDetail = (user: HousekeepingUserDetailData): IHousekeepingUser => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
motto: user.motto,
|
||||
figure: user.figure,
|
||||
rank: user.rank,
|
||||
rankName: user.rankName,
|
||||
online: user.online,
|
||||
lastOnlineAt: user.lastOnlineAt > 0 ? user.lastOnlineAt : null,
|
||||
creditsBalance: user.creditsBalance,
|
||||
ducketsBalance: user.ducketsBalance,
|
||||
diamondsBalance: user.diamondsBalance,
|
||||
email: user.email,
|
||||
ipLast: user.ipLast,
|
||||
isBanned: user.isBanned,
|
||||
isMuted: user.isMuted,
|
||||
isTradeLocked: user.isTradeLocked
|
||||
});
|
||||
|
||||
const awaitUserDetail = (): Promise<IHousekeepingUser | null> =>
|
||||
awaitMessageEvent<HousekeepingUserDetailEvent, IHousekeepingUser | null>(HousekeepingUserDetailEvent, {
|
||||
timeoutMs: 8_000,
|
||||
select: event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
if(!parser || !parser.found || !parser.user) return null;
|
||||
|
||||
return mapUserDetail(parser.user);
|
||||
}
|
||||
});
|
||||
|
||||
const findUserByNameViaPacket = async (username: string): Promise<IHousekeepingUser | null> =>
|
||||
{
|
||||
const trimmed = (username || '').trim();
|
||||
|
||||
if(!trimmed) return null;
|
||||
|
||||
SendMessageComposer(new HousekeepingFindUserByNameComposer(trimmed));
|
||||
|
||||
return awaitUserDetail();
|
||||
};
|
||||
|
||||
const findUserByIdViaPacket = async (userId: number): Promise<IHousekeepingUser | null> =>
|
||||
{
|
||||
if(!Number.isFinite(userId) || userId <= 0) return null;
|
||||
|
||||
SendMessageComposer(new HousekeepingFindUserByIdComposer(userId));
|
||||
|
||||
return awaitUserDetail();
|
||||
};
|
||||
|
||||
/**
|
||||
* Fire any HK action composer and resolve when the matching
|
||||
* HousekeepingActionResultEvent arrives. The server tags each ack with
|
||||
* a string `actionKey` (`user.ban`, `user.mute`, …) so the listener can
|
||||
* filter via the `accept` predicate — protects against another concurrent
|
||||
* action's ack slipping into a waiter that was expecting a different one.
|
||||
*/
|
||||
const runHkAction = async (composer: IMessageComposer<unknown[]>, expectedActionKey: string, timeoutMs = 15_000): Promise<IHousekeepingActionResult> =>
|
||||
{
|
||||
SendMessageComposer(composer);
|
||||
|
||||
try
|
||||
{
|
||||
return await awaitMessageEvent<HousekeepingActionResultEvent, IHousekeepingActionResult>(HousekeepingActionResultEvent, {
|
||||
timeoutMs,
|
||||
accept: e => e.getParser()?.actionKey === expectedActionKey,
|
||||
select: event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
if(!parser) return { ok: false, actionId: null, message: 'no_parser' };
|
||||
|
||||
return {
|
||||
ok: parser.ok,
|
||||
actionId: parser.actionId > 0 ? parser.actionId : null,
|
||||
message: parser.message
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
const reason = err instanceof Error ? err.message : 'unknown';
|
||||
|
||||
return { ok: false, actionId: null, message: reason };
|
||||
}
|
||||
};
|
||||
|
||||
const banUserViaPacket = (userId: number, reason: string, hours: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingBanUserComposer(userId, reason || '', hours), 'user.ban');
|
||||
|
||||
const unbanUserViaPacket = (userId: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingUnbanUserComposer(userId), 'user.unban');
|
||||
|
||||
const muteUserViaPacket = (userId: number, reason: string, minutes: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingMuteUserComposer(userId, reason || '', minutes), 'user.mute');
|
||||
|
||||
const kickUserViaPacket = (userId: number, reason: string): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingKickUserComposer(userId, reason || ''), 'user.kick');
|
||||
|
||||
const forceDisconnectUserViaPacket = (userId: number, reason: string): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingForceDisconnectUserComposer(userId, reason || ''), 'user.disconnect');
|
||||
|
||||
const setUserRankViaPacket = (userId: number, rank: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingSetUserRankComposer(userId, rank), 'user.set_rank');
|
||||
|
||||
const tradeLockUserViaPacket = (userId: number, hours: number, reason: string): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingTradeLockUserComposer(userId, hours, reason || ''), 'user.trade_lock');
|
||||
|
||||
const resetUserPasswordViaPacket = (userId: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingResetUserPasswordComposer(userId), 'user.reset_password');
|
||||
|
||||
const mapRoom = (room: HousekeepingRoomData): IHousekeepingRoom => ({
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
description: room.description,
|
||||
ownerId: room.ownerId,
|
||||
ownerName: room.ownerName,
|
||||
userCount: room.userCount,
|
||||
maxUsers: room.maxUsers,
|
||||
isLocked: room.isLocked,
|
||||
isMuted: room.isMuted,
|
||||
isPublic: room.isPublic,
|
||||
createdAt: room.createdAt
|
||||
});
|
||||
|
||||
const findRoomByIdViaPacket = (roomId: number): Promise<IHousekeepingRoom | null> =>
|
||||
{
|
||||
if(!Number.isFinite(roomId) || roomId <= 0) return Promise.resolve(null);
|
||||
|
||||
SendMessageComposer(new HousekeepingFindRoomByIdComposer(roomId));
|
||||
|
||||
return awaitMessageEvent<HousekeepingRoomDetailEvent, IHousekeepingRoom | null>(HousekeepingRoomDetailEvent, {
|
||||
timeoutMs: 8_000,
|
||||
select: event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
if(!parser || !parser.found || !parser.room) return null;
|
||||
|
||||
return mapRoom(parser.room);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const findRoomByNameViaPacket = (name: string): Promise<IHousekeepingRoom[]> =>
|
||||
{
|
||||
const trimmed = (name || '').trim();
|
||||
|
||||
if(!trimmed) return Promise.resolve([]);
|
||||
|
||||
SendMessageComposer(new HousekeepingSearchRoomsComposer(trimmed, true, 50));
|
||||
|
||||
return awaitMessageEvent<HousekeepingRoomListEvent, IHousekeepingRoom[]>(HousekeepingRoomListEvent, {
|
||||
timeoutMs: 8_000,
|
||||
select: event => event.getParser()?.rooms.map(mapRoom) ?? []
|
||||
});
|
||||
};
|
||||
|
||||
const searchRoomsViaPacket = (prefix: string, signal?: AbortSignal): Promise<IHousekeepingRoomSummary[]> =>
|
||||
{
|
||||
const trimmed = (prefix || '').trim();
|
||||
|
||||
if(!trimmed) return Promise.resolve([]);
|
||||
|
||||
SendMessageComposer(new HousekeepingSearchRoomsComposer(trimmed, false, 8));
|
||||
|
||||
return awaitMessageEvent<HousekeepingRoomListEvent, IHousekeepingRoomSummary[]>(HousekeepingRoomListEvent, {
|
||||
signal,
|
||||
timeoutMs: 8_000,
|
||||
select: event => event.getParser()?.rooms.map(room => ({
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
userCount: room.userCount,
|
||||
ownerName: room.ownerName
|
||||
})) ?? []
|
||||
});
|
||||
};
|
||||
|
||||
const setRoomStateViaPacket = (roomId: number, open: boolean): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingRoomStateComposer(roomId, open), open ? 'room.open' : 'room.close');
|
||||
|
||||
const muteRoomViaPacket = (roomId: number, minutes: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingMuteRoomComposer(roomId, minutes), 'room.mute');
|
||||
|
||||
const kickAllFromRoomViaPacket = (roomId: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingKickAllFromRoomComposer(roomId), 'room.kick_all');
|
||||
|
||||
const transferRoomOwnershipViaPacket = (roomId: number, newOwnerId: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingTransferRoomOwnershipComposer(roomId, newOwnerId), 'room.transfer');
|
||||
|
||||
const deleteRoomViaPacket = (roomId: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingDeleteRoomComposer(roomId), 'room.delete');
|
||||
|
||||
const CURRENCY_DUCKETS = 0;
|
||||
const CURRENCY_DIAMONDS = 5;
|
||||
|
||||
const giveCreditsViaPacket = (userId: number, amount: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingGiveCreditsComposer(userId, amount), 'user.give_credits');
|
||||
|
||||
const giveDucketsViaPacket = (userId: number, amount: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingGiveCurrencyComposer(userId, CURRENCY_DUCKETS, amount), `user.give_currency_${ CURRENCY_DUCKETS }`);
|
||||
|
||||
const giveDiamondsViaPacket = (userId: number, amount: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingGiveCurrencyComposer(userId, CURRENCY_DIAMONDS, amount), `user.give_currency_${ CURRENCY_DIAMONDS }`);
|
||||
|
||||
const grantItemViaPacket = (userId: number, itemId: number, quantity: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingGrantItemComposer(userId, itemId, quantity), 'user.grant_item');
|
||||
|
||||
const setHcSubscriptionViaPacket = (userId: number, days: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingSetHcSubscriptionComposer(userId, days), 'user.set_hc');
|
||||
|
||||
const sendHotelAlertViaPacket = (message: string): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingSendHotelAlertComposer(message || ''), 'hotel.alert');
|
||||
|
||||
const EMPTY_DASHBOARD: IHousekeepingDashboard = {
|
||||
onlineUsers: 0, totalUsers: 0, activeRooms: 0, totalRooms: 0,
|
||||
peakOnlineToday: 0, peakOnlineAllTime: 0, pendingTickets: 0,
|
||||
sanctionsLast24h: 0, serverUptimeSeconds: 0, serverVersion: ''
|
||||
};
|
||||
|
||||
const getDashboardViaPacket = (signal?: AbortSignal): Promise<IHousekeepingDashboard> =>
|
||||
{
|
||||
SendMessageComposer(new HousekeepingGetDashboardComposer());
|
||||
|
||||
return awaitMessageEvent<HousekeepingDashboardEvent, IHousekeepingDashboard>(HousekeepingDashboardEvent, {
|
||||
signal,
|
||||
timeoutMs: 10_000,
|
||||
select: event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
if(!parser) return EMPTY_DASHBOARD;
|
||||
|
||||
return {
|
||||
onlineUsers: parser.onlineUsers,
|
||||
totalUsers: parser.totalUsers,
|
||||
activeRooms: parser.activeRooms,
|
||||
totalRooms: parser.totalRooms,
|
||||
peakOnlineToday: parser.peakOnlineToday,
|
||||
peakOnlineAllTime: parser.peakOnlineAllTime,
|
||||
pendingTickets: parser.pendingTickets,
|
||||
sanctionsLast24h: parser.sanctionsLast24h,
|
||||
serverUptimeSeconds: parser.serverUptimeSeconds,
|
||||
serverVersion: parser.serverVersion
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const listActionLogViaPacket = (limit: number, signal?: AbortSignal): Promise<IHousekeepingActionLogEntry[]> =>
|
||||
{
|
||||
const safeLimit = Math.max(1, Math.min(500, Math.floor(limit || 50)));
|
||||
|
||||
SendMessageComposer(new HousekeepingListActionLogComposer(safeLimit));
|
||||
|
||||
return awaitMessageEvent<HousekeepingActionLogEvent, IHousekeepingActionLogEntry[]>(HousekeepingActionLogEvent, {
|
||||
signal,
|
||||
timeoutMs: 10_000,
|
||||
select: event => event.getParser()?.entries.map(entry => ({
|
||||
id: entry.id,
|
||||
timestamp: entry.timestamp,
|
||||
actorId: entry.actorId,
|
||||
actorName: entry.actorName,
|
||||
targetType: (entry.targetType === 'room' || entry.targetType === 'hotel') ? entry.targetType : 'user',
|
||||
targetId: entry.targetId > 0 ? entry.targetId : null,
|
||||
targetLabel: entry.targetLabel,
|
||||
action: entry.action,
|
||||
detail: entry.detail,
|
||||
success: entry.success
|
||||
})) ?? []
|
||||
});
|
||||
};
|
||||
|
||||
export const HousekeepingApi = {
|
||||
// -- dashboard -------------------------------------------------
|
||||
getDashboard: (signal?: AbortSignal) => getDashboardViaPacket(signal),
|
||||
|
||||
// -- user lookup -----------------------------------------------
|
||||
findUserByName: (username: string) => findUserByNameViaPacket(username),
|
||||
findUserById: (userId: number) => findUserByIdViaPacket(userId),
|
||||
searchUsers: (prefix: string, signal?: AbortSignal) => searchUsersViaPacket(prefix, signal),
|
||||
|
||||
// -- user actions ----------------------------------------------
|
||||
banUser: (userId: number, reason: string, hours: number) => banUserViaPacket(userId, reason, hours),
|
||||
unbanUser: (userId: number) => unbanUserViaPacket(userId),
|
||||
muteUser: (userId: number, reason: string, minutes: number) => muteUserViaPacket(userId, reason, minutes),
|
||||
kickUser: (userId: number, reason: string) => kickUserViaPacket(userId, reason),
|
||||
forceDisconnectUser: (userId: number, reason: string) => forceDisconnectUserViaPacket(userId, reason),
|
||||
resetUserPassword: (userId: number) => resetUserPasswordViaPacket(userId),
|
||||
setUserRank: (userId: number, rank: number) => setUserRankViaPacket(userId, rank),
|
||||
tradeLockUser: (userId: number, hours: number, reason: string) => tradeLockUserViaPacket(userId, hours, reason),
|
||||
|
||||
// -- room lookup -----------------------------------------------
|
||||
findRoomById: (roomId: number) => findRoomByIdViaPacket(roomId),
|
||||
findRoomByName: (name: string) => findRoomByNameViaPacket(name),
|
||||
searchRooms: (prefix: string, signal?: AbortSignal) => searchRoomsViaPacket(prefix, signal),
|
||||
|
||||
// -- room actions ----------------------------------------------
|
||||
openRoom: (roomId: number) => setRoomStateViaPacket(roomId, true),
|
||||
closeRoom: (roomId: number) => setRoomStateViaPacket(roomId, false),
|
||||
muteRoom: (roomId: number, minutes: number) => muteRoomViaPacket(roomId, minutes),
|
||||
kickAllFromRoom: (roomId: number) => kickAllFromRoomViaPacket(roomId),
|
||||
transferRoomOwnership: (roomId: number, newOwnerId: number) => transferRoomOwnershipViaPacket(roomId, newOwnerId),
|
||||
deleteRoom: (roomId: number) => deleteRoomViaPacket(roomId),
|
||||
|
||||
// -- economy actions -------------------------------------------
|
||||
giveCredits: (userId: number, amount: number) => giveCreditsViaPacket(userId, amount),
|
||||
giveDuckets: (userId: number, amount: number) => giveDucketsViaPacket(userId, amount),
|
||||
giveDiamonds: (userId: number, amount: number) => giveDiamondsViaPacket(userId, amount),
|
||||
grantItem: (userId: number, itemId: number, quantity: number) => grantItemViaPacket(userId, itemId, quantity),
|
||||
setHcSubscription: (userId: number, days: number) => setHcSubscriptionViaPacket(userId, days),
|
||||
|
||||
// -- hotel-level -----------------------------------------------
|
||||
sendHotelAlert: (message: string) => sendHotelAlertViaPacket(message),
|
||||
listActionLog: (limit: number, signal?: AbortSignal) => listActionLogViaPacket(limit, signal)
|
||||
} as const;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { HousekeepingTabId } from './HousekeepingActionType';
|
||||
import { housekeepingTabsForMode, isHousekeepingTabAvailable, resolveHousekeepingMode } from './HousekeepingConfig';
|
||||
|
||||
describe('resolveHousekeepingMode', () =>
|
||||
{
|
||||
it('returns "light" only for the exact "light" string', () =>
|
||||
{
|
||||
expect(resolveHousekeepingMode('light')).toBe('light');
|
||||
});
|
||||
|
||||
it('falls back to "full" for any other value (unknown strings, typos, non-strings)', () =>
|
||||
{
|
||||
expect(resolveHousekeepingMode('full')).toBe('full');
|
||||
expect(resolveHousekeepingMode('FULL')).toBe('full');
|
||||
expect(resolveHousekeepingMode('Light')).toBe('full');
|
||||
expect(resolveHousekeepingMode('')).toBe('full');
|
||||
expect(resolveHousekeepingMode(undefined)).toBe('full');
|
||||
expect(resolveHousekeepingMode(null)).toBe('full');
|
||||
expect(resolveHousekeepingMode(42)).toBe('full');
|
||||
expect(resolveHousekeepingMode({})).toBe('full');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isHousekeepingTabAvailable', () =>
|
||||
{
|
||||
it('exposes every tab in full mode', () =>
|
||||
{
|
||||
expect(isHousekeepingTabAvailable(HousekeepingTabId.DASHBOARD, 'full')).toBe(true);
|
||||
expect(isHousekeepingTabAvailable(HousekeepingTabId.USERS, 'full')).toBe(true);
|
||||
expect(isHousekeepingTabAvailable(HousekeepingTabId.ROOMS, 'full')).toBe(true);
|
||||
expect(isHousekeepingTabAvailable(HousekeepingTabId.ECONOMY, 'full')).toBe(true);
|
||||
expect(isHousekeepingTabAvailable(HousekeepingTabId.AUDIT, 'full')).toBe(true);
|
||||
});
|
||||
|
||||
it('exposes only Users + Rooms in light mode', () =>
|
||||
{
|
||||
expect(isHousekeepingTabAvailable(HousekeepingTabId.USERS, 'light')).toBe(true);
|
||||
expect(isHousekeepingTabAvailable(HousekeepingTabId.ROOMS, 'light')).toBe(true);
|
||||
|
||||
expect(isHousekeepingTabAvailable(HousekeepingTabId.DASHBOARD, 'light')).toBe(false);
|
||||
expect(isHousekeepingTabAvailable(HousekeepingTabId.ECONOMY, 'light')).toBe(false);
|
||||
expect(isHousekeepingTabAvailable(HousekeepingTabId.AUDIT, 'light')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('housekeepingTabsForMode', () =>
|
||||
{
|
||||
it('returns the full ordered tab list in full mode', () =>
|
||||
{
|
||||
expect(housekeepingTabsForMode('full')).toEqual([
|
||||
HousekeepingTabId.DASHBOARD,
|
||||
HousekeepingTabId.USERS,
|
||||
HousekeepingTabId.ROOMS,
|
||||
HousekeepingTabId.ECONOMY,
|
||||
HousekeepingTabId.AUDIT
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns Users + Rooms (in that order) for light mode', () =>
|
||||
{
|
||||
expect(housekeepingTabsForMode('light')).toEqual([
|
||||
HousekeepingTabId.USERS,
|
||||
HousekeepingTabId.ROOMS
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { GetConfigurationValue } from '../nitro';
|
||||
import { HousekeepingTabId } from './HousekeepingActionType';
|
||||
|
||||
export type HousekeepingMode = 'light' | 'full';
|
||||
|
||||
export const HOUSEKEEPING_ENABLED_KEY = 'housekeeping.enabled';
|
||||
export const HOUSEKEEPING_MODE_KEY = 'housekeeping.mode';
|
||||
|
||||
/**
|
||||
* Default-off master switch. When false, the HK module is completely
|
||||
* hidden: no toolbar icon, no panel mount, no link-event routing.
|
||||
* Layered ON TOP of the `acc_housekeeping` permission gate — config
|
||||
* lets the operator disable HK at the build/deploy level even when
|
||||
* the permission exists on the server.
|
||||
*/
|
||||
export const isHousekeepingEnabled = (): boolean =>
|
||||
GetConfigurationValue<boolean>(HOUSEKEEPING_ENABLED_KEY, false) === true;
|
||||
|
||||
/**
|
||||
* `full` (default) exposes the five-tab layout: dashboard, users,
|
||||
* rooms, economy, audit. `light` strips the panel down to the
|
||||
* essentials — Users + Rooms only — for operators who want the
|
||||
* in-client HK only for live moderation, not for economy
|
||||
* management. Anything else than `'light'` resolves to `'full'`
|
||||
* so a typo doesn't quietly hide tabs.
|
||||
*/
|
||||
export const resolveHousekeepingMode = (raw: unknown): HousekeepingMode =>
|
||||
(raw === 'light') ? 'light' : 'full';
|
||||
|
||||
export const getHousekeepingMode = (): HousekeepingMode =>
|
||||
resolveHousekeepingMode(GetConfigurationValue<string>(HOUSEKEEPING_MODE_KEY, 'full'));
|
||||
|
||||
const LIGHT_TABS: ReadonlySet<HousekeepingTabId> = new Set<HousekeepingTabId>([
|
||||
HousekeepingTabId.USERS,
|
||||
HousekeepingTabId.ROOMS
|
||||
]);
|
||||
|
||||
/**
|
||||
* Pure tab-availability check. Kept side-effect-free so tab list
|
||||
* filtering and toolbar / link-event gating can all read the same
|
||||
* source of truth without hitting the config layer multiple times.
|
||||
*/
|
||||
export const isHousekeepingTabAvailable = (tab: HousekeepingTabId, mode: HousekeepingMode): boolean =>
|
||||
{
|
||||
if(mode === 'full') return true;
|
||||
|
||||
return LIGHT_TABS.has(tab);
|
||||
};
|
||||
|
||||
export const housekeepingTabsForMode = (mode: HousekeepingMode): HousekeepingTabId[] =>
|
||||
{
|
||||
const all: HousekeepingTabId[] = [
|
||||
HousekeepingTabId.DASHBOARD,
|
||||
HousekeepingTabId.USERS,
|
||||
HousekeepingTabId.ROOMS,
|
||||
HousekeepingTabId.ECONOMY,
|
||||
HousekeepingTabId.AUDIT
|
||||
];
|
||||
|
||||
return all.filter(tab => isHousekeepingTabAvailable(tab, mode));
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatCompactNumber, formatRelativePast, formatUptime } from './HousekeepingFormatters';
|
||||
|
||||
describe('formatUptime', () =>
|
||||
{
|
||||
it('renders 0/negative/NaN/Infinity as "—"', () =>
|
||||
{
|
||||
expect(formatUptime(-1)).toBe('—');
|
||||
expect(formatUptime(NaN)).toBe('—');
|
||||
expect(formatUptime(Infinity)).toBe('—');
|
||||
});
|
||||
|
||||
it('renders seconds only for the fresh-boot case', () =>
|
||||
{
|
||||
expect(formatUptime(0)).toBe('0s');
|
||||
expect(formatUptime(45)).toBe('45s');
|
||||
});
|
||||
|
||||
it('renders minutes (no hour part) when below 1h', () =>
|
||||
{
|
||||
expect(formatUptime(60)).toBe('1m');
|
||||
expect(formatUptime(60 * 59)).toBe('59m');
|
||||
});
|
||||
|
||||
it('renders hours + minutes when below 1 day', () =>
|
||||
{
|
||||
expect(formatUptime(60 * 60)).toBe('1h 0m');
|
||||
expect(formatUptime((60 * 60 * 5) + (60 * 12))).toBe('5h 12m');
|
||||
});
|
||||
|
||||
it('renders days + hours + minutes when over a day', () =>
|
||||
{
|
||||
const fiveDaysTwelveHoursThreeMinutes = (5 * 86400) + (12 * 3600) + (3 * 60);
|
||||
|
||||
expect(formatUptime(fiveDaysTwelveHoursThreeMinutes)).toBe('5d 12h 3m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRelativePast', () =>
|
||||
{
|
||||
const NOW = 1_700_000_000_000; // fixed reference
|
||||
|
||||
it('renders "—" for invalid input', () =>
|
||||
{
|
||||
expect(formatRelativePast(0, NOW)).toBe('—');
|
||||
expect(formatRelativePast(-100, NOW)).toBe('—');
|
||||
expect(formatRelativePast(NaN, NOW)).toBe('—');
|
||||
});
|
||||
|
||||
it('renders "now" for the first 5 seconds', () =>
|
||||
{
|
||||
expect(formatRelativePast(NOW - 1_000, NOW)).toBe('now');
|
||||
expect(formatRelativePast(NOW - 4_000, NOW)).toBe('now');
|
||||
});
|
||||
|
||||
it('renders seconds-ago between 5s and 1m', () =>
|
||||
{
|
||||
expect(formatRelativePast(NOW - 10_000, NOW)).toBe('10s ago');
|
||||
expect(formatRelativePast(NOW - 59_000, NOW)).toBe('59s ago');
|
||||
});
|
||||
|
||||
it('renders minutes / hours / days as we cross each unit boundary', () =>
|
||||
{
|
||||
expect(formatRelativePast(NOW - (60 * 1000), NOW)).toBe('1m ago');
|
||||
expect(formatRelativePast(NOW - (3600 * 1000), NOW)).toBe('1h ago');
|
||||
expect(formatRelativePast(NOW - (86_400 * 1000), NOW)).toBe('1d ago');
|
||||
expect(formatRelativePast(NOW - (3 * 86_400 * 1000), NOW)).toBe('3d ago');
|
||||
});
|
||||
|
||||
it('switches to a fixed ISO-date prefix beyond 7 days', () =>
|
||||
{
|
||||
const tenDaysAgoMs = NOW - (10 * 86_400 * 1000);
|
||||
const expected = new Date(tenDaysAgoMs).toISOString().slice(0, 10);
|
||||
|
||||
expect(formatRelativePast(tenDaysAgoMs, NOW)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCompactNumber', () =>
|
||||
{
|
||||
it('returns "—" for non-finite input', () =>
|
||||
{
|
||||
expect(formatCompactNumber(NaN)).toBe('—');
|
||||
expect(formatCompactNumber(Infinity)).toBe('—');
|
||||
});
|
||||
|
||||
it('passes through small values', () =>
|
||||
{
|
||||
expect(formatCompactNumber(0)).toBe('0');
|
||||
expect(formatCompactNumber(42)).toBe('42');
|
||||
expect(formatCompactNumber(999)).toBe('999');
|
||||
});
|
||||
|
||||
it('uses K from 1_000 onwards (drops decimals at 10K+ for readability)', () =>
|
||||
{
|
||||
expect(formatCompactNumber(1_000)).toBe('1.0K');
|
||||
expect(formatCompactNumber(1_500)).toBe('1.5K');
|
||||
expect(formatCompactNumber(12_345)).toBe('12K');
|
||||
});
|
||||
|
||||
it('uses M from 1_000_000 onwards (drops decimals at 10M+)', () =>
|
||||
{
|
||||
expect(formatCompactNumber(1_000_000)).toBe('1.0M');
|
||||
expect(formatCompactNumber(2_300_000)).toBe('2.3M');
|
||||
expect(formatCompactNumber(15_000_000)).toBe('15M');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
const SECOND = 1;
|
||||
const MINUTE = 60 * SECOND;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
/**
|
||||
* "5d 12h 3m" — compact uptime display for the dashboard. We don't
|
||||
* use the existing `friendlyTime` helper because that one is tuned
|
||||
* for "how long ago" (past tense, single-unit), while uptime needs
|
||||
* multi-unit forward-looking output and to handle seconds-only
|
||||
* fresh-boot cases.
|
||||
*/
|
||||
export const formatUptime = (seconds: number): string =>
|
||||
{
|
||||
if(!Number.isFinite(seconds) || seconds < 0) return '—';
|
||||
if(seconds < MINUTE) return `${ Math.floor(seconds) }s`;
|
||||
|
||||
const d = Math.floor(seconds / DAY);
|
||||
const h = Math.floor((seconds % DAY) / HOUR);
|
||||
const m = Math.floor((seconds % HOUR) / MINUTE);
|
||||
|
||||
if(d > 0) return `${ d }d ${ h }h ${ m }m`;
|
||||
if(h > 0) return `${ h }h ${ m }m`;
|
||||
|
||||
return `${ m }m`;
|
||||
};
|
||||
|
||||
/**
|
||||
* "5m ago", "2h ago", "3d ago" — past-tense relative formatter for
|
||||
* audit-log timestamps. Anything older than a day rolls to a fixed
|
||||
* date string so the log entries stay scannable even after a week.
|
||||
*/
|
||||
export const formatRelativePast = (timestampMs: number, nowMs: number = Date.now()): string =>
|
||||
{
|
||||
if(!Number.isFinite(timestampMs) || timestampMs <= 0) return '—';
|
||||
|
||||
const deltaSeconds = Math.max(0, Math.floor((nowMs - timestampMs) / 1000));
|
||||
|
||||
if(deltaSeconds < 5) return 'now';
|
||||
if(deltaSeconds < MINUTE) return `${ deltaSeconds }s ago`;
|
||||
if(deltaSeconds < HOUR) return `${ Math.floor(deltaSeconds / MINUTE) }m ago`;
|
||||
if(deltaSeconds < DAY) return `${ Math.floor(deltaSeconds / HOUR) }h ago`;
|
||||
if(deltaSeconds < 7 * DAY) return `${ Math.floor(deltaSeconds / DAY) }d ago`;
|
||||
|
||||
const date = new Date(timestampMs);
|
||||
|
||||
return date.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
export const formatCompactNumber = (value: number): string =>
|
||||
{
|
||||
if(!Number.isFinite(value)) return '—';
|
||||
|
||||
const abs = Math.abs(value);
|
||||
|
||||
if(abs >= 1_000_000) return `${ (value / 1_000_000).toFixed(abs >= 10_000_000 ? 0 : 1) }M`;
|
||||
if(abs >= 1_000) return `${ (value / 1_000).toFixed(abs >= 10_000 ? 0 : 1) }K`;
|
||||
|
||||
return value.toString();
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import { getAccessToken } from '../auth/accessToken';
|
||||
|
||||
const trimSlash = (value: string) => value.replace(/\/$/, '');
|
||||
|
||||
const resolveBaseUrl = (): string =>
|
||||
{
|
||||
const mode = (window as any).NitroClientMode;
|
||||
|
||||
if(mode && typeof mode.apiBaseUrl === 'string' && mode.apiBaseUrl.length) return trimSlash(mode.apiBaseUrl);
|
||||
|
||||
const configured = (window as any).NitroSecureApiUrl;
|
||||
|
||||
if(typeof configured === 'string' && configured.length) return trimSlash(configured);
|
||||
|
||||
return trimSlash(window.location.origin);
|
||||
};
|
||||
|
||||
const buildUrl = (path: string): string =>
|
||||
{
|
||||
const base = resolveBaseUrl();
|
||||
const normalized = path.startsWith('/') ? path : `/${ path }`;
|
||||
|
||||
return `${ base }${ normalized }`;
|
||||
};
|
||||
|
||||
const authHeader = (): Record<string, string> =>
|
||||
{
|
||||
const token = getAccessToken();
|
||||
|
||||
return token ? { Authorization: `Bearer ${ token }` } : {};
|
||||
};
|
||||
|
||||
export interface HousekeepingRequestInit
|
||||
{
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
body?: unknown;
|
||||
query?: Record<string, string | number | boolean | undefined | null>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const appendQuery = (url: string, query?: HousekeepingRequestInit['query']): string =>
|
||||
{
|
||||
if(!query) return url;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for(const [ key, value ] of Object.entries(query))
|
||||
{
|
||||
if(value === undefined || value === null) continue;
|
||||
|
||||
params.set(key, String(value));
|
||||
}
|
||||
|
||||
const qs = params.toString();
|
||||
|
||||
return qs.length ? `${ url }?${ qs }` : url;
|
||||
};
|
||||
|
||||
/**
|
||||
* Thin HTTP wrapper for the admin/housekeeping endpoints. Backed by the
|
||||
* same `apiBaseUrl` the secure-asset layer uses, with the user's
|
||||
* persisted access token attached as a bearer.
|
||||
*
|
||||
* Server is expected to expose REST endpoints under
|
||||
* `${apiBaseUrl}/api/housekeeping/...`. The shape mirrors what
|
||||
* Arcturus-style admin panels already publish, so a server-side
|
||||
* implementation is incremental rather than greenfield.
|
||||
*/
|
||||
export const housekeepingFetch = async <T = unknown>(path: string, init: HousekeepingRequestInit = {}): Promise<T> =>
|
||||
{
|
||||
const { method = 'GET', body = undefined, query = undefined, signal = undefined } = init;
|
||||
const url = appendQuery(buildUrl(path), query);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...authHeader()
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if(!response.ok)
|
||||
{
|
||||
let detail = '';
|
||||
|
||||
try
|
||||
{
|
||||
const text = await response.text();
|
||||
detail = text || '';
|
||||
}
|
||||
catch
|
||||
{}
|
||||
|
||||
throw new HousekeepingHttpError(response.status, response.statusText, detail, url);
|
||||
}
|
||||
|
||||
if(response.status === 204) return undefined;
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
if(!contentType.includes('application/json')) return undefined;
|
||||
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export class HousekeepingHttpError extends Error
|
||||
{
|
||||
public readonly status: number;
|
||||
public readonly statusText: string;
|
||||
public readonly detail: string;
|
||||
public readonly url: string;
|
||||
|
||||
constructor(status: number, statusText: string, detail: string, url: string)
|
||||
{
|
||||
super(`HK HTTP ${ status } ${ statusText }: ${ detail || url }`);
|
||||
this.name = 'HousekeepingHttpError';
|
||||
this.status = status;
|
||||
this.statusText = statusText;
|
||||
this.detail = detail;
|
||||
this.url = url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { readFileSync, readdirSync, statSync } from 'fs';
|
||||
import { join, resolve } from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const ROOT = resolve(__dirname, '..', '..', '..');
|
||||
const EN_PATH = join(ROOT, 'public', 'configuration', 'housekeeping-texts-en.example');
|
||||
const IT_PATH = join(ROOT, 'public', 'configuration', 'housekeeping-texts-it.example');
|
||||
|
||||
const loadDict = (path: string): Record<string, string> =>
|
||||
{
|
||||
const raw = readFileSync(path, 'utf8');
|
||||
|
||||
return JSON.parse(raw);
|
||||
};
|
||||
|
||||
// Walk every .ts/.tsx file under src/ and extract every quoted
|
||||
// `housekeeping.<...>` literal. Doesn't catch fully dynamic keys
|
||||
// (e.g. `housekeeping.validation.${ k }`), so we hand-extend the
|
||||
// expected set with the dynamic prefixes covered in code.
|
||||
const collectReferencedKeys = (): Set<string> =>
|
||||
{
|
||||
const sources: string[] = [];
|
||||
|
||||
const walk = (dir: string) =>
|
||||
{
|
||||
for(const entry of readdirSync(dir))
|
||||
{
|
||||
if(entry.startsWith('.') || entry === 'node_modules') continue;
|
||||
|
||||
const full = join(dir, entry);
|
||||
const stat = statSync(full);
|
||||
|
||||
if(stat.isDirectory()) walk(full);
|
||||
else if(entry.endsWith('.ts') || entry.endsWith('.tsx')) sources.push(full);
|
||||
}
|
||||
};
|
||||
|
||||
walk(join(ROOT, 'src'));
|
||||
|
||||
const keys = new Set<string>();
|
||||
|
||||
for(const source of sources)
|
||||
{
|
||||
const content = readFileSync(source, 'utf8');
|
||||
const matches = content.match(/['"`]housekeeping\.[a-z0-9._]+['"`]/g) || [];
|
||||
|
||||
for(const m of matches)
|
||||
{
|
||||
const cleaned = m.slice(1, -1);
|
||||
|
||||
// Skip config keys (they live in renderer config, not in
|
||||
// the localization dict).
|
||||
const CONFIG_KEYS = new Set([
|
||||
'housekeeping.enabled',
|
||||
'housekeeping.mode',
|
||||
'housekeeping.telemetry.enabled',
|
||||
'housekeeping.audit.poll_interval_ms'
|
||||
]);
|
||||
|
||||
if(CONFIG_KEYS.has(cleaned)) continue;
|
||||
|
||||
keys.add(cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
};
|
||||
|
||||
describe('housekeeping i18n dictionaries', () =>
|
||||
{
|
||||
it('EN parses as valid JSON', () =>
|
||||
{
|
||||
expect(() => loadDict(EN_PATH)).not.toThrow();
|
||||
});
|
||||
|
||||
it('IT parses as valid JSON', () =>
|
||||
{
|
||||
expect(() => loadDict(IT_PATH)).not.toThrow();
|
||||
});
|
||||
|
||||
it('EN and IT share the exact same key set (no missing translations on either side)', () =>
|
||||
{
|
||||
const en = loadDict(EN_PATH);
|
||||
const it = loadDict(IT_PATH);
|
||||
const enKeys = new Set(Object.keys(en));
|
||||
const itKeys = new Set(Object.keys(it));
|
||||
|
||||
const missingInIt = [ ...enKeys ].filter(k => !itKeys.has(k));
|
||||
const missingInEn = [ ...itKeys ].filter(k => !enKeys.has(k));
|
||||
|
||||
expect(missingInIt).toEqual([]);
|
||||
expect(missingInEn).toEqual([]);
|
||||
});
|
||||
|
||||
it('every value is a non-empty string in both dicts', () =>
|
||||
{
|
||||
for(const path of [ EN_PATH, IT_PATH ])
|
||||
{
|
||||
const dict = loadDict(path);
|
||||
|
||||
for(const [ key, value ] of Object.entries(dict))
|
||||
{
|
||||
expect(typeof value).toBe('string');
|
||||
expect(value.length).toBeGreaterThan(0);
|
||||
expect(key.startsWith('housekeeping.')).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('EN covers every static `housekeeping.*` key referenced in source code', () =>
|
||||
{
|
||||
const en = loadDict(EN_PATH);
|
||||
const enKeys = new Set(Object.keys(en));
|
||||
const referenced = collectReferencedKeys();
|
||||
|
||||
const uncovered = [ ...referenced ].filter(key => !enKeys.has(key));
|
||||
|
||||
expect(uncovered).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { emptySample, HK_METRICS_SAMPLE_CAP, recordSample, sampleToMetric } from './HousekeepingMetrics';
|
||||
|
||||
describe('emptySample', () =>
|
||||
{
|
||||
it('starts with zero samples and counts', () =>
|
||||
{
|
||||
const e = emptySample();
|
||||
|
||||
expect(e.samples).toEqual([]);
|
||||
expect(e.count).toBe(0);
|
||||
expect(e.errors).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordSample', () =>
|
||||
{
|
||||
it('appends one sample and increments count', () =>
|
||||
{
|
||||
const next = recordSample(emptySample(), 50, false);
|
||||
|
||||
expect(next.samples).toEqual([ 50 ]);
|
||||
expect(next.count).toBe(1);
|
||||
expect(next.errors).toBe(0);
|
||||
});
|
||||
|
||||
it('tracks errors independently from total count', () =>
|
||||
{
|
||||
let s = emptySample();
|
||||
s = recordSample(s, 10, false);
|
||||
s = recordSample(s, 20, true);
|
||||
s = recordSample(s, 30, false);
|
||||
|
||||
expect(s.count).toBe(3);
|
||||
expect(s.errors).toBe(1);
|
||||
});
|
||||
|
||||
it('never mutates the input (returns a new sample object)', () =>
|
||||
{
|
||||
const before = emptySample();
|
||||
const after = recordSample(before, 100, false);
|
||||
|
||||
expect(before.samples).toEqual([]);
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
|
||||
it('trims the sliding window to SAMPLE_CAP, keeping the most recent values', () =>
|
||||
{
|
||||
let s = emptySample();
|
||||
|
||||
// Push CAP+5 samples so the first 5 should fall off.
|
||||
for(let i = 0; i < HK_METRICS_SAMPLE_CAP + 5; i++) s = recordSample(s, i, false);
|
||||
|
||||
expect(s.samples.length).toBe(HK_METRICS_SAMPLE_CAP);
|
||||
// Most-recent sample (i = CAP+4) survives
|
||||
expect(s.samples[s.samples.length - 1]).toBe(HK_METRICS_SAMPLE_CAP + 4);
|
||||
// First 5 values (0..4) dropped — sample[0] now starts at 5
|
||||
expect(s.samples[0]).toBe(5);
|
||||
// Count keeps growing past the cap (cumulative, NOT windowed)
|
||||
expect(s.count).toBe(HK_METRICS_SAMPLE_CAP + 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sampleToMetric', () =>
|
||||
{
|
||||
it('returns zeros for an empty sample (no samples observed yet)', () =>
|
||||
{
|
||||
const m = sampleToMetric('ban', emptySample());
|
||||
|
||||
expect(m).toEqual({
|
||||
action: 'ban',
|
||||
count: 0,
|
||||
errors: 0,
|
||||
lastMs: 0,
|
||||
minMs: 0,
|
||||
maxMs: 0,
|
||||
p50Ms: 0,
|
||||
p95Ms: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('handles a single sample (P50 == P95 == min == max == lastMs)', () =>
|
||||
{
|
||||
let s = emptySample();
|
||||
s = recordSample(s, 42, false);
|
||||
const m = sampleToMetric('kick', s);
|
||||
|
||||
expect(m.lastMs).toBe(42);
|
||||
expect(m.minMs).toBe(42);
|
||||
expect(m.maxMs).toBe(42);
|
||||
expect(m.p50Ms).toBe(42);
|
||||
expect(m.p95Ms).toBe(42);
|
||||
});
|
||||
|
||||
it('computes P50 and P95 on a sorted copy (input order does not affect output)', () =>
|
||||
{
|
||||
// Build a known 11-sample distribution: 0..100 in steps of 10.
|
||||
let s = emptySample();
|
||||
const values = [ 100, 10, 50, 30, 80, 0, 70, 20, 90, 40, 60 ];
|
||||
|
||||
for(const v of values) s = recordSample(s, v, false);
|
||||
|
||||
const m = sampleToMetric('mute', s);
|
||||
|
||||
// With 11 samples sorted 0..100, P50 = 50 (median index 5),
|
||||
// P95 = 95 (between sorted[9]=90 and sorted[10]=100, half-way).
|
||||
expect(m.p50Ms).toBe(50);
|
||||
expect(m.p95Ms).toBeCloseTo(95, 1);
|
||||
expect(m.minMs).toBe(0);
|
||||
expect(m.maxMs).toBe(100);
|
||||
expect(m.lastMs).toBe(60); // last pushed value
|
||||
expect(m.count).toBe(11);
|
||||
});
|
||||
|
||||
it('preserves the error count in the snapshot', () =>
|
||||
{
|
||||
let s = emptySample();
|
||||
s = recordSample(s, 10, true);
|
||||
s = recordSample(s, 20, true);
|
||||
s = recordSample(s, 30, false);
|
||||
|
||||
const m = sampleToMetric('ban', s);
|
||||
|
||||
expect(m.count).toBe(3);
|
||||
expect(m.errors).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Per-action metrics — bounded sliding window of latency samples,
|
||||
* P50/P95 computed on demand. Keep this pure so the action runner
|
||||
* (`useHousekeepingActions.runAction`) and the debug panel render
|
||||
* function can both read the same shape without re-implementing
|
||||
* percentile math.
|
||||
*/
|
||||
|
||||
export interface HousekeepingActionMetric
|
||||
{
|
||||
action: string;
|
||||
/** Total calls observed (success + failure). */
|
||||
count: number;
|
||||
/** Failures only — `result.ok === false` or thrown. */
|
||||
errors: number;
|
||||
/** Most-recent latency in ms, plus min/max for visibility. */
|
||||
lastMs: number;
|
||||
minMs: number;
|
||||
maxMs: number;
|
||||
p50Ms: number;
|
||||
p95Ms: number;
|
||||
}
|
||||
|
||||
const SAMPLE_CAP = 50;
|
||||
|
||||
const percentile = (sorted: ReadonlyArray<number>, p: number): number =>
|
||||
{
|
||||
if(sorted.length === 0) return 0;
|
||||
if(sorted.length === 1) return sorted[0];
|
||||
|
||||
// Linear interpolation between adjacent samples — standard
|
||||
// percentile definition. Clamp the rank into [0, n-1] so p=100
|
||||
// doesn't read off the end on small samples.
|
||||
const rank = (p / 100) * (sorted.length - 1);
|
||||
const lo = Math.floor(rank);
|
||||
const hi = Math.ceil(rank);
|
||||
|
||||
if(lo === hi) return sorted[lo];
|
||||
|
||||
const frac = rank - lo;
|
||||
|
||||
return (sorted[lo] * (1 - frac)) + (sorted[hi] * frac);
|
||||
};
|
||||
|
||||
export interface MetricSample
|
||||
{
|
||||
samples: number[];
|
||||
count: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
export const emptySample = (): MetricSample => ({ samples: [], count: 0, errors: 0 });
|
||||
|
||||
/**
|
||||
* Append a new latency sample, trim past SAMPLE_CAP. Returns a NEW
|
||||
* object so the shape plays nicely with React state updates — never
|
||||
* mutates the input.
|
||||
*/
|
||||
export const recordSample = (current: MetricSample, latencyMs: number, isError: boolean): MetricSample =>
|
||||
{
|
||||
const trimmed = current.samples.length >= SAMPLE_CAP
|
||||
? current.samples.slice(current.samples.length - (SAMPLE_CAP - 1))
|
||||
: current.samples.slice();
|
||||
|
||||
trimmed.push(latencyMs);
|
||||
|
||||
return {
|
||||
samples: trimmed,
|
||||
count: current.count + 1,
|
||||
errors: current.errors + (isError ? 1 : 0)
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Snapshot transform — fold the sliding window into a renderable
|
||||
* record. Computes percentiles on a sorted copy (small `samples`
|
||||
* sizes — cap is 50, so this is essentially O(n log n) on n≤50).
|
||||
*/
|
||||
export const sampleToMetric = (action: string, sample: MetricSample): HousekeepingActionMetric =>
|
||||
{
|
||||
if(sample.samples.length === 0)
|
||||
{
|
||||
return {
|
||||
action,
|
||||
count: sample.count,
|
||||
errors: sample.errors,
|
||||
lastMs: 0,
|
||||
minMs: 0,
|
||||
maxMs: 0,
|
||||
p50Ms: 0,
|
||||
p95Ms: 0
|
||||
};
|
||||
}
|
||||
|
||||
const sorted = sample.samples.slice().sort((a, b) => a - b);
|
||||
|
||||
return {
|
||||
action,
|
||||
count: sample.count,
|
||||
errors: sample.errors,
|
||||
lastMs: sample.samples[sample.samples.length - 1],
|
||||
minMs: sorted[0],
|
||||
maxMs: sorted[sorted.length - 1],
|
||||
p50Ms: percentile(sorted, 50),
|
||||
p95Ms: percentile(sorted, 95)
|
||||
};
|
||||
};
|
||||
|
||||
export const HK_METRICS_SAMPLE_CAP = SAMPLE_CAP;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { pushRecentLookup, RECENT_LOOKUPS_LIMIT, RecentLookupEntry } from './HousekeepingRecentLookups';
|
||||
|
||||
const entry = (over: Partial<RecentLookupEntry> = {}): RecentLookupEntry => ({
|
||||
kind: 'user',
|
||||
id: 1,
|
||||
label: 'alice',
|
||||
at: 1,
|
||||
...over
|
||||
});
|
||||
|
||||
describe('pushRecentLookup', () =>
|
||||
{
|
||||
it('prepends a new entry to an empty list', () =>
|
||||
{
|
||||
const next = pushRecentLookup([], entry({ id: 7, label: 'bob' }));
|
||||
|
||||
expect(next).toHaveLength(1);
|
||||
expect(next[0].id).toBe(7);
|
||||
});
|
||||
|
||||
it('moves an existing entry of the same kind+id to the front (and refreshes the timestamp)', () =>
|
||||
{
|
||||
const initial: RecentLookupEntry[] = [
|
||||
entry({ kind: 'user', id: 1, label: 'alice', at: 1 }),
|
||||
entry({ kind: 'user', id: 2, label: 'bob', at: 2 })
|
||||
];
|
||||
const next = pushRecentLookup(initial, entry({ kind: 'user', id: 2, label: 'bob', at: 99 }));
|
||||
|
||||
expect(next.map(e => e.id)).toEqual([ 2, 1 ]);
|
||||
expect(next[0].at).toBe(99);
|
||||
});
|
||||
|
||||
it('does NOT dedupe across kinds (user #1 and room #1 are distinct)', () =>
|
||||
{
|
||||
const next = pushRecentLookup(
|
||||
[ entry({ kind: 'user', id: 1 }) ],
|
||||
entry({ kind: 'room', id: 1, label: 'lobby' })
|
||||
);
|
||||
|
||||
expect(next).toHaveLength(2);
|
||||
expect(next[0].kind).toBe('room');
|
||||
expect(next[1].kind).toBe('user');
|
||||
});
|
||||
|
||||
it('trims past the limit by dropping the tail entry (caller invariant: newest at index 0, oldest at the end)', () =>
|
||||
{
|
||||
// Build the initial list in store-order: index 0 is the most-recently-pushed
|
||||
// entry, index N-1 is the oldest. id=1 has the FRESHEST `at`, id=N has the OLDEST.
|
||||
const initial: RecentLookupEntry[] = Array.from({ length: RECENT_LOOKUPS_LIMIT }, (_, i) =>
|
||||
entry({ kind: 'user', id: i + 1, label: `u${ i + 1 }`, at: RECENT_LOOKUPS_LIMIT - i })
|
||||
);
|
||||
const tailId = initial[initial.length - 1].id;
|
||||
const next = pushRecentLookup(initial, entry({ kind: 'user', id: 999, label: 'new', at: 1000 }));
|
||||
|
||||
expect(next).toHaveLength(RECENT_LOOKUPS_LIMIT);
|
||||
expect(next[0].id).toBe(999);
|
||||
// The tail entry (the oldest, by store invariant) is the one that falls off
|
||||
expect(next.find(e => e.id === tailId)).toBeUndefined();
|
||||
// The head of the previous list is still around, now at index 1
|
||||
expect(next[1].id).toBe(initial[0].id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
const STORAGE_KEY = 'nitro.housekeeping.recent';
|
||||
const MAX_ENTRIES = 8;
|
||||
|
||||
export interface RecentLookupEntry
|
||||
{
|
||||
kind: 'user' | 'room';
|
||||
id: number;
|
||||
label: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
const isEntry = (value: unknown): value is RecentLookupEntry =>
|
||||
{
|
||||
if(!value || typeof value !== 'object') return false;
|
||||
|
||||
const obj = value as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
(obj.kind === 'user' || obj.kind === 'room') &&
|
||||
Number.isFinite(obj.id) &&
|
||||
typeof obj.label === 'string' &&
|
||||
Number.isFinite(obj.at)
|
||||
);
|
||||
};
|
||||
|
||||
const readStore = (): RecentLookupEntry[] =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
|
||||
if(!raw) return [];
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
if(!Array.isArray(parsed)) return [];
|
||||
|
||||
return parsed.filter(isEntry);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const writeStore = (entries: RecentLookupEntry[]): void =>
|
||||
{
|
||||
try
|
||||
{
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
|
||||
}
|
||||
catch
|
||||
{}
|
||||
};
|
||||
|
||||
export const loadRecentLookups = (): RecentLookupEntry[] => readStore();
|
||||
|
||||
/**
|
||||
* Push an entry to the front of the recent-lookups stack. Existing
|
||||
* entries with the same kind+id are deduped (so reopening the same
|
||||
* user doesn't bury fresher entries), and the list is trimmed to
|
||||
* MAX_ENTRIES. Pure for the in-memory transform — the persistence is
|
||||
* a side effect on top.
|
||||
*/
|
||||
export const pushRecentLookup = (current: RecentLookupEntry[], entry: RecentLookupEntry): RecentLookupEntry[] =>
|
||||
{
|
||||
const filtered = current.filter(item => !(item.kind === entry.kind && item.id === entry.id));
|
||||
const next = [ entry, ...filtered ].slice(0, MAX_ENTRIES);
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
export const persistRecentLookups = (entries: RecentLookupEntry[]): void => writeStore(entries);
|
||||
|
||||
export const clearRecentLookups = (): void => writeStore([]);
|
||||
|
||||
export const RECENT_LOOKUPS_LIMIT = MAX_ENTRIES;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
findTemplateById, HK_SANCTION_TEMPLATES, HousekeepingSanctionType, templatesByType
|
||||
} from './HousekeepingSanctionTemplates';
|
||||
|
||||
describe('HK_SANCTION_TEMPLATES', () =>
|
||||
{
|
||||
it('has a unique id for every template', () =>
|
||||
{
|
||||
const ids = HK_SANCTION_TEMPLATES.map(t => t.id);
|
||||
const unique = new Set(ids);
|
||||
|
||||
expect(unique.size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('covers every sanction type at least once', () =>
|
||||
{
|
||||
const types = new Set(HK_SANCTION_TEMPLATES.map(t => t.type));
|
||||
|
||||
expect(types.has(HousekeepingSanctionType.BAN)).toBe(true);
|
||||
expect(types.has(HousekeepingSanctionType.MUTE)).toBe(true);
|
||||
expect(types.has(HousekeepingSanctionType.KICK)).toBe(true);
|
||||
expect(types.has(HousekeepingSanctionType.TRADE_LOCK)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses durationValue=0 for KICK templates only (kick is instant, no duration)', () =>
|
||||
{
|
||||
for(const template of HK_SANCTION_TEMPLATES)
|
||||
{
|
||||
if(template.type === HousekeepingSanctionType.KICK) expect(template.durationValue).toBe(0);
|
||||
else expect(template.durationValue).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('every template has a non-empty default reason (avoids empty-reason validation failures)', () =>
|
||||
{
|
||||
for(const template of HK_SANCTION_TEMPLATES)
|
||||
{
|
||||
expect(template.defaultReason.trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('findTemplateById', () =>
|
||||
{
|
||||
it('returns the matching template', () =>
|
||||
{
|
||||
expect(findTemplateById('ban_24h')?.type).toBe(HousekeepingSanctionType.BAN);
|
||||
expect(findTemplateById('ban_24h')?.durationValue).toBe(24);
|
||||
});
|
||||
|
||||
it('returns null for an unknown id', () =>
|
||||
{
|
||||
expect(findTemplateById('does-not-exist')).toBeNull();
|
||||
expect(findTemplateById('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('templatesByType', () =>
|
||||
{
|
||||
it('filters the list down to a single type', () =>
|
||||
{
|
||||
const bans = templatesByType(HousekeepingSanctionType.BAN);
|
||||
|
||||
expect(bans.length).toBeGreaterThan(0);
|
||||
expect(bans.every(t => t.type === HousekeepingSanctionType.BAN)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns an empty list for unknown types (defensive)', () =>
|
||||
{
|
||||
expect(templatesByType('unknown' as never)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
export const HousekeepingSanctionType = {
|
||||
BAN: 'ban',
|
||||
MUTE: 'mute',
|
||||
KICK: 'kick',
|
||||
TRADE_LOCK: 'trade_lock'
|
||||
} as const;
|
||||
|
||||
export type HousekeepingSanctionType = typeof HousekeepingSanctionType[keyof typeof HousekeepingSanctionType];
|
||||
|
||||
export interface HousekeepingSanctionTemplate
|
||||
{
|
||||
id: string;
|
||||
/** Display name (LocalizeText key OR plain label fallback). */
|
||||
name: string;
|
||||
type: HousekeepingSanctionType;
|
||||
/** Duration in hours for BAN / TRADE_LOCK, minutes for MUTE; ignored for KICK. */
|
||||
durationValue: number;
|
||||
/** Pre-canned reason — overridable from the UI textarea. */
|
||||
defaultReason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-canned sanction shortcuts. Lifted from the shape of mod-tools'
|
||||
* `MOD_ACTION_DEFINITIONS` (see `ModActionDefinition.ts`) but
|
||||
* simplified — HK doesn't need the CFH topic / sanctionTypeId
|
||||
* indirection because the HK HTTP API takes plain `(userId, reason,
|
||||
* duration)` triples.
|
||||
*
|
||||
* Operators that need different presets can mirror this file and
|
||||
* inject through the UI config layer down the road; for now keep
|
||||
* a flat default set covering the common cases.
|
||||
*/
|
||||
export const HK_SANCTION_TEMPLATES: HousekeepingSanctionTemplate[] = [
|
||||
{ id: 'kick', name: 'Kick', type: HousekeepingSanctionType.KICK, durationValue: 0, defaultReason: 'Removed from session' },
|
||||
{ id: 'mute_5m', name: 'Mute 5m', type: HousekeepingSanctionType.MUTE, durationValue: 5, defaultReason: 'Cool down — chat flood' },
|
||||
{ id: 'mute_60m', name: 'Mute 60m', type: HousekeepingSanctionType.MUTE, durationValue: 60, defaultReason: 'Mute — repeat offender' },
|
||||
{ id: 'ban_1h', name: 'Ban 1h', type: HousekeepingSanctionType.BAN, durationValue: 1, defaultReason: 'Temporary ban — rule violation' },
|
||||
{ id: 'ban_24h', name: 'Ban 24h', type: HousekeepingSanctionType.BAN, durationValue: 24, defaultReason: '24h ban — rule violation' },
|
||||
{ id: 'ban_7d', name: 'Ban 7d', type: HousekeepingSanctionType.BAN, durationValue: 168, defaultReason: '7-day ban — serious violation' },
|
||||
{ id: 'ban_30d', name: 'Ban 30d', type: HousekeepingSanctionType.BAN, durationValue: 720, defaultReason: '30-day ban — final warning' },
|
||||
{ id: 'ban_perm', name: 'Ban permanent', type: HousekeepingSanctionType.BAN, durationValue: 24 * 365 * 100, defaultReason: 'Permanent ban' },
|
||||
{ id: 'tlock_7d', name: 'Trade lock 7d', type: HousekeepingSanctionType.TRADE_LOCK, durationValue: 168, defaultReason: 'Trade lock — suspected scam' },
|
||||
{ id: 'tlock_perm', name: 'Trade lock perm', type: HousekeepingSanctionType.TRADE_LOCK, durationValue: 24 * 365 * 100, defaultReason: 'Permanent trade lock' }
|
||||
];
|
||||
|
||||
export const findTemplateById = (id: string): HousekeepingSanctionTemplate | null =>
|
||||
HK_SANCTION_TEMPLATES.find(t => t.id === id) ?? null;
|
||||
|
||||
export const templatesByType = (type: HousekeepingSanctionType): HousekeepingSanctionTemplate[] =>
|
||||
HK_SANCTION_TEMPLATES.filter(t => t.type === type);
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
HK_MAX_BAN_HOURS, HK_MAX_GIVE_AMOUNT, HK_MAX_RANK, HK_MIN_RANK, HousekeepingErrorKey,
|
||||
validateAmount, validateBanHours, validatePositiveId, validateRank, validateReason, validateUsername
|
||||
} from './HousekeepingValidation';
|
||||
|
||||
describe('validateUsername', () =>
|
||||
{
|
||||
it('rejects empty / whitespace-only input', () =>
|
||||
{
|
||||
expect(validateUsername('')).toBe(HousekeepingErrorKey.EMPTY_USERNAME);
|
||||
expect(validateUsername(' ')).toBe(HousekeepingErrorKey.EMPTY_USERNAME);
|
||||
expect(validateUsername(null)).toBe(HousekeepingErrorKey.EMPTY_USERNAME);
|
||||
});
|
||||
|
||||
it('accepts any non-empty trimmed value (server is source of truth for valid chars)', () =>
|
||||
{
|
||||
expect(validateUsername('alice')).toBe(HousekeepingErrorKey.NONE);
|
||||
expect(validateUsername(' Bob ')).toBe(HousekeepingErrorKey.NONE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePositiveId', () =>
|
||||
{
|
||||
it('rejects non-positive / non-integer / NaN / non-finite', () =>
|
||||
{
|
||||
expect(validatePositiveId(0, 'user')).toBe(HousekeepingErrorKey.INVALID_USER_ID);
|
||||
expect(validatePositiveId(-1, 'user')).toBe(HousekeepingErrorKey.INVALID_USER_ID);
|
||||
expect(validatePositiveId(1.5, 'user')).toBe(HousekeepingErrorKey.INVALID_USER_ID);
|
||||
expect(validatePositiveId(NaN, 'user')).toBe(HousekeepingErrorKey.INVALID_USER_ID);
|
||||
expect(validatePositiveId(Infinity, 'user')).toBe(HousekeepingErrorKey.INVALID_USER_ID);
|
||||
});
|
||||
|
||||
it('emits INVALID_ROOM_ID for the room kind', () =>
|
||||
{
|
||||
expect(validatePositiveId(0, 'room')).toBe(HousekeepingErrorKey.INVALID_ROOM_ID);
|
||||
expect(validatePositiveId(-2, 'room')).toBe(HousekeepingErrorKey.INVALID_ROOM_ID);
|
||||
});
|
||||
|
||||
it('accepts positive integers', () =>
|
||||
{
|
||||
expect(validatePositiveId(1, 'user')).toBe(HousekeepingErrorKey.NONE);
|
||||
expect(validatePositiveId(99999, 'room')).toBe(HousekeepingErrorKey.NONE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAmount', () =>
|
||||
{
|
||||
it('rejects non-positive / non-integer / non-finite', () =>
|
||||
{
|
||||
expect(validateAmount(0)).toBe(HousekeepingErrorKey.INVALID_AMOUNT);
|
||||
expect(validateAmount(-5)).toBe(HousekeepingErrorKey.INVALID_AMOUNT);
|
||||
expect(validateAmount(1.5)).toBe(HousekeepingErrorKey.INVALID_AMOUNT);
|
||||
expect(validateAmount(NaN)).toBe(HousekeepingErrorKey.INVALID_AMOUNT);
|
||||
expect(validateAmount(Infinity)).toBe(HousekeepingErrorKey.INVALID_AMOUNT);
|
||||
});
|
||||
|
||||
it('rejects amounts above the cap', () =>
|
||||
{
|
||||
expect(validateAmount(HK_MAX_GIVE_AMOUNT + 1)).toBe(HousekeepingErrorKey.AMOUNT_TOO_LARGE);
|
||||
});
|
||||
|
||||
it('accepts the cap itself and any positive integer below it', () =>
|
||||
{
|
||||
expect(validateAmount(1)).toBe(HousekeepingErrorKey.NONE);
|
||||
expect(validateAmount(1000)).toBe(HousekeepingErrorKey.NONE);
|
||||
expect(validateAmount(HK_MAX_GIVE_AMOUNT)).toBe(HousekeepingErrorKey.NONE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateReason', () =>
|
||||
{
|
||||
it('rejects empty / whitespace-only', () =>
|
||||
{
|
||||
expect(validateReason('')).toBe(HousekeepingErrorKey.EMPTY_REASON);
|
||||
expect(validateReason(' ')).toBe(HousekeepingErrorKey.EMPTY_REASON);
|
||||
expect(validateReason(null)).toBe(HousekeepingErrorKey.EMPTY_REASON);
|
||||
});
|
||||
|
||||
it('accepts any non-empty reason', () =>
|
||||
{
|
||||
expect(validateReason('spam')).toBe(HousekeepingErrorKey.NONE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBanHours', () =>
|
||||
{
|
||||
it('rejects non-positive / non-finite', () =>
|
||||
{
|
||||
expect(validateBanHours(0)).toBe(HousekeepingErrorKey.INVALID_HOURS);
|
||||
expect(validateBanHours(-1)).toBe(HousekeepingErrorKey.INVALID_HOURS);
|
||||
expect(validateBanHours(NaN)).toBe(HousekeepingErrorKey.INVALID_HOURS);
|
||||
expect(validateBanHours(Infinity)).toBe(HousekeepingErrorKey.INVALID_HOURS);
|
||||
});
|
||||
|
||||
it('rejects values above the 100-year cap', () =>
|
||||
{
|
||||
expect(validateBanHours(HK_MAX_BAN_HOURS + 1)).toBe(HousekeepingErrorKey.INVALID_HOURS);
|
||||
});
|
||||
|
||||
it('accepts the cap and any positive value below it (fractional included — minutes / partial hours)', () =>
|
||||
{
|
||||
expect(validateBanHours(1)).toBe(HousekeepingErrorKey.NONE);
|
||||
expect(validateBanHours(0.5)).toBe(HousekeepingErrorKey.NONE);
|
||||
expect(validateBanHours(HK_MAX_BAN_HOURS)).toBe(HousekeepingErrorKey.NONE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateRank', () =>
|
||||
{
|
||||
it('rejects out-of-range values (sub-min, above-max, non-integer, non-finite)', () =>
|
||||
{
|
||||
expect(validateRank(HK_MIN_RANK - 1)).toBe(HousekeepingErrorKey.INVALID_RANK);
|
||||
expect(validateRank(HK_MAX_RANK + 1)).toBe(HousekeepingErrorKey.INVALID_RANK);
|
||||
expect(validateRank(1.5)).toBe(HousekeepingErrorKey.INVALID_RANK);
|
||||
expect(validateRank(NaN)).toBe(HousekeepingErrorKey.INVALID_RANK);
|
||||
});
|
||||
|
||||
it('accepts boundary values', () =>
|
||||
{
|
||||
expect(validateRank(HK_MIN_RANK)).toBe(HousekeepingErrorKey.NONE);
|
||||
expect(validateRank(HK_MAX_RANK)).toBe(HousekeepingErrorKey.NONE);
|
||||
expect(validateRank(5)).toBe(HousekeepingErrorKey.NONE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
export const HousekeepingErrorKey = {
|
||||
NONE: 'none',
|
||||
EMPTY_USERNAME: 'empty_username',
|
||||
INVALID_USER_ID: 'invalid_user_id',
|
||||
INVALID_ROOM_ID: 'invalid_room_id',
|
||||
INVALID_AMOUNT: 'invalid_amount',
|
||||
AMOUNT_TOO_LARGE: 'amount_too_large',
|
||||
EMPTY_REASON: 'empty_reason',
|
||||
INVALID_HOURS: 'invalid_hours',
|
||||
INVALID_RANK: 'invalid_rank'
|
||||
} as const;
|
||||
|
||||
export type HousekeepingErrorKey = typeof HousekeepingErrorKey[keyof typeof HousekeepingErrorKey];
|
||||
|
||||
export const HK_MAX_GIVE_AMOUNT = 1_000_000_000;
|
||||
export const HK_MAX_BAN_HOURS = 24 * 365 * 100;
|
||||
export const HK_MIN_RANK = 1;
|
||||
export const HK_MAX_RANK = 12;
|
||||
|
||||
export const validateUsername = (raw: string): HousekeepingErrorKey =>
|
||||
{
|
||||
if(!raw || raw.trim().length === 0) return HousekeepingErrorKey.EMPTY_USERNAME;
|
||||
|
||||
return HousekeepingErrorKey.NONE;
|
||||
};
|
||||
|
||||
export const validatePositiveId = (raw: number, kind: 'user' | 'room'): HousekeepingErrorKey =>
|
||||
{
|
||||
if(!Number.isFinite(raw) || !Number.isInteger(raw) || raw <= 0)
|
||||
{
|
||||
return kind === 'user' ? HousekeepingErrorKey.INVALID_USER_ID : HousekeepingErrorKey.INVALID_ROOM_ID;
|
||||
}
|
||||
|
||||
return HousekeepingErrorKey.NONE;
|
||||
};
|
||||
|
||||
export const validateAmount = (raw: number): HousekeepingErrorKey =>
|
||||
{
|
||||
if(!Number.isFinite(raw) || !Number.isInteger(raw) || raw <= 0) return HousekeepingErrorKey.INVALID_AMOUNT;
|
||||
if(raw > HK_MAX_GIVE_AMOUNT) return HousekeepingErrorKey.AMOUNT_TOO_LARGE;
|
||||
|
||||
return HousekeepingErrorKey.NONE;
|
||||
};
|
||||
|
||||
export const validateReason = (raw: string): HousekeepingErrorKey =>
|
||||
{
|
||||
if(!raw || raw.trim().length === 0) return HousekeepingErrorKey.EMPTY_REASON;
|
||||
|
||||
return HousekeepingErrorKey.NONE;
|
||||
};
|
||||
|
||||
export const validateBanHours = (raw: number): HousekeepingErrorKey =>
|
||||
{
|
||||
if(!Number.isFinite(raw) || raw <= 0) return HousekeepingErrorKey.INVALID_HOURS;
|
||||
if(raw > HK_MAX_BAN_HOURS) return HousekeepingErrorKey.INVALID_HOURS;
|
||||
|
||||
return HousekeepingErrorKey.NONE;
|
||||
};
|
||||
|
||||
export const validateRank = (raw: number): HousekeepingErrorKey =>
|
||||
{
|
||||
if(!Number.isFinite(raw) || !Number.isInteger(raw)) return HousekeepingErrorKey.INVALID_RANK;
|
||||
if(raw < HK_MIN_RANK || raw > HK_MAX_RANK) return HousekeepingErrorKey.INVALID_RANK;
|
||||
|
||||
return HousekeepingErrorKey.NONE;
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
export interface IHousekeepingUser
|
||||
{
|
||||
id: number;
|
||||
username: string;
|
||||
motto: string;
|
||||
figure: string;
|
||||
rank: number;
|
||||
rankName: string;
|
||||
online: boolean;
|
||||
lastOnlineAt: number | null;
|
||||
creditsBalance: number;
|
||||
ducketsBalance: number;
|
||||
diamondsBalance: number;
|
||||
email: string;
|
||||
ipLast: string;
|
||||
isBanned: boolean;
|
||||
isMuted: boolean;
|
||||
isTradeLocked: boolean;
|
||||
}
|
||||
|
||||
export interface IHousekeepingRoom
|
||||
{
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
ownerId: number;
|
||||
ownerName: string;
|
||||
userCount: number;
|
||||
maxUsers: number;
|
||||
isLocked: boolean;
|
||||
isMuted: boolean;
|
||||
isPublic: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface IHousekeepingActionResult
|
||||
{
|
||||
ok: boolean;
|
||||
actionId: number | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface IHousekeepingActionLogEntry
|
||||
{
|
||||
id: number;
|
||||
timestamp: number;
|
||||
actorId: number;
|
||||
actorName: string;
|
||||
targetType: 'user' | 'room' | 'hotel';
|
||||
targetId: number | null;
|
||||
targetLabel: string;
|
||||
action: string;
|
||||
detail: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface IHousekeepingUserSummary
|
||||
{
|
||||
id: number;
|
||||
username: string;
|
||||
figure: string;
|
||||
online: boolean;
|
||||
rank: number;
|
||||
}
|
||||
|
||||
export interface IHousekeepingRoomSummary
|
||||
{
|
||||
id: number;
|
||||
name: string;
|
||||
userCount: number;
|
||||
ownerName: string;
|
||||
}
|
||||
|
||||
export interface IHousekeepingDashboard
|
||||
{
|
||||
onlineUsers: number;
|
||||
totalUsers: number;
|
||||
activeRooms: number;
|
||||
totalRooms: number;
|
||||
peakOnlineToday: number;
|
||||
peakOnlineAllTime: number;
|
||||
pendingTickets: number;
|
||||
sanctionsLast24h: number;
|
||||
serverUptimeSeconds: number;
|
||||
serverVersion: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export * from './HousekeepingActionType';
|
||||
export * from './HousekeepingApi';
|
||||
export * from './HousekeepingConfig';
|
||||
export * from './HousekeepingFormatters';
|
||||
export * from './HousekeepingHttpClient';
|
||||
export * from './HousekeepingMetrics';
|
||||
export * from './HousekeepingRecentLookups';
|
||||
export * from './HousekeepingSanctionTemplates';
|
||||
export * from './HousekeepingValidation';
|
||||
export * from './IHousekeepingTypes';
|
||||
@@ -15,6 +15,7 @@ export * from './groups';
|
||||
export * from './guide-tool';
|
||||
export * from './hc-center';
|
||||
export * from './help';
|
||||
export * from './housekeeping';
|
||||
export * from './inventory';
|
||||
export * from './mod-tools';
|
||||
export * from './navigator';
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { GetCommunication, IMessageEvent } from '@nitrots/nitro-renderer';
|
||||
|
||||
export interface AwaitMessageEventInit<T extends IMessageEvent, R = T>
|
||||
{
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
accept?: (event: T) => boolean;
|
||||
/**
|
||||
* Synchronous mapper that runs INSIDE the subscribe callback, while
|
||||
* the parser is still valid. Whatever it returns is what the Promise
|
||||
* resolves to. **MUST** be used for any read of `event.getParser()` —
|
||||
* the renderer recycles parser instances (the `_parser` field is
|
||||
* nulled / repopulated for the next packet) so reading the parser
|
||||
* AFTER the await microtask gives back null fields. Snapshot the
|
||||
* data here, return a plain object/value, then your async code is
|
||||
* safe.
|
||||
*/
|
||||
select?: (event: T) => R;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* One-shot Promise adapter over the renderer's CommunicationManager.subscribeMessage.
|
||||
* Resolves on the first matching event, rejects on timeout / abort / connection error.
|
||||
* Used by request-response patterns (e.g. housekeeping lookups) that need a Promise
|
||||
* facade over the underlying packet stream.
|
||||
*
|
||||
* **Read the parser inside `select`, not after the await.** See the
|
||||
* AwaitMessageEventInit.select javadoc — the renderer recycles parsers,
|
||||
* so post-await reads come back null.
|
||||
*/
|
||||
export const awaitMessageEvent = <T extends IMessageEvent, R = T>(eventCtor: new (callback: (event: T) => void) => T, init: AwaitMessageEventInit<T, R> = {}): Promise<R> =>
|
||||
{
|
||||
const { timeoutMs = DEFAULT_TIMEOUT_MS, signal, accept, select } = init;
|
||||
|
||||
return new Promise<R>((resolve, reject) =>
|
||||
{
|
||||
if(signal?.aborted)
|
||||
{
|
||||
reject(new DOMException('aborted', 'AbortError'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const communication = GetCommunication();
|
||||
|
||||
if(!communication || !communication.connection)
|
||||
{
|
||||
reject(new Error('no_connection'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let onAbort: (() => void) | null = null;
|
||||
|
||||
const cleanup = () =>
|
||||
{
|
||||
settled = true;
|
||||
if(unsubscribe) unsubscribe();
|
||||
unsubscribe = null;
|
||||
if(timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
if(onAbort && signal) signal.removeEventListener('abort', onAbort);
|
||||
onAbort = null;
|
||||
};
|
||||
|
||||
unsubscribe = communication.subscribeMessage(eventCtor, event =>
|
||||
{
|
||||
if(settled) return;
|
||||
|
||||
if(accept && !accept(event)) return;
|
||||
|
||||
// Snapshot the data synchronously: post-await reads of the
|
||||
// event's parser come back null because the renderer recycles
|
||||
// parser instances between packets. If no select supplied,
|
||||
// resolve with the raw event for backwards-compat callers
|
||||
// that don't touch the parser.
|
||||
let snapshot: R;
|
||||
|
||||
try
|
||||
{
|
||||
snapshot = select ? select(event) : (event as unknown as R);
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
cleanup();
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup();
|
||||
resolve(snapshot);
|
||||
});
|
||||
|
||||
timer = setTimeout(() =>
|
||||
{
|
||||
if(settled) return;
|
||||
cleanup();
|
||||
reject(new Error('timeout'));
|
||||
}, timeoutMs);
|
||||
|
||||
if(signal)
|
||||
{
|
||||
onAbort = () =>
|
||||
{
|
||||
if(settled) return;
|
||||
cleanup();
|
||||
reject(new DOMException('aborted', 'AbortError'));
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './awaitMessageEvent';
|
||||
export * from './CreateLinkEvent';
|
||||
export * from './GetConfigurationValue';
|
||||
export * from './OpenUrl';
|
||||
|
||||
@@ -23,6 +23,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';
|
||||
@@ -142,6 +143,7 @@ export const MainView: FC<{}> = props =>
|
||||
<TranslationBootstrap />
|
||||
<GoogleAdsView />
|
||||
<ModToolsView />
|
||||
<HousekeepingView />
|
||||
<WiredCreatorToolsView />
|
||||
<RoomView />
|
||||
<ChatHistoryView />
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { FaCheck, FaCopy, FaKey, FaTimes } from 'react-icons/fa';
|
||||
import { LocalizeText } from '../../api';
|
||||
import { useHousekeepingStore } from '../../hooks';
|
||||
|
||||
const COPY_CONFIRM_MS = 1600;
|
||||
|
||||
/**
|
||||
* Password-reveal card — surfaces the plaintext password the emulator
|
||||
* returned from `HousekeepingResetUserPasswordEvent` so the operator
|
||||
* can read it once and copy it out-of-band to the user.
|
||||
*
|
||||
* Sensitive data, so:
|
||||
* - Renders OUTSIDE the auto-dismissing status banner (which truncates
|
||||
* long content and disappears after 4s).
|
||||
* - Stays put until the operator explicitly dismisses — they have to
|
||||
* acknowledge they've copied/communicated the secret.
|
||||
* - The plaintext lives in `useHousekeepingStore.passwordReveal` and
|
||||
* never flows through the generic success-toast / banner pipeline
|
||||
* (`useHousekeepingActions.resetUserPassword` intercepts it before
|
||||
* `wrap`'s default path).
|
||||
* - The clipboard write uses the modern `navigator.clipboard.writeText`
|
||||
* when available, with a `document.execCommand('copy')` fallback for
|
||||
* non-secure-context legacy paths so the button still works inside an
|
||||
* `http://` deployment.
|
||||
*/
|
||||
export const HousekeepingPasswordReveal: FC = () =>
|
||||
{
|
||||
const { passwordReveal, clearPasswordReveal } = useHousekeepingStore();
|
||||
const [ copyState, setCopyState ] = useState<'idle' | 'ok' | 'fail'>('idle');
|
||||
|
||||
// Reset the "copied!" visual whenever a new reveal lands so the
|
||||
// operator doesn't see a stale checkmark from a previous reset.
|
||||
useEffect(() =>
|
||||
{
|
||||
setCopyState('idle');
|
||||
}, [ passwordReveal?.password ]);
|
||||
|
||||
// Auto-revert the copy-confirmation icon back to the copy icon
|
||||
// a short while after a successful copy. The plaintext itself
|
||||
// stays revealed until the operator explicitly dismisses.
|
||||
useEffect(() =>
|
||||
{
|
||||
if(copyState === 'idle') return;
|
||||
|
||||
const handle = window.setTimeout(() => setCopyState('idle'), COPY_CONFIRM_MS);
|
||||
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [ copyState ]);
|
||||
|
||||
if(!passwordReveal) return null;
|
||||
|
||||
const copyPassword = async () =>
|
||||
{
|
||||
const text = passwordReveal.password;
|
||||
|
||||
if(!text) return;
|
||||
|
||||
// Modern path — requires a secure context (https / wss / localhost).
|
||||
if(typeof navigator !== 'undefined' && navigator.clipboard && window.isSecureContext)
|
||||
{
|
||||
try
|
||||
{
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopyState('ok');
|
||||
return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fall through to the legacy path below — some browsers
|
||||
// still gate the modern API behind extra permissions even
|
||||
// in secure contexts.
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy fallback: stage a textarea, select, exec copy. Works
|
||||
// on plain-http deployments where `navigator.clipboard` is
|
||||
// refused. The textarea is positioned off-screen so the user
|
||||
// doesn't see a flash.
|
||||
try
|
||||
{
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', '');
|
||||
textarea.style.position = 'absolute';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.style.top = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, text.length);
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
setCopyState(ok ? 'ok' : 'fail');
|
||||
}
|
||||
catch
|
||||
{
|
||||
setCopyState('fail');
|
||||
}
|
||||
};
|
||||
|
||||
const copyIcon = copyState === 'ok' ? <FaCheck size={ 11 } /> : <FaCopy size={ 11 } />;
|
||||
const copyLabel = copyState === 'ok'
|
||||
? LocalizeText('housekeeping.password.copied')
|
||||
: copyState === 'fail'
|
||||
? LocalizeText('housekeeping.password.copy_failed')
|
||||
: LocalizeText('housekeeping.password.copy');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 px-2.5 py-2 border-y border-amber-200 bg-gradient-to-r from-amber-50 via-yellow-50 to-amber-50" role="status">
|
||||
<div className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider font-semibold text-amber-700">
|
||||
<FaKey size={ 10 } />
|
||||
<span className="grow">
|
||||
{ LocalizeText('housekeeping.password.title', [ 'username', 'id' ], [ passwordReveal.username || '—', String(passwordReveal.userId) ]) }
|
||||
</span>
|
||||
<button
|
||||
className="inline-flex items-center justify-center w-5 h-5 rounded text-amber-700 hover:text-amber-900 hover:bg-amber-200/60"
|
||||
onClick={ () => clearPasswordReveal() }
|
||||
title={ LocalizeText('housekeeping.password.dismiss') }
|
||||
aria-label={ LocalizeText('housekeeping.password.dismiss') }>
|
||||
<FaTimes size={ 10 } />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Readonly input lets the operator triple-click + Ctrl+C
|
||||
as a manual fallback to the copy button. Monospace +
|
||||
tabular-nums keeps lookalikes (Il1, O0) visually
|
||||
distinct so they can read it aloud without typos. */}
|
||||
<input
|
||||
readOnly
|
||||
type="text"
|
||||
value={ passwordReveal.password }
|
||||
className="grow font-mono tabular-nums text-sm px-2 py-1 rounded border border-amber-300 bg-white text-amber-950 focus:outline-none focus:ring-1 focus:ring-amber-400 select-all"
|
||||
onFocus={ event => event.currentTarget.select() }
|
||||
aria-label={ LocalizeText('housekeeping.password.value_label') } />
|
||||
<button
|
||||
className={ `inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-semibold border transition-colors ${ copyState === 'ok' ? 'bg-emerald-100 border-emerald-300 text-emerald-800' : copyState === 'fail' ? 'bg-rose-100 border-rose-300 text-rose-800' : 'bg-amber-100 border-amber-300 text-amber-900 hover:bg-amber-200' }` }
|
||||
onClick={ copyPassword }
|
||||
title={ copyLabel }
|
||||
aria-label={ copyLabel }>
|
||||
{ copyIcon }
|
||||
<span>{ copyLabel }</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-amber-700/80 leading-snug">{ LocalizeText('housekeeping.password.hint') }</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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,209 @@
|
||||
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 { HousekeepingPasswordReveal } from './HousekeepingPasswordReveal';
|
||||
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 />
|
||||
<HousekeepingPasswordReveal />
|
||||
<NitroCardContentView className="text-black" gap={ 2 }>
|
||||
{ activeView }
|
||||
</NitroCardContentView>
|
||||
</NitroCardView>
|
||||
</WidgetErrorBoundary>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
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();
|
||||
const telemetryEnabled = useMemo(() => GetConfigurationValue<boolean>('housekeeping.telemetry.enabled', false) === true, []);
|
||||
const [ isTelemetryExpanded, setIsTelemetryExpanded ] = useState(false);
|
||||
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">
|
||||
<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 text-white">{ 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,305 @@
|
||||
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);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(dashboard) setRefreshedAt(Date.now());
|
||||
}, [ dashboard ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const id = setInterval(() => setNow(Date.now()), 1_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
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">
|
||||
<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 text-white">{ 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 &&
|
||||
<>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</> }
|
||||
|
||||
{ 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> }
|
||||
|
||||
{ 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,159 @@
|
||||
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">
|
||||
{ !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>
|
||||
) }
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<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 text-white">{ LocalizeText('housekeeping.economy.give_credits') }</span>
|
||||
</Button>
|
||||
</div>
|
||||
<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 text-white">{ LocalizeText('housekeeping.economy.give_duckets') }</span>
|
||||
</Button>
|
||||
</div>
|
||||
<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 text-white">{ LocalizeText('housekeeping.economy.give_diamonds') }</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<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 text-white">{ LocalizeText('housekeeping.economy.grant_item') }</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<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);
|
||||
|
||||
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 text-white">{ LocalizeText('housekeeping.hotel.alert.send') }</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,184 @@
|
||||
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;
|
||||
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">
|
||||
<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>
|
||||
{ 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>
|
||||
) }
|
||||
<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 text-white">{ LocalizeText('housekeeping.room.open') }</span>
|
||||
</Button>
|
||||
<Button variant="danger" disabled={ disableActions || selectedRoom?.isLocked } onClick={ () => closeRoom(selectedRoom.id) }>
|
||||
<FaLock size={ 10 } />
|
||||
<span className="ml-1 text-white">{ 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 text-white">{ LocalizeText('housekeeping.room.delete') }</span>
|
||||
</Button>
|
||||
</div>
|
||||
<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 text-white">{ LocalizeText('housekeeping.room.transfer') }</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,393 @@
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
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">
|
||||
<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={ () =>
|
||||
{
|
||||
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>
|
||||
|
||||
{ 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> }
|
||||
|
||||
{ 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">
|
||||
<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">
|
||||
<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>
|
||||
) }
|
||||
|
||||
{ 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> }
|
||||
|
||||
<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>
|
||||
|
||||
<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 text-white">{ 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 text-white">{ 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 text-white">{ LocalizeText('housekeeping.action.unban') }</span>
|
||||
</Button>
|
||||
<Button variant="danger" disabled={ disableActions } onClick={ () => forceDisconnectUser(selectedUser.id, reasonOrDefault) }>
|
||||
<FaPlug size={ 10 } />
|
||||
<span className="ml-1 text-white">{ LocalizeText('housekeeping.action.force_disconnect') }</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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 text-white">{ LocalizeText('housekeeping.action.set_rank') }</span>
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="dark" disabled={ disableActions } onClick={ () => resetUserPassword(selectedUser.id) }>
|
||||
<FaKey size={ 10 } />
|
||||
<span className="ml-1 text-white">{ 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>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { CreateLinkEvent, Dispose, DropBounce, EaseOut, JumpBy, Motions, NitroToolbarAnimateIconEvent, PerkAllowancesMessageEvent, PerkEnum, Queue, Wait, YouTubeRoomSettingsEvent } from '@nitrots/nitro-renderer';
|
||||
import { AnimatePresence, motion, Variants } from 'framer-motion';
|
||||
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { GetConfigurationValue, MessengerIconState, OpenMessengerChat, setYoutubeRoomEnabled, VisitDesktop } from '../../api';
|
||||
import { GetConfigurationValue, isHousekeepingEnabled, MessengerIconState, OpenMessengerChat, setYoutubeRoomEnabled, VisitDesktop } from '../../api';
|
||||
import { Flex, LayoutAvatarImageView, LayoutItemCountView } from '../../common';
|
||||
import { useAchievements, useFriends, useHasPermission, useInventoryUnseenTracker, useMessageEvent, useMessenger, useModTools, useNitroEvent, useSessionInfo, useWiredTools } from '../../hooks';
|
||||
import { ToolbarItemView } from './ToolbarItemView';
|
||||
@@ -43,6 +43,8 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
||||
const { iconState = MessengerIconState.HIDDEN } = useMessenger();
|
||||
const { openMonitor, showToolbarButton } = useWiredTools();
|
||||
const isMod = useHasPermission('acc_supporttool');
|
||||
const isHk = useHasPermission('acc_housekeeping');
|
||||
const hkEnabled = useMemo(() => isHousekeepingEnabled(), []);
|
||||
const { tickets = [] } = useModTools();
|
||||
const openTicketsCount = useMemo(
|
||||
() => isMod ? tickets.filter(ticket => ticket && (ticket.state === 1)).length : 0,
|
||||
@@ -270,6 +272,10 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
||||
<motion.div variants={ itemVariants }>
|
||||
<ToolbarItemView icon="furnieditor" onClick={ () => CreateLinkEvent('furni-editor/toggle') } className="tb-icon" />
|
||||
</motion.div> }
|
||||
{ (isHk && hkEnabled) &&
|
||||
<motion.div variants={ itemVariants }>
|
||||
<ToolbarItemView icon="housekeeping" onClick={ () => CreateLinkEvent('housekeeping/toggle') } className="tb-icon" />
|
||||
</motion.div> }
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
@@ -378,6 +384,10 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props =>
|
||||
<motion.div variants={ itemVariants }>
|
||||
<ToolbarItemView icon="furnieditor" onClick={ () => CreateLinkEvent('furni-editor/toggle') } className="tb-icon" />
|
||||
</motion.div> }
|
||||
{ (isHk && hkEnabled) &&
|
||||
<motion.div variants={ itemVariants }>
|
||||
<ToolbarItemView icon="housekeeping" onClick={ () => CreateLinkEvent('housekeeping/toggle') } className="tb-icon" />
|
||||
</motion.div> }
|
||||
<motion.div variants={ itemVariants } className="relative">
|
||||
<ToolbarItemView icon="friendall" onClick={ () => CreateLinkEvent('friends/toggle') } className="tb-icon" />
|
||||
{ (requests.length > 0) &&
|
||||
|
||||
@@ -77,6 +77,13 @@
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.nitro-icon.icon-housekeeping {
|
||||
background-image: url("@/assets/images/toolbar/icons/modtools.png");
|
||||
width: 29px;
|
||||
height: 34px;
|
||||
filter: hue-rotate(140deg);
|
||||
}
|
||||
|
||||
.nitro-icon.icon-furnieditor {
|
||||
background-image: url("@/assets/images/toolbar/icons/furnieditor.png");
|
||||
width: 30px;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './useHousekeeping';
|
||||
export * from './useHousekeepingActions';
|
||||
export * from './useHousekeepingConfirm';
|
||||
export * from './useHousekeepingStore';
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useHousekeepingActions } from './useHousekeepingActions';
|
||||
import { useHousekeepingStore } from './useHousekeepingStore';
|
||||
|
||||
/**
|
||||
* Single facade for the in-client housekeeping panel — composes the
|
||||
* shared store with the imperative actions. Consumers that only need
|
||||
* one side can still import `useHousekeepingStore` /
|
||||
* `useHousekeepingActions` directly; this hook exists for the panel
|
||||
* views that need both.
|
||||
*/
|
||||
export const useHousekeeping = () =>
|
||||
{
|
||||
const store = useHousekeepingStore();
|
||||
const actions = useHousekeepingActions();
|
||||
|
||||
return { ...store, ...actions };
|
||||
};
|
||||
@@ -0,0 +1,512 @@
|
||||
import { useCallback } from 'react';
|
||||
import { GetRoomSession, HousekeepingApi, HousekeepingErrorKey, IHousekeepingActionResult, LocalizeText, NotificationBubbleType, validateAmount, validateBanHours, validatePositiveId, validateRank, validateReason } from '../../api';
|
||||
import { useNotification } from '../notification';
|
||||
import { useHousekeepingStore } from './useHousekeepingStore';
|
||||
|
||||
const SUCCESS_KEY = 'housekeeping.action.success';
|
||||
const ERROR_KEY = 'housekeeping.action.error';
|
||||
|
||||
type ToastFn = (message: string, type: string, imageUrl?: string, internalLink?: string, senderName?: string) => void;
|
||||
|
||||
const localizeOrPassthrough = (key: string): string =>
|
||||
{
|
||||
if(!key) return '';
|
||||
if(!key.includes('.')) return key;
|
||||
|
||||
const localized = LocalizeText(key);
|
||||
|
||||
return (localized === key) ? key : localized;
|
||||
};
|
||||
|
||||
const wrap = async (
|
||||
runner: () => Promise<IHousekeepingActionResult>,
|
||||
markPending: () => void,
|
||||
markDone: (errorKey: string | null, successKey: string | null) => void,
|
||||
toast: ToastFn,
|
||||
recordMetric: (action: string, latencyMs: number, isError: boolean) => void,
|
||||
actionLabel: string
|
||||
): Promise<IHousekeepingActionResult | null> =>
|
||||
{
|
||||
markPending();
|
||||
|
||||
const startedAt = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
const measure = (isError: boolean) =>
|
||||
{
|
||||
const endedAt = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
|
||||
recordMetric(actionLabel, endedAt - startedAt, isError);
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
const result = await runner();
|
||||
|
||||
if(result && result.ok === false)
|
||||
{
|
||||
// Error path: status banner only — the banner is inline
|
||||
// and stays put until dismissed, more visible than a
|
||||
// transient bubble for a failure that needs operator
|
||||
// attention.
|
||||
markDone(result.message || ERROR_KEY, null);
|
||||
measure(true);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const successKey = result?.message || SUCCESS_KEY;
|
||||
|
||||
markDone(null, successKey);
|
||||
// Success path also fires a transient toast so the operator
|
||||
// gets feedback without scanning the banner — banner stays
|
||||
// as a fallback for users that have bubbles disabled.
|
||||
toast(localizeOrPassthrough(successKey), NotificationBubbleType.INFO);
|
||||
measure(false);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch(error)
|
||||
{
|
||||
markDone(String((error as Error)?.message ?? error), null);
|
||||
measure(true);
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const validationOr = (key: HousekeepingErrorKey, markDone: (e: string | null, s: string | null) => void): boolean =>
|
||||
{
|
||||
if(key === HousekeepingErrorKey.NONE) return true;
|
||||
|
||||
markDone(`housekeeping.validation.${ key }`, null);
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Imperative facade for every HK admin action. State (selected
|
||||
* user/room, status banner) lives in `useHousekeepingStore`; this
|
||||
* hook reads it for context (e.g. the currently-selected target)
|
||||
* and writes only the action-pending / status flags via
|
||||
* `markActionPending` / `markActionDone`. Keeping the read-only
|
||||
* state in a separate filter would still work, but the singleton
|
||||
* store keeps invocation simple for the panel views that already
|
||||
* pull state via `useHousekeepingStore`.
|
||||
*/
|
||||
export const useHousekeepingActions = () =>
|
||||
{
|
||||
const { selectedUser, selectedRoom, markActionPending, markActionDone, setSelectedUser, setSelectedRoom, recordActionMetric, revealPassword } = useHousekeepingStore();
|
||||
const { showSingleBubble } = useNotification();
|
||||
// Stable closure-bound runner so every action below stays a
|
||||
// one-liner: only the runner thunk + a per-action telemetry
|
||||
// label change per call site. The label keys into the metrics
|
||||
// map; a missing label defaults to "anonymous" so untagged calls
|
||||
// still produce a metric row.
|
||||
const runAction = useCallback((runner: () => Promise<IHousekeepingActionResult>, actionLabel: string = 'anonymous') =>
|
||||
wrap(runner, markActionPending, markActionDone, showSingleBubble, recordActionMetric, actionLabel),
|
||||
[ markActionPending, markActionDone, showSingleBubble, recordActionMetric ]);
|
||||
|
||||
// -- USER --------------------------------------------------------
|
||||
const banUser = useCallback(async (userId: number, reason: string, hours: number) =>
|
||||
{
|
||||
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
|
||||
if(!validationOr(validateReason(reason), markActionDone)) return null;
|
||||
if(!validationOr(validateBanHours(hours), markActionDone)) return null;
|
||||
|
||||
return runAction(() => HousekeepingApi.banUser(userId, reason, hours), 'banUser');
|
||||
}, [ runAction, markActionDone ]);
|
||||
|
||||
const unbanUser = useCallback(async (userId: number) =>
|
||||
{
|
||||
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
|
||||
|
||||
return runAction(() => HousekeepingApi.unbanUser(userId), 'unbanUser');
|
||||
}, [ runAction, markActionDone ]);
|
||||
|
||||
const muteUser = useCallback(async (userId: number, reason: string, minutes: number) =>
|
||||
{
|
||||
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
|
||||
if(!validationOr(validateReason(reason), markActionDone)) return null;
|
||||
if(!validationOr(validateBanHours(minutes), markActionDone)) return null;
|
||||
|
||||
return runAction(() => HousekeepingApi.muteUser(userId, reason, minutes), 'muteUser');
|
||||
}, [ runAction, markActionDone ]);
|
||||
|
||||
const kickUser = useCallback(async (userId: number, reason: string) =>
|
||||
{
|
||||
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
|
||||
if(!validationOr(validateReason(reason), markActionDone)) return null;
|
||||
|
||||
return runAction(() => HousekeepingApi.kickUser(userId, reason), 'kickUser');
|
||||
}, [ runAction, markActionDone ]);
|
||||
|
||||
const forceDisconnectUser = useCallback(async (userId: number, reason: string) =>
|
||||
{
|
||||
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
|
||||
if(!validationOr(validateReason(reason), markActionDone)) return null;
|
||||
|
||||
return runAction(() => HousekeepingApi.forceDisconnectUser(userId, reason), 'forceDisconnectUser');
|
||||
}, [ runAction, markActionDone ]);
|
||||
|
||||
const resetUserPassword = useCallback(async (userId: number) =>
|
||||
{
|
||||
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
|
||||
|
||||
// Run the action with a localizable success message — we
|
||||
// INTERCEPT before `wrap`'s default behavior leaks the plaintext
|
||||
// into the auto-dismissing status banner. The emulator returns
|
||||
// the freshly-generated plaintext in `result.message`; we lift it
|
||||
// into the dedicated `passwordReveal` slot which renders a
|
||||
// persistent card with a copy button. The wrapping `runAction`
|
||||
// would also fire a transient toast with whatever string lands
|
||||
// in `message`, so we bypass it via a direct API call + manual
|
||||
// status writes here.
|
||||
const username = (selectedUser && selectedUser.id === userId) ? selectedUser.username : '';
|
||||
|
||||
markActionPending();
|
||||
const startedAt = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
const measure = (isError: boolean) =>
|
||||
{
|
||||
const endedAt = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
recordActionMetric('resetUserPassword', endedAt - startedAt, isError);
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
const result = await HousekeepingApi.resetUserPassword(userId);
|
||||
|
||||
if(!result || result.ok === false)
|
||||
{
|
||||
markActionDone(result?.message || 'housekeeping.action.error', null);
|
||||
measure(true);
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
const plaintext = result.message ?? '';
|
||||
|
||||
if(plaintext) revealPassword(userId, username, plaintext);
|
||||
|
||||
// Generic success key — does NOT include the plaintext, so
|
||||
// even if the banner is visible the password isn't in it.
|
||||
markActionDone(null, 'housekeeping.action.reset_password.done');
|
||||
measure(false);
|
||||
return result;
|
||||
}
|
||||
catch(error)
|
||||
{
|
||||
markActionDone(String((error as Error)?.message ?? error), null);
|
||||
measure(true);
|
||||
return null;
|
||||
}
|
||||
}, [ markActionPending, markActionDone, selectedUser, revealPassword, recordActionMetric ]);
|
||||
|
||||
const setUserRank = useCallback(async (userId: number, rank: number) =>
|
||||
{
|
||||
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
|
||||
if(!validationOr(validateRank(rank), markActionDone)) return null;
|
||||
|
||||
const result = await runAction(() => HousekeepingApi.setUserRank(userId, rank), 'setUserRank');
|
||||
|
||||
if(result && result.ok !== false && selectedUser && selectedUser.id === userId)
|
||||
{
|
||||
setSelectedUser({ ...selectedUser, rank });
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [ runAction, markActionDone, selectedUser, setSelectedUser ]);
|
||||
|
||||
const tradeLockUser = useCallback(async (userId: number, hours: number, reason: string) =>
|
||||
{
|
||||
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
|
||||
if(!validationOr(validateReason(reason), markActionDone)) return null;
|
||||
if(!validationOr(validateBanHours(hours), markActionDone)) return null;
|
||||
|
||||
return runAction(() => HousekeepingApi.tradeLockUser(userId, hours, reason), 'tradeLockUser');
|
||||
}, [ runAction, markActionDone ]);
|
||||
|
||||
// -- ROOM --------------------------------------------------------
|
||||
const openRoom = useCallback(async (roomId: number) =>
|
||||
{
|
||||
if(!validationOr(validatePositiveId(roomId, 'room'), markActionDone)) return null;
|
||||
|
||||
const result = await runAction(() => HousekeepingApi.openRoom(roomId), 'openRoom');
|
||||
|
||||
if(result && result.ok !== false && selectedRoom && selectedRoom.id === roomId)
|
||||
{
|
||||
setSelectedRoom({ ...selectedRoom, isLocked: false });
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [ runAction, markActionDone, selectedRoom, setSelectedRoom ]);
|
||||
|
||||
const closeRoom = useCallback(async (roomId: number) =>
|
||||
{
|
||||
if(!validationOr(validatePositiveId(roomId, 'room'), markActionDone)) return null;
|
||||
|
||||
const result = await runAction(() => HousekeepingApi.closeRoom(roomId), 'closeRoom');
|
||||
|
||||
if(result && result.ok !== false && selectedRoom && selectedRoom.id === roomId)
|
||||
{
|
||||
setSelectedRoom({ ...selectedRoom, isLocked: true });
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [ runAction, markActionDone, selectedRoom, setSelectedRoom ]);
|
||||
|
||||
const muteRoom = useCallback(async (roomId: number, minutes: number) =>
|
||||
{
|
||||
if(!validationOr(validatePositiveId(roomId, 'room'), markActionDone)) return null;
|
||||
if(!validationOr(validateBanHours(minutes), markActionDone)) return null;
|
||||
|
||||
return runAction(() => HousekeepingApi.muteRoom(roomId, minutes), 'muteRoom');
|
||||
}, [ runAction, markActionDone ]);
|
||||
|
||||
const kickAllFromRoom = useCallback(async (roomId: number) =>
|
||||
{
|
||||
if(!validationOr(validatePositiveId(roomId, 'room'), markActionDone)) return null;
|
||||
|
||||
return runAction(() => HousekeepingApi.kickAllFromRoom(roomId), 'kickAllFromRoom');
|
||||
}, [ runAction, markActionDone ]);
|
||||
|
||||
const transferRoomOwnership = useCallback(async (roomId: number, newOwnerId: number) =>
|
||||
{
|
||||
if(!validationOr(validatePositiveId(roomId, 'room'), markActionDone)) return null;
|
||||
if(!validationOr(validatePositiveId(newOwnerId, 'user'), markActionDone)) return null;
|
||||
|
||||
return runAction(() => HousekeepingApi.transferRoomOwnership(roomId, newOwnerId), 'transferRoomOwnership');
|
||||
}, [ runAction, markActionDone ]);
|
||||
|
||||
const deleteRoom = useCallback(async (roomId: number) =>
|
||||
{
|
||||
if(!validationOr(validatePositiveId(roomId, 'room'), markActionDone)) return null;
|
||||
|
||||
const result = await runAction(() => HousekeepingApi.deleteRoom(roomId), 'deleteRoom');
|
||||
|
||||
if(result && result.ok !== false && selectedRoom && selectedRoom.id === roomId)
|
||||
{
|
||||
setSelectedRoom(null);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [ runAction, markActionDone, selectedRoom, setSelectedRoom ]);
|
||||
|
||||
// -- ECONOMY -----------------------------------------------------
|
||||
const giveCredits = useCallback(async (userId: number, amount: number) =>
|
||||
{
|
||||
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
|
||||
if(!validationOr(validateAmount(amount), markActionDone)) return null;
|
||||
|
||||
return runAction(() => HousekeepingApi.giveCredits(userId, amount), 'giveCredits');
|
||||
}, [ runAction, markActionDone ]);
|
||||
|
||||
const giveDuckets = useCallback(async (userId: number, amount: number) =>
|
||||
{
|
||||
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
|
||||
if(!validationOr(validateAmount(amount), markActionDone)) return null;
|
||||
|
||||
return runAction(() => HousekeepingApi.giveDuckets(userId, amount), 'giveDuckets');
|
||||
}, [ runAction, markActionDone ]);
|
||||
|
||||
const giveDiamonds = useCallback(async (userId: number, amount: number) =>
|
||||
{
|
||||
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
|
||||
if(!validationOr(validateAmount(amount), markActionDone)) return null;
|
||||
|
||||
return runAction(() => HousekeepingApi.giveDiamonds(userId, amount), 'giveDiamonds');
|
||||
}, [ runAction, markActionDone ]);
|
||||
|
||||
const grantItem = useCallback(async (userId: number, itemId: number, quantity: number) =>
|
||||
{
|
||||
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
|
||||
if(!validationOr(validatePositiveId(itemId, 'user'), markActionDone)) return null;
|
||||
if(!validationOr(validateAmount(quantity), markActionDone)) return null;
|
||||
|
||||
return runAction(() => HousekeepingApi.grantItem(userId, itemId, quantity), 'grantItem');
|
||||
}, [ runAction, markActionDone ]);
|
||||
|
||||
const setHcSubscription = useCallback(async (userId: number, days: number) =>
|
||||
{
|
||||
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
|
||||
if(!validationOr(validateAmount(days), markActionDone)) return null;
|
||||
|
||||
return runAction(() => HousekeepingApi.setHcSubscription(userId, days), 'setHcSubscription');
|
||||
}, [ runAction, markActionDone ]);
|
||||
|
||||
const sendHotelAlert = useCallback(async (message: string) =>
|
||||
{
|
||||
if(!validationOr(validateReason(message), markActionDone)) return null;
|
||||
|
||||
return runAction(() => HousekeepingApi.sendHotelAlert(message), 'sendHotelAlert');
|
||||
}, [ runAction, markActionDone ]);
|
||||
|
||||
// -- LIVE IN-ROOM ACTIONS ---------------------------------------
|
||||
// These bridge directly to the active RoomSession so the
|
||||
// sanction lands on the current game state (no server roundtrip
|
||||
// through the HTTP layer). Use for "the user is here, right
|
||||
// now" sanctions; persistent admin actions still go through the
|
||||
// HTTP API above.
|
||||
const kickFromCurrentRoom = useCallback((webUserId: number) =>
|
||||
{
|
||||
const session = GetRoomSession();
|
||||
|
||||
if(!session)
|
||||
{
|
||||
markActionDone('housekeeping.live.no_room', null);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
session.sendKickMessage(webUserId);
|
||||
markActionDone(null, 'housekeeping.live.kicked');
|
||||
showSingleBubble(localizeOrPassthrough('housekeeping.live.kicked'), NotificationBubbleType.INFO);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch(error)
|
||||
{
|
||||
markActionDone(String((error as Error)?.message ?? error), null);
|
||||
|
||||
return false;
|
||||
}
|
||||
}, [ markActionDone, showSingleBubble ]);
|
||||
|
||||
const banFromCurrentRoom = useCallback((webUserId: number, severity: 'hour' | 'day' | 'perm' = 'hour') =>
|
||||
{
|
||||
const session = GetRoomSession();
|
||||
|
||||
if(!session)
|
||||
{
|
||||
markActionDone('housekeeping.live.no_room', null);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const code = severity === 'perm' ? 'RWUAM_BAN_USER_PERM' : severity === 'day' ? 'RWUAM_BAN_USER_DAY' : 'RWUAM_BAN_USER_HOUR';
|
||||
|
||||
try
|
||||
{
|
||||
session.sendBanMessage(webUserId, code);
|
||||
markActionDone(null, 'housekeeping.live.banned');
|
||||
showSingleBubble(localizeOrPassthrough('housekeeping.live.banned'), NotificationBubbleType.INFO);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch(error)
|
||||
{
|
||||
markActionDone(String((error as Error)?.message ?? error), null);
|
||||
|
||||
return false;
|
||||
}
|
||||
}, [ markActionDone, showSingleBubble ]);
|
||||
|
||||
// -- BULK HTTP ACTIONS ------------------------------------------
|
||||
// Loop with Promise.allSettled so a single failure doesn't abort
|
||||
// the rest of the batch. Aggregated success/failure counts land
|
||||
// in the status banner; per-user errors fall through to the audit
|
||||
// log on the server side.
|
||||
const runBulk = useCallback(async (
|
||||
userIds: ReadonlyArray<number>,
|
||||
single: (id: number) => Promise<IHousekeepingActionResult | null>,
|
||||
actionLabel: string
|
||||
): Promise<{ ok: number; failed: number }> =>
|
||||
{
|
||||
if(userIds.length === 0) return { ok: 0, failed: 0 };
|
||||
|
||||
markActionPending();
|
||||
|
||||
const startedAt = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
const settled = await Promise.allSettled(userIds.map(id => single(id)));
|
||||
let ok = 0;
|
||||
let failed = 0;
|
||||
|
||||
for(const outcome of settled)
|
||||
{
|
||||
if(outcome.status === 'fulfilled' && outcome.value && outcome.value.ok !== false) ok++;
|
||||
else failed++;
|
||||
}
|
||||
|
||||
// One metric sample per bulk run rather than per user — the
|
||||
// bulk timing is what the operator cares about. Bucket suffix
|
||||
// `:bulk` keeps the metric separate from the matching single
|
||||
// action in the telemetry panel.
|
||||
const endedAt = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||||
|
||||
recordActionMetric(`${ actionLabel }:bulk`, endedAt - startedAt, failed > 0);
|
||||
|
||||
const summaryKey = failed === 0 ? 'housekeeping.bulk.success' : 'housekeeping.bulk.partial';
|
||||
|
||||
markActionDone(failed > 0 && ok === 0 ? 'housekeeping.bulk.failed' : null, failed === 0 ? summaryKey : null);
|
||||
showSingleBubble(`${ localizeOrPassthrough('housekeeping.bulk.done') } — ${ ok }/${ userIds.length }`, NotificationBubbleType.INFO);
|
||||
|
||||
return { ok, failed };
|
||||
}, [ markActionPending, markActionDone, showSingleBubble, recordActionMetric ]);
|
||||
|
||||
const banUsersBulk = useCallback((userIds: ReadonlyArray<number>, reason: string, hours: number) =>
|
||||
runBulk(userIds, id => HousekeepingApi.banUser(id, reason, hours), 'banUser'),
|
||||
[ runBulk ]);
|
||||
|
||||
const kickUsersBulk = useCallback((userIds: ReadonlyArray<number>, reason: string) =>
|
||||
runBulk(userIds, id => HousekeepingApi.kickUser(id, reason), 'kickUser'),
|
||||
[ runBulk ]);
|
||||
|
||||
const muteUsersBulk = useCallback((userIds: ReadonlyArray<number>, reason: string, minutes: number) =>
|
||||
runBulk(userIds, id => HousekeepingApi.muteUser(id, reason, minutes), 'muteUser'),
|
||||
[ runBulk ]);
|
||||
|
||||
const muteInCurrentRoom = useCallback((webUserId: number, minutes: number) =>
|
||||
{
|
||||
const session = GetRoomSession();
|
||||
|
||||
if(!session)
|
||||
{
|
||||
markActionDone('housekeeping.live.no_room', null);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
session.sendMuteMessage(webUserId, minutes);
|
||||
markActionDone(null, 'housekeeping.live.muted');
|
||||
showSingleBubble(localizeOrPassthrough('housekeeping.live.muted'), NotificationBubbleType.INFO);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch(error)
|
||||
{
|
||||
markActionDone(String((error as Error)?.message ?? error), null);
|
||||
|
||||
return false;
|
||||
}
|
||||
}, [ markActionDone, showSingleBubble ]);
|
||||
|
||||
return {
|
||||
banUser,
|
||||
unbanUser,
|
||||
muteUser,
|
||||
kickUser,
|
||||
forceDisconnectUser,
|
||||
resetUserPassword,
|
||||
setUserRank,
|
||||
tradeLockUser,
|
||||
openRoom,
|
||||
closeRoom,
|
||||
muteRoom,
|
||||
kickAllFromRoom,
|
||||
transferRoomOwnership,
|
||||
deleteRoom,
|
||||
giveCredits,
|
||||
giveDuckets,
|
||||
giveDiamonds,
|
||||
grantItem,
|
||||
setHcSubscription,
|
||||
sendHotelAlert,
|
||||
kickFromCurrentRoom,
|
||||
banFromCurrentRoom,
|
||||
muteInCurrentRoom,
|
||||
banUsersBulk,
|
||||
kickUsersBulk,
|
||||
muteUsersBulk
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Pure aggregation logic of the bulk path — modelled after the
|
||||
* `runBulk` reducer inside `useHousekeepingActions.ts`. The hook
|
||||
* itself is hard to drive cleanly in jsdom because it pulls
|
||||
* `useBetween`, `useNotification`, and the renderer-SDK mock through
|
||||
* a long transitive import chain. The actual aggregation is the
|
||||
* interesting bit and isolating it keeps the test fast + readable.
|
||||
*
|
||||
* Mirror of the production logic — if the hook's reducer changes,
|
||||
* this test should change with it.
|
||||
*/
|
||||
|
||||
type Outcome = PromiseSettledResult<{ ok: boolean } | null>;
|
||||
|
||||
const aggregate = (settled: Outcome[]): { ok: number; failed: number } =>
|
||||
{
|
||||
let ok = 0;
|
||||
let failed = 0;
|
||||
|
||||
for(const outcome of settled)
|
||||
{
|
||||
if(outcome.status === 'fulfilled' && outcome.value && outcome.value.ok !== false) ok++;
|
||||
else failed++;
|
||||
}
|
||||
|
||||
return { ok, failed };
|
||||
};
|
||||
|
||||
const ok = (): Outcome => ({ status: 'fulfilled', value: { ok: true } });
|
||||
const fail = (): Outcome => ({ status: 'fulfilled', value: { ok: false } });
|
||||
const rejected = (): Outcome => ({ status: 'rejected', reason: new Error('net') });
|
||||
const nullValue = (): Outcome => ({ status: 'fulfilled', value: null });
|
||||
|
||||
describe('bulk aggregation (mirrors useHousekeepingActions.runBulk)', () =>
|
||||
{
|
||||
it('counts only `ok: true` results as success', () =>
|
||||
{
|
||||
expect(aggregate([ ok(), ok(), ok() ])).toEqual({ ok: 3, failed: 0 });
|
||||
});
|
||||
|
||||
it('counts `ok: false` results as failures', () =>
|
||||
{
|
||||
expect(aggregate([ ok(), fail(), ok() ])).toEqual({ ok: 2, failed: 1 });
|
||||
});
|
||||
|
||||
it('counts rejected promises as failures (not crashes)', () =>
|
||||
{
|
||||
expect(aggregate([ ok(), rejected(), ok() ])).toEqual({ ok: 2, failed: 1 });
|
||||
});
|
||||
|
||||
it('counts null-result responses as failures (server returned nothing meaningful)', () =>
|
||||
{
|
||||
expect(aggregate([ ok(), nullValue(), ok() ])).toEqual({ ok: 2, failed: 1 });
|
||||
});
|
||||
|
||||
it('returns 0/0 for an empty input — no division-by-zero', () =>
|
||||
{
|
||||
expect(aggregate([])).toEqual({ ok: 0, failed: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useCallback } from 'react';
|
||||
import { LocalizeText } from '../../api';
|
||||
import { useNotification } from '../notification';
|
||||
|
||||
/**
|
||||
* Themed confirmation wrapper around `useNotification().showConfirm`.
|
||||
*
|
||||
* Destructive HK actions (delete room, kick-all, bulk ban) used to
|
||||
* call `window.confirm` directly — that's a system-modal that breaks
|
||||
* out of the client visually and doesn't honor the LocalizeText
|
||||
* dictionary. `useHousekeepingConfirm` swaps in the in-client
|
||||
* NotificationConfirm modal, with the HK button labels and a
|
||||
* sensible default title.
|
||||
*
|
||||
* Returns a single function `confirm(message, onConfirm)` to keep
|
||||
* the call sites tight. Pass an `options.confirmText` override when
|
||||
* the action needs a custom label (e.g. "Delete forever" instead of
|
||||
* the generic confirm).
|
||||
*/
|
||||
export const useHousekeepingConfirm = () =>
|
||||
{
|
||||
const { showConfirm } = useNotification();
|
||||
|
||||
return useCallback((message: string, onConfirm: () => void, options: { confirmText?: string; cancelText?: string; title?: string } = {}) =>
|
||||
{
|
||||
const confirmText = options.confirmText ?? LocalizeText('housekeeping.confirm.proceed');
|
||||
const cancelText = options.cancelText ?? LocalizeText('housekeeping.confirm.cancel');
|
||||
const title = options.title ?? LocalizeText('housekeeping.confirm.title');
|
||||
|
||||
showConfirm(message, onConfirm, () => {}, confirmText, cancelText, title);
|
||||
}, [ showConfirm ]);
|
||||
};
|
||||
@@ -0,0 +1,529 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useBetween } from 'use-between';
|
||||
import {
|
||||
emptySample, GetConfigurationValue, HousekeepingApi, HousekeepingTabId, IHousekeepingActionLogEntry,
|
||||
IHousekeepingDashboard, IHousekeepingRoom, IHousekeepingRoomSummary, IHousekeepingUser,
|
||||
IHousekeepingUserSummary, loadRecentLookups, persistRecentLookups, pushRecentLookup, recordSample,
|
||||
RecentLookupEntry
|
||||
} from '../../api';
|
||||
import { useLocalStorage } from '../useLocalStorage';
|
||||
|
||||
const AUDIT_POLL_DEFAULT_MS = 30000;
|
||||
const AUDIT_POLL_MIN_MS = 5000;
|
||||
|
||||
const ACTION_LOG_LIMIT = 100;
|
||||
const AUTOCOMPLETE_DEBOUNCE_MS = 250;
|
||||
const AUTOCOMPLETE_MIN_PREFIX = 2;
|
||||
|
||||
const useHousekeepingStoreInner = () =>
|
||||
{
|
||||
const [ isVisible, setIsVisible ] = useState(false);
|
||||
// Last-tab is persisted per user (useLocalStorage auto-scopes the key
|
||||
// by userId from the URL) so reopening the panel lands on the same
|
||||
// tab the operator was using. HousekeepingView's auto-redirect
|
||||
// effect handles the case where the persisted tab isn't available
|
||||
// in the current `housekeeping.mode` (light bounces DASHBOARD → USERS).
|
||||
const [ activeTab, setActiveTab ] = useLocalStorage<HousekeepingTabId>('nitro.housekeeping.last_tab', HousekeepingTabId.DASHBOARD);
|
||||
const [ selectedUser, setSelectedUser ] = useState<IHousekeepingUser | null>(null);
|
||||
const [ selectedRoom, setSelectedRoom ] = useState<IHousekeepingRoom | null>(null);
|
||||
const [ actionLog, setActionLog ] = useState<IHousekeepingActionLogEntry[]>([]);
|
||||
const [ isUserLoading, setIsUserLoading ] = useState(false);
|
||||
const [ isRoomLoading, setIsRoomLoading ] = useState(false);
|
||||
const [ isActionPending, setIsActionPending ] = useState(false);
|
||||
const [ lastError, setLastError ] = useState<string | null>(null);
|
||||
const [ lastSuccess, setLastSuccess ] = useState<string | null>(null);
|
||||
const [ dashboard, setDashboard ] = useState<IHousekeepingDashboard | null>(null);
|
||||
const [ isDashboardLoading, setIsDashboardLoading ] = useState(false);
|
||||
const [ userSuggestions, setUserSuggestions ] = useState<IHousekeepingUserSummary[]>([]);
|
||||
const [ roomSuggestions, setRoomSuggestions ] = useState<IHousekeepingRoomSummary[]>([]);
|
||||
const [ recentLookups, setRecentLookups ] = useState<RecentLookupEntry[]>(() => loadRecentLookups());
|
||||
// Multi-select state for the Users tab. We use an array of ids
|
||||
// rather than a Set because Zustand-style `useBetween` re-renders
|
||||
// on referential equality — mutating a Set in place would miss
|
||||
// updates. Capped via the dedupe in toggleUserSelection.
|
||||
const [ selectedUserIds, setSelectedUserIds ] = useState<number[]>([]);
|
||||
// Password-reveal state — when reset-password succeeds, the emulator
|
||||
// returns the freshly-generated plaintext password in the action
|
||||
// result. We hold it in a dedicated state slot (not the success
|
||||
// banner) so it doesn't auto-dismiss and the operator can read /
|
||||
// copy it. Cleared manually via `clearPasswordReveal()` — sensitive
|
||||
// data, treat it like a one-shot secret.
|
||||
const [ passwordReveal, setPasswordReveal ] = useState<{ userId: number; username: string; password: string } | null>(null);
|
||||
const revealPassword = useCallback((userId: number, username: string, password: string) =>
|
||||
{
|
||||
if(!password) return;
|
||||
setPasswordReveal({ userId, username, password });
|
||||
}, []);
|
||||
const clearPasswordReveal = useCallback(() => setPasswordReveal(null), []);
|
||||
// Per-action latency / count / error metrics. Map → triggers a
|
||||
// new reference on every update so subscribers re-render.
|
||||
// Capped per-action via `recordSample`'s sliding window so the
|
||||
// memory footprint is bounded regardless of session length.
|
||||
const [ metricsByAction, setMetricsByAction ] = useState<Map<string, import('../../api').MetricSample>>(() => new Map());
|
||||
// Track the most-recent fetch per slot so out-of-order responses don't
|
||||
// flash stale data into the panel.
|
||||
const userFetchTokenRef = useRef(0);
|
||||
const roomFetchTokenRef = useRef(0);
|
||||
const userSuggestTokenRef = useRef(0);
|
||||
const roomSuggestTokenRef = useRef(0);
|
||||
const userSuggestTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const roomSuggestTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const userSuggestAbortRef = useRef<AbortController | null>(null);
|
||||
const roomSuggestAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => () =>
|
||||
{
|
||||
if(userSuggestTimerRef.current) clearTimeout(userSuggestTimerRef.current);
|
||||
if(roomSuggestTimerRef.current) clearTimeout(roomSuggestTimerRef.current);
|
||||
userSuggestAbortRef.current?.abort();
|
||||
roomSuggestAbortRef.current?.abort();
|
||||
}, []);
|
||||
|
||||
const fetchDashboard = useCallback(async (signal?: AbortSignal) =>
|
||||
{
|
||||
setIsDashboardLoading(true);
|
||||
|
||||
try
|
||||
{
|
||||
const data = await HousekeepingApi.getDashboard(signal);
|
||||
|
||||
if(signal?.aborted) return;
|
||||
|
||||
setDashboard(data ?? null);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if(!signal?.aborted) setDashboard(null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(!signal?.aborted) setIsDashboardLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchAuditLog = useCallback(async (signal?: AbortSignal) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const entries = await HousekeepingApi.listActionLog(ACTION_LOG_LIMIT, signal);
|
||||
|
||||
if(signal?.aborted) return;
|
||||
|
||||
setActionLog(Array.isArray(entries) ? entries : []);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if(!signal?.aborted) setActionLog([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!isVisible) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
// Refresh dashboard + audit log every time the panel opens so
|
||||
// a HK who's been away doesn't see a stale snapshot. We
|
||||
// INTENTIONALLY call the async fetchers from inside the effect
|
||||
// — they're external-system calls (HTTP + signal-aware abort)
|
||||
// not derived state, which is exactly the case
|
||||
// set-state-in-effect's docs carve out. The setState inside
|
||||
// the fetchers lands in a microtask after the await, not in
|
||||
// this synchronous effect body.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
fetchDashboard(controller.signal);
|
||||
fetchAuditLog(controller.signal);
|
||||
|
||||
return () => controller.abort();
|
||||
}, [ isVisible, fetchDashboard, fetchAuditLog ]);
|
||||
|
||||
// Live audit polling. While the panel is open AND the document
|
||||
// is visible, repoll the audit endpoint on a configurable
|
||||
// interval (`housekeeping.audit.poll_interval_ms`, default 30s,
|
||||
// floor 5s). Set to 0 to disable. Pauses entirely on tab-hidden
|
||||
// so a stack of background sessions doesn't hammer the admin
|
||||
// endpoint.
|
||||
//
|
||||
// This is intentionally HTTP polling rather than `useMessageEvent`
|
||||
// — the latter would require a new HousekeepingAuditPushEvent
|
||||
// composer/parser in the renderer SDK, which is out of scope for
|
||||
// a client-only change. Drop-in upgrade path documented in
|
||||
// CLAUDE.md when the wire protocol catches up.
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!isVisible) return;
|
||||
|
||||
const configured = GetConfigurationValue<number>('housekeeping.audit.poll_interval_ms', AUDIT_POLL_DEFAULT_MS);
|
||||
const intervalMs = typeof configured === 'number' && configured >= AUDIT_POLL_MIN_MS ? configured : (configured === 0 ? 0 : AUDIT_POLL_DEFAULT_MS);
|
||||
|
||||
if(intervalMs === 0) return; // operator opted out via config
|
||||
|
||||
let handle: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const start = () =>
|
||||
{
|
||||
if(handle) return;
|
||||
|
||||
handle = setInterval(() =>
|
||||
{
|
||||
if(typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
fetchAuditLog();
|
||||
}, intervalMs);
|
||||
};
|
||||
|
||||
const stop = () =>
|
||||
{
|
||||
if(handle) clearInterval(handle);
|
||||
handle = null;
|
||||
};
|
||||
|
||||
const onVisibility = () =>
|
||||
{
|
||||
if(document.visibilityState === 'visible') start();
|
||||
else stop();
|
||||
};
|
||||
|
||||
start();
|
||||
|
||||
if(typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility);
|
||||
|
||||
return () =>
|
||||
{
|
||||
stop();
|
||||
|
||||
if(typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibility);
|
||||
};
|
||||
}, [ isVisible, fetchAuditLog ]);
|
||||
|
||||
const clearStatus = useCallback(() =>
|
||||
{
|
||||
setLastError(null);
|
||||
setLastSuccess(null);
|
||||
}, []);
|
||||
|
||||
const rememberLookup = useCallback((entry: RecentLookupEntry) =>
|
||||
{
|
||||
setRecentLookups(prev =>
|
||||
{
|
||||
const next = pushRecentLookup(prev, entry);
|
||||
|
||||
persistRecentLookups(next);
|
||||
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const lookupUserByName = useCallback(async (username: string) =>
|
||||
{
|
||||
const token = ++userFetchTokenRef.current;
|
||||
|
||||
setIsUserLoading(true);
|
||||
clearStatus();
|
||||
|
||||
try
|
||||
{
|
||||
const result = await HousekeepingApi.findUserByName(username);
|
||||
|
||||
if(userFetchTokenRef.current !== token) return null;
|
||||
|
||||
setSelectedUser(result ?? null);
|
||||
|
||||
if(result) rememberLookup({ kind: 'user', id: result.id, label: result.username, at: Date.now() });
|
||||
else setLastError('housekeeping.user.not_found');
|
||||
|
||||
return result;
|
||||
}
|
||||
catch(error)
|
||||
{
|
||||
if(userFetchTokenRef.current === token) setLastError(String((error as Error)?.message ?? error));
|
||||
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(userFetchTokenRef.current === token) setIsUserLoading(false);
|
||||
}
|
||||
}, [ clearStatus, rememberLookup ]);
|
||||
|
||||
/**
|
||||
* Optimistic seed used when the operator clicks an avatar in-room —
|
||||
* the renderer already knows id / name / figure, so we surface those
|
||||
* immediately while the findUserById packet enriches the rest
|
||||
* (credits, email, ipLast, …) in the background. If the packet
|
||||
* times out we keep the hint so actions still work on the userId.
|
||||
*/
|
||||
const seedUserFromAvatar = useCallback((userId: number, username: string, figure: string) =>
|
||||
{
|
||||
if(!Number.isFinite(userId) || userId <= 0) return;
|
||||
|
||||
const hint: IHousekeepingUser = {
|
||||
id: userId,
|
||||
username: username || '',
|
||||
motto: '',
|
||||
figure: figure || '',
|
||||
rank: 0,
|
||||
rankName: '',
|
||||
online: true,
|
||||
lastOnlineAt: null,
|
||||
creditsBalance: 0,
|
||||
ducketsBalance: 0,
|
||||
diamondsBalance: 0,
|
||||
email: '',
|
||||
ipLast: '',
|
||||
isBanned: false,
|
||||
isMuted: false,
|
||||
isTradeLocked: false
|
||||
};
|
||||
|
||||
setSelectedUser(hint);
|
||||
rememberLookup({ kind: 'user', id: userId, label: hint.username, at: Date.now() });
|
||||
}, [ rememberLookup ]);
|
||||
|
||||
const lookupUserById = useCallback(async (userId: number) =>
|
||||
{
|
||||
const token = ++userFetchTokenRef.current;
|
||||
|
||||
setIsUserLoading(true);
|
||||
clearStatus();
|
||||
|
||||
try
|
||||
{
|
||||
const result = await HousekeepingApi.findUserById(userId);
|
||||
|
||||
if(userFetchTokenRef.current !== token) return null;
|
||||
|
||||
// Don't blank the optimistic seed when the lookup times out
|
||||
// or returns null — operators clicking in-room want the
|
||||
// hint to stay visible so the action buttons remain usable.
|
||||
if(result)
|
||||
{
|
||||
setSelectedUser(result);
|
||||
rememberLookup({ kind: 'user', id: result.id, label: result.username, at: Date.now() });
|
||||
}
|
||||
else
|
||||
{
|
||||
setLastError('housekeeping.user.not_found');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch(error)
|
||||
{
|
||||
if(userFetchTokenRef.current === token) setLastError(String((error as Error)?.message ?? error));
|
||||
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(userFetchTokenRef.current === token) setIsUserLoading(false);
|
||||
}
|
||||
}, [ clearStatus, rememberLookup ]);
|
||||
|
||||
const lookupRoomById = useCallback(async (roomId: number) =>
|
||||
{
|
||||
const token = ++roomFetchTokenRef.current;
|
||||
|
||||
setIsRoomLoading(true);
|
||||
clearStatus();
|
||||
|
||||
try
|
||||
{
|
||||
const result = await HousekeepingApi.findRoomById(roomId);
|
||||
|
||||
if(roomFetchTokenRef.current !== token) return null;
|
||||
|
||||
setSelectedRoom(result ?? null);
|
||||
|
||||
if(result) rememberLookup({ kind: 'room', id: result.id, label: result.name, at: Date.now() });
|
||||
else setLastError('housekeeping.room.not_found');
|
||||
|
||||
return result;
|
||||
}
|
||||
catch(error)
|
||||
{
|
||||
if(roomFetchTokenRef.current === token) setLastError(String((error as Error)?.message ?? error));
|
||||
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if(roomFetchTokenRef.current === token) setIsRoomLoading(false);
|
||||
}
|
||||
}, [ clearStatus, rememberLookup ]);
|
||||
|
||||
const requestUserSuggestions = useCallback((prefix: string) =>
|
||||
{
|
||||
if(userSuggestTimerRef.current) clearTimeout(userSuggestTimerRef.current);
|
||||
|
||||
const trimmed = (prefix || '').trim();
|
||||
|
||||
if(trimmed.length < AUTOCOMPLETE_MIN_PREFIX)
|
||||
{
|
||||
userSuggestAbortRef.current?.abort();
|
||||
setUserSuggestions([]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
userSuggestTimerRef.current = setTimeout(async () =>
|
||||
{
|
||||
userSuggestAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
|
||||
userSuggestAbortRef.current = controller;
|
||||
|
||||
const token = ++userSuggestTokenRef.current;
|
||||
|
||||
try
|
||||
{
|
||||
const list = await HousekeepingApi.searchUsers(trimmed, controller.signal);
|
||||
|
||||
if(controller.signal.aborted || userSuggestTokenRef.current !== token) return;
|
||||
|
||||
setUserSuggestions(Array.isArray(list) ? list : []);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if(!controller.signal.aborted) setUserSuggestions([]);
|
||||
}
|
||||
}, AUTOCOMPLETE_DEBOUNCE_MS);
|
||||
}, []);
|
||||
|
||||
const requestRoomSuggestions = useCallback((prefix: string) =>
|
||||
{
|
||||
if(roomSuggestTimerRef.current) clearTimeout(roomSuggestTimerRef.current);
|
||||
|
||||
const trimmed = (prefix || '').trim();
|
||||
|
||||
if(trimmed.length < AUTOCOMPLETE_MIN_PREFIX)
|
||||
{
|
||||
roomSuggestAbortRef.current?.abort();
|
||||
setRoomSuggestions([]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
roomSuggestTimerRef.current = setTimeout(async () =>
|
||||
{
|
||||
roomSuggestAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
|
||||
roomSuggestAbortRef.current = controller;
|
||||
|
||||
const token = ++roomSuggestTokenRef.current;
|
||||
|
||||
try
|
||||
{
|
||||
const list = await HousekeepingApi.searchRooms(trimmed, controller.signal);
|
||||
|
||||
if(controller.signal.aborted || roomSuggestTokenRef.current !== token) return;
|
||||
|
||||
setRoomSuggestions(Array.isArray(list) ? list : []);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if(!controller.signal.aborted) setRoomSuggestions([]);
|
||||
}
|
||||
}, AUTOCOMPLETE_DEBOUNCE_MS);
|
||||
}, []);
|
||||
|
||||
const markActionPending = useCallback(() => setIsActionPending(true), []);
|
||||
const markActionDone = useCallback((errorKey: string | null, successKey: string | null) =>
|
||||
{
|
||||
setIsActionPending(false);
|
||||
setLastError(errorKey);
|
||||
setLastSuccess(successKey);
|
||||
}, []);
|
||||
|
||||
const closePanel = useCallback(() =>
|
||||
{
|
||||
setIsVisible(false);
|
||||
clearStatus();
|
||||
}, [ clearStatus ]);
|
||||
|
||||
const togglePanel = useCallback(() => setIsVisible(value => !value), []);
|
||||
|
||||
const toggleUserSelection = useCallback((userId: number) =>
|
||||
{
|
||||
setSelectedUserIds(prev =>
|
||||
{
|
||||
if(prev.includes(userId)) return prev.filter(id => id !== userId);
|
||||
|
||||
return [ ...prev, userId ];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearUserSelection = useCallback(() => setSelectedUserIds([]), []);
|
||||
|
||||
const recordActionMetric = useCallback((action: string, latencyMs: number, isError: boolean) =>
|
||||
{
|
||||
setMetricsByAction(prev =>
|
||||
{
|
||||
const next = new Map(prev);
|
||||
const current = next.get(action) ?? emptySample();
|
||||
|
||||
next.set(action, recordSample(current, latencyMs, isError));
|
||||
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resetActionMetrics = useCallback(() => setMetricsByAction(new Map()), []);
|
||||
|
||||
return {
|
||||
isVisible,
|
||||
setIsVisible,
|
||||
togglePanel,
|
||||
closePanel,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
selectedUser,
|
||||
setSelectedUser,
|
||||
selectedRoom,
|
||||
setSelectedRoom,
|
||||
actionLog,
|
||||
setActionLog,
|
||||
isUserLoading,
|
||||
isRoomLoading,
|
||||
isActionPending,
|
||||
markActionPending,
|
||||
markActionDone,
|
||||
lastError,
|
||||
lastSuccess,
|
||||
clearStatus,
|
||||
lookupUserByName,
|
||||
lookupUserById,
|
||||
seedUserFromAvatar,
|
||||
lookupRoomById,
|
||||
dashboard,
|
||||
isDashboardLoading,
|
||||
refreshDashboard: fetchDashboard,
|
||||
refreshAuditLog: fetchAuditLog,
|
||||
userSuggestions,
|
||||
roomSuggestions,
|
||||
requestUserSuggestions,
|
||||
requestRoomSuggestions,
|
||||
recentLookups,
|
||||
selectedUserIds,
|
||||
toggleUserSelection,
|
||||
clearUserSelection,
|
||||
passwordReveal,
|
||||
revealPassword,
|
||||
clearPasswordReveal,
|
||||
metricsByAction,
|
||||
recordActionMetric,
|
||||
resetActionMetrics
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Singleton store backing the housekeeping panel. State, lookups,
|
||||
* dashboard/audit fetches, autocomplete + recent-lookups
|
||||
* persistence all live in one `useBetween` closure so every tab
|
||||
* shares the same view of the world — and reopening the panel
|
||||
* doesn't re-fetch state that's already in memory.
|
||||
*/
|
||||
export const useHousekeepingStore = () => useBetween(useHousekeepingStoreInner);
|
||||
@@ -8,6 +8,7 @@ export * from './friends';
|
||||
export * from './game-center';
|
||||
export * from './groups';
|
||||
export * from './help';
|
||||
export * from './housekeeping';
|
||||
export * from './inventory';
|
||||
export * from './mod-tools';
|
||||
export * from './navigator';
|
||||
|
||||
Reference in New Issue
Block a user