import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { ListChecks, Loader2, Plus, RotateCw, X } 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 { cn } from "@/lib/utils"; import { CollapsibleSection, FeedbackLine, MC_NAME, PagerFooter, PlayerField, rconReply, SearchBox, usePagedNames, type Feedback, } from "./shared"; /** WhitelistSection manages the server's join whitelist: add a name, remove any * entry, and read the current list. Built to stay usable at a few hundred names — * a live count, a filter and paging (via usePagedNames) once the list is long * enough to need them, and a two-step remove so a name never vanishes on one tap. */ export function WhitelistSection({ name, defaultOpen = false }: { name: string; defaultOpen?: boolean }) { const { t } = useTranslation("servers"); const { data, error, loading, reload } = useAsync( () => api.accessWhitelistList(name), [name], ); const [value, setValue] = useState(""); const [touched, setTouched] = useState(false); const [adding, setAdding] = useState(false); const [removing, setRemoving] = useState(null); const [confirming, setConfirming] = useState(null); const [fb, setFb] = useState(null); const player = value.trim(); const valid = MC_NAME.test(player); const invalid = touched && player.length > 0 && !valid; const players = useMemo(() => data?.players ?? [], [data]); // The server always echoes the raw RCON text. Parsed `players` drives the list / // empty state; `raw` is kept as an always-available, low-key disclosure — it is // ground truth when the vanilla-only parse can't tokenize a plugin or localized // whitelist (the names are in `output` even when `players` came back empty). const raw = data?.output?.trim() ?? ""; const { query, onQuery, q, shown, showSearch, pageItems, pageCount, clampedPage, needFooter, setPage } = usePagedNames(players); const add = useCallback(async () => { if (!valid || adding) { setTouched(true); return; } setFb(null); setAdding(true); try { const res = await api.accessWhitelist(name, "add", player); setFb({ kind: "ok", msg: rconReply(res.output, t("access_whitelist_added", { player })), }); setValue(""); setTouched(false); reload(); } catch (e) { setFb({ kind: "err", msg: humanizeError(e) }); } finally { setAdding(false); } }, [name, player, valid, adding, reload, t]); const remove = useCallback( async (p: string) => { setFb(null); setRemoving(p); try { const res = await api.accessWhitelist(name, "remove", p); setFb({ kind: "ok", msg: rconReply(res.output, t("access_whitelist_removed", { player: p })), }); reload(); } catch (e) { setFb({ kind: "err", msg: humanizeError(e) }); } finally { setRemoving(null); setConfirming(null); } }, [name, reload, t], ); return ( } title={t("access_whitelist_title")} count={!loading && !error ? players.length : undefined} defaultOpen={defaultOpen} actions={ } >

{t("access_whitelist_desc")}

{/* Add row — the primary action, kept at the top so it is always in reach. */}
{invalid &&

{t("access_player_invalid")}

}
{loading && !data ? (
{t("log_connecting")}
) : error ? (

{t("access_whitelist_load_error")}

) : players.length === 0 ? (

{t("access_whitelist_empty")}

{t("access_whitelist_empty_hint")}

) : (
{showSearch && }
{shown.length === 0 ? (
{t("access_search_no_match", { query: query.trim() })}
) : ( pageItems.map((p) => (
{p} {/* Removal asks once before it fires: one click arms the row (X → 取消 / 移除), a second confirms. Recoverable, but a name gone on a single stray tap is exactly the surprise to avoid. */} {confirming === p ? ( remove(p)} onCancel={() => setConfirming(null)} confirmLabel={t("access_remove")} cancelLabel={t("access_cancel")} size="sm" className="flex shrink-0 items-center gap-1" /> ) : ( )}
)) )}
{needFooter && ( )}
)} {/* The server's verbatim reply, kept as an opt-in disclosure — the escape hatch when the vanilla-only parse can't tokenize a plugin or localized whitelist. Collapsed by default so it never clutters the common case. */} {!loading && !error && raw && (
{t("access_whitelist_raw")}
              {raw}
            
)}
); }