import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Ban, Loader2, LogOut, RotateCw, Users } from "lucide-react"; import { Button } from "@/components/ui/button"; import { InlineConfirm } from "@/components/InlineConfirm"; import { api, humanizeError } from "@/lib/api"; import { useAsync } from "@/lib/hooks"; import { CollapsibleSection, FeedbackLine, PagerFooter, rconReply, SearchBox, usePagedNames, type Feedback, } from "./shared"; type RowAction = "kick" | "ban"; /** OnlineSection is the live roster: who is on the server right now, each with a * one-click kick or ban (both two-step confirmed, since both are disruptive). It * is the ONLY place the panel learns WHO is online — status carries the count * alone — so it reads the RCON "list" reply on demand. Refresh is MANUAL (a button * + a last-updated stamp), never a timer: auto-polling would fire an RCON command * per viewer forever, and the roster does not move fast enough to justify it. */ export function OnlineSection({ name, defaultOpen = true }: { name: string; defaultOpen?: boolean }) { const { t } = useTranslation("servers"); const { data, error, loading, reload } = useAsync(() => api.accessPlayers(name), [name]); const [updatedAt, setUpdatedAt] = useState(null); const [confirming, setConfirming] = useState<{ player: string; action: RowAction } | null>(null); const [pending, setPending] = useState<{ player: string; action: RowAction } | null>(null); const [fb, setFb] = useState(null); // Stamp the last successful read so the roster's freshness is always visible — // the honest counterpart to not auto-refreshing. useEffect(() => { if (data) setUpdatedAt(new Date()); }, [data]); const online = data?.online ?? 0; const max = data?.max ?? 0; const players = useMemo(() => data?.players ?? [], [data]); const raw = data?.output?.trim() ?? ""; // The tally says someone is on but no names parsed (a non-vanilla "list" format): // report the count honestly and point at the raw reply rather than a false empty. const namesUnavailable = online > 0 && players.length === 0; const { query, onQuery, q, shown, showSearch, pageItems, pageCount, clampedPage, needFooter, setPage } = usePagedNames(players); const run = useCallback( async (playerName: string, action: RowAction) => { setFb(null); setPending({ player: playerName, action }); try { const res = action === "kick" ? await api.accessKick(name, playerName) : await api.accessBan(name, "ban", playerName); setFb({ kind: "ok", msg: rconReply( res.output, t(action === "kick" ? "access_kicked" : "access_banned", { player: playerName }), ), }); reload(); // the player just left — refresh so the roster reflects it } catch (e) { setFb({ kind: "err", msg: humanizeError(e) }); } finally { setPending(null); setConfirming(null); } }, [name, reload, t], ); return ( } title={t("access_online_title")} count={!loading && !error ? (max > 0 ? `${online} / ${max}` : online) : undefined} defaultOpen={defaultOpen} actions={ } >
{/* Freshness stamp — the honest counterpart to manual refresh — sits with the section's description now that the card header is just the collapsed index. */}

{t("access_online_desc")}

{updatedAt && !loading && ( {t("access_updated_at", { time: updatedAt.toLocaleTimeString() })} )}
{loading && !data ? (
{t("log_connecting")}
) : error ? (

{t("access_online_load_error")}

) : players.length === 0 ? (
{namesUnavailable ? ( <>

{t("access_online_names_unavailable", { count: online })}

{t("access_online_names_unavailable_hint")}

) : (

{t("access_online_empty")}

)}
) : (
{showSearch && }
{shown.length === 0 ? (
{t("access_search_no_match", { query: query.trim() })}
) : ( pageItems.map((p) => { // Narrow here so confirming.action is non-null inside the branch. const c = confirming && confirming.player === p ? confirming : null; return (
{p} {/* Both kick and ban are disruptive, so each arms a one-step inline confirm before it fires (no native confirm()). */} {c ? ( run(p, c.action)} onCancel={() => setConfirming(null)} confirmLabel={c.action === "kick" ? t("access_kick_btn") : t("access_ban_btn")} cancelLabel={t("access_cancel")} size="sm" className="flex shrink-0 items-center gap-1" /> ) : (
)}
); }) )}
{needFooter && ( )}
)} {/* Raw RCON reply — the ground truth for names when the parse can't tokenize a non-vanilla "list" format. Collapsed by default. */} {!loading && !error && raw && (
{t("access_online_raw")}
              {raw}
            
)}
); }