feat(panel): player management
This commit is contained in:
19 files changed
+2282
-7
No files matched your search
@@ -0,0 +1,290 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Ban, Loader2, RotateCw, Undo2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { useAsync } from "@/lib/hooks";
|
||||
import {
|
||||
CollapsibleSection,
|
||||
FeedbackLine,
|
||||
MC_NAME,
|
||||
PagerFooter,
|
||||
PlayerField,
|
||||
SearchBox,
|
||||
usePagedNames,
|
||||
type Feedback,
|
||||
} from "./shared";
|
||||
|
||||
/** BansSection is the ban roster: view who is blocked from the server, pardon any
|
||||
* of them in one tap, and ban an arbitrary player by ID (the one player action
|
||||
* whose target is NOT already on screen). Built to stay usable at a few hundred
|
||||
* names — a live count, filter + paging (via usePagedNames), and a manual refresh.
|
||||
*
|
||||
* Two deliberately DIFFERENT confirmations, weighted by consequence:
|
||||
* - Banning a typed-in name is the most surprising/destructive action here (the
|
||||
* target isn't in front of you), so it arms a FULL-SENTENCE confirm that names
|
||||
* the consequence — and Enter only ARMS it, never fires the ban.
|
||||
* - Pardoning is recoverable (re-ban is one tap) but still security-relevant (it
|
||||
* lets someone back in), so it gets a lighter one-step inline confirm per row. */
|
||||
export function BansSection({ name }: { name: string }) {
|
||||
const { t } = useTranslation("servers");
|
||||
const { data, error, loading, reload } = useAsync(() => api.accessBanList(name), [name]);
|
||||
const [value, setValue] = useState("");
|
||||
const [touched, setTouched] = useState(false);
|
||||
const [armed, setArmed] = useState(false); // ban add-row: confirm shown, not yet fired
|
||||
const [banning, setBanning] = useState(false);
|
||||
const [pardoning, setPardoning] = useState<string | null>(null);
|
||||
const [confirming, setConfirming] = useState<string | null>(null); // pardon row armed
|
||||
const [fb, setFb] = useState<Feedback>(null);
|
||||
|
||||
const player = value.trim();
|
||||
const valid = MC_NAME.test(player);
|
||||
const invalid = touched && player.length > 0 && !valid;
|
||||
|
||||
const players = useMemo(() => data?.players ?? [], [data]);
|
||||
// Raw RCON reply is ground truth: the vanilla-only parse can come back empty on a
|
||||
// plugin or localized "banlist" format while `output` still names the bans, so it
|
||||
// stays available as a low-key disclosure rather than showing a false "no bans".
|
||||
const raw = data?.output?.trim() ?? "";
|
||||
|
||||
const { query, onQuery, q, shown, showSearch, pageItems, pageCount, clampedPage, needFooter, setPage } =
|
||||
usePagedNames(players);
|
||||
|
||||
// Enter and the Ban button only ARM the confirm — the ban never fires without the
|
||||
// deliberate second click on the full-sentence confirmation below.
|
||||
const arm = useCallback(() => {
|
||||
if (!valid) {
|
||||
setTouched(true);
|
||||
return;
|
||||
}
|
||||
setFb(null);
|
||||
setArmed(true);
|
||||
}, [valid]);
|
||||
|
||||
const ban = useCallback(async () => {
|
||||
if (!valid || banning) return;
|
||||
setFb(null);
|
||||
setBanning(true);
|
||||
try {
|
||||
await api.accessBan(name, "ban", player);
|
||||
setFb({ kind: "ok", msg: t("access_banned", { player }) });
|
||||
setValue("");
|
||||
setTouched(false);
|
||||
setArmed(false);
|
||||
reload();
|
||||
} catch (e) {
|
||||
setFb({ kind: "err", msg: humanizeError(e) });
|
||||
} finally {
|
||||
setBanning(false);
|
||||
}
|
||||
}, [name, player, valid, banning, reload, t]);
|
||||
|
||||
const pardon = useCallback(
|
||||
async (p: string) => {
|
||||
setFb(null);
|
||||
setPardoning(p);
|
||||
try {
|
||||
await api.accessBan(name, "pardon", p);
|
||||
setFb({ kind: "ok", msg: t("access_pardoned", { player: p }) });
|
||||
reload();
|
||||
} catch (e) {
|
||||
setFb({ kind: "err", msg: humanizeError(e) });
|
||||
} finally {
|
||||
setPardoning(null);
|
||||
setConfirming(null);
|
||||
}
|
||||
},
|
||||
[name, reload, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<CollapsibleSection
|
||||
icon={<Ban className="h-4 w-4" />}
|
||||
title={t("access_ban_title")}
|
||||
count={!loading && !error ? players.length : undefined}
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground"
|
||||
onClick={reload}
|
||||
disabled={loading}
|
||||
title={t("access_refresh")}
|
||||
aria-label={t("access_refresh")}
|
||||
>
|
||||
<RotateCw className={loading ? "h-4 w-4 animate-spin" : "h-4 w-4"} />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t("access_ban_desc")}</p>
|
||||
|
||||
{/* Ban-by-name — the one action whose target isn't already on screen, so it
|
||||
is the most guarded: the button arms a full-sentence confirm rather than
|
||||
firing, and Enter arms it too (PlayerField.onEnter={arm}). */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1">
|
||||
<PlayerField
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
setValue(v);
|
||||
setArmed(false); // editing the name re-disarms; confirm what you see
|
||||
}}
|
||||
onEnter={arm}
|
||||
invalid={invalid}
|
||||
disabled={banning}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="h-9"
|
||||
onClick={arm}
|
||||
disabled={!valid || armed || banning}
|
||||
>
|
||||
<Ban className="h-4 w-4" />
|
||||
{t("access_ban_btn")}
|
||||
</Button>
|
||||
</div>
|
||||
{invalid && <p className="text-xs text-destructive">{t("access_player_invalid")}</p>}
|
||||
|
||||
{armed && valid && (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2">
|
||||
<p className="min-w-0 flex-1 text-xs text-foreground">
|
||||
{t("access_ban_confirm", { player })}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={() => setArmed(false)}
|
||||
disabled={banning}
|
||||
>
|
||||
{t("access_cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={ban}
|
||||
disabled={banning}
|
||||
>
|
||||
{banning ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
t("access_ban_confirm_yes")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t("log_connecting")}
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-xs text-destructive">{t("access_ban_load_error")}</p>
|
||||
) : players.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">{t("access_ban_empty")}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground/80">{t("access_ban_empty_hint")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{showSearch && <SearchBox value={query} onChange={onQuery} />}
|
||||
|
||||
<ul className="divide-y divide-border rounded-md border border-border">
|
||||
{shown.length === 0 ? (
|
||||
<li className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
{t("access_search_no_match", { query: query.trim() })}
|
||||
</li>
|
||||
) : (
|
||||
pageItems.map((p) => (
|
||||
<li
|
||||
key={p}
|
||||
className="flex items-center justify-between gap-2 px-3 py-2 transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Ban className="h-3.5 w-3.5 shrink-0 text-destructive/70" />
|
||||
<span className="truncate font-mono text-sm">{p}</span>
|
||||
</span>
|
||||
{/* Pardon lets a player back in, so it asks once: one tap arms the
|
||||
row (取消 / 解封), a second confirms. Lighter than the ban-by-name
|
||||
confirm because a mistaken pardon is re-bannable in one tap. */}
|
||||
{confirming === p ? (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<span className="mr-1 hidden text-xs text-muted-foreground sm:inline">
|
||||
{t("access_pardon_q")}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2"
|
||||
onClick={() => setConfirming(null)}
|
||||
disabled={pardoning === p}
|
||||
>
|
||||
{t("access_cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-6 px-2"
|
||||
onClick={() => pardon(p)}
|
||||
disabled={pardoning !== null}
|
||||
>
|
||||
{pardoning === p ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
t("access_pardon_btn")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 px-2"
|
||||
onClick={() => setConfirming(p)}
|
||||
disabled={pardoning !== null}
|
||||
>
|
||||
<Undo2 className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{t("access_pardon_btn")}</span>
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{needFooter && (
|
||||
<PagerFooter
|
||||
q={q}
|
||||
shownCount={shown.length}
|
||||
total={players.length}
|
||||
pageCount={pageCount}
|
||||
clampedPage={clampedPage}
|
||||
onPage={setPage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Verbatim server reply — the escape hatch when the vanilla-only parse can't
|
||||
tokenize a plugin or localized "banlist". Collapsed by default. */}
|
||||
{!loading && !error && raw && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer select-none text-muted-foreground transition-colors hover:text-foreground">
|
||||
{t("access_ban_raw")}
|
||||
</summary>
|
||||
<pre className="mt-1.5 whitespace-pre-wrap break-words rounded-md border border-border bg-muted/30 p-2.5 font-mono text-foreground">
|
||||
{raw}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<FeedbackLine fb={fb} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
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 { api, humanizeError } from "@/lib/api";
|
||||
import { useAsync } from "@/lib/hooks";
|
||||
import {
|
||||
CollapsibleSection,
|
||||
FeedbackLine,
|
||||
PagerFooter,
|
||||
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 }: { name: string }) {
|
||||
const { t } = useTranslation("servers");
|
||||
const { data, error, loading, reload } = useAsync(() => api.accessPlayers(name), [name]);
|
||||
const [updatedAt, setUpdatedAt] = useState<Date | null>(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<Feedback>(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 {
|
||||
if (action === "kick") await api.accessKick(name, playerName);
|
||||
else await api.accessBan(name, "ban", playerName);
|
||||
setFb({
|
||||
kind: "ok",
|
||||
msg: 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 (
|
||||
<CollapsibleSection
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
title={t("access_online_title")}
|
||||
count={!loading && !error ? (max > 0 ? `${online} / ${max}` : online) : undefined}
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground"
|
||||
onClick={reload}
|
||||
disabled={loading}
|
||||
title={t("access_refresh")}
|
||||
aria-label={t("access_refresh")}
|
||||
>
|
||||
<RotateCw className={loading ? "h-4 w-4 animate-spin" : "h-4 w-4"} />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Freshness stamp — the honest counterpart to manual refresh — sits with the
|
||||
section's description now that the card header is just the collapsed index. */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm text-muted-foreground">{t("access_online_desc")}</p>
|
||||
{updatedAt && !loading && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{t("access_updated_at", { time: updatedAt.toLocaleTimeString() })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && !data ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t("log_connecting")}
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-xs text-destructive">{t("access_online_load_error")}</p>
|
||||
) : players.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
|
||||
{namesUnavailable ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("access_online_names_unavailable", { count: online })}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground/80">
|
||||
{t("access_online_names_unavailable_hint")}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t("access_online_empty")}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{showSearch && <SearchBox value={query} onChange={onQuery} />}
|
||||
|
||||
<ul className="divide-y divide-border rounded-md border border-border">
|
||||
{shown.length === 0 ? (
|
||||
<li className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
{t("access_search_no_match", { query: query.trim() })}
|
||||
</li>
|
||||
) : (
|
||||
pageItems.map((p) => {
|
||||
// Narrow here so confirming.action is non-null inside the branch.
|
||||
const c = confirming && confirming.player === p ? confirming : null;
|
||||
const isPending = pending?.player === p;
|
||||
return (
|
||||
<li
|
||||
key={p}
|
||||
className="flex items-center justify-between gap-2 px-3 py-2 transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500" />
|
||||
<span className="truncate font-mono text-sm">{p}</span>
|
||||
</span>
|
||||
{/* Both kick and ban are disruptive, so each arms a one-step
|
||||
inline confirm before it fires (no native confirm()). */}
|
||||
{c ? (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<span className="mr-1 hidden text-xs text-muted-foreground sm:inline">
|
||||
{c.action === "kick" ? t("access_kick_q") : t("access_ban_q")}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2"
|
||||
onClick={() => setConfirming(null)}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t("access_cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-6 px-2"
|
||||
onClick={() => run(p, c.action)}
|
||||
disabled={pending !== null}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : c.action === "kick" ? (
|
||||
t("access_kick_btn")
|
||||
) : (
|
||||
t("access_ban_btn")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={() => setConfirming({ player: p, action: "kick" })}
|
||||
disabled={pending !== null}
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{t("access_kick_btn")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => setConfirming({ player: p, action: "ban" })}
|
||||
disabled={pending !== null}
|
||||
>
|
||||
<Ban className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{t("access_ban_btn")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{needFooter && (
|
||||
<PagerFooter
|
||||
q={q}
|
||||
shownCount={shown.length}
|
||||
total={players.length}
|
||||
pageCount={pageCount}
|
||||
clampedPage={clampedPage}
|
||||
onPage={setPage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer select-none text-muted-foreground transition-colors hover:text-foreground">
|
||||
{t("access_online_raw")}
|
||||
</summary>
|
||||
<pre className="mt-1.5 whitespace-pre-wrap break-words rounded-md border border-border bg-muted/30 p-2.5 font-mono text-foreground">
|
||||
{raw}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<FeedbackLine fb={fb} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
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 { api, humanizeError } from "@/lib/api";
|
||||
import { useAsync } from "@/lib/hooks";
|
||||
import {
|
||||
CollapsibleSection,
|
||||
FeedbackLine,
|
||||
MC_NAME,
|
||||
PagerFooter,
|
||||
PlayerField,
|
||||
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 }: { name: string }) {
|
||||
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<string | null>(null);
|
||||
const [confirming, setConfirming] = useState<string | null>(null);
|
||||
const [fb, setFb] = useState<Feedback>(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 {
|
||||
await api.accessWhitelist(name, "add", player);
|
||||
setFb({ kind: "ok", msg: 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 {
|
||||
await api.accessWhitelist(name, "remove", p);
|
||||
setFb({ kind: "ok", msg: t("access_whitelist_removed", { player: p }) });
|
||||
reload();
|
||||
} catch (e) {
|
||||
setFb({ kind: "err", msg: humanizeError(e) });
|
||||
} finally {
|
||||
setRemoving(null);
|
||||
setConfirming(null);
|
||||
}
|
||||
},
|
||||
[name, reload, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<CollapsibleSection
|
||||
icon={<ListChecks className="h-4 w-4" />}
|
||||
title={t("access_whitelist_title")}
|
||||
count={!loading && !error ? players.length : undefined}
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground"
|
||||
onClick={reload}
|
||||
disabled={loading}
|
||||
title={t("access_refresh")}
|
||||
aria-label={t("access_refresh")}
|
||||
>
|
||||
<RotateCw className={loading ? "h-4 w-4 animate-spin" : "h-4 w-4"} />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t("access_whitelist_desc")}</p>
|
||||
|
||||
{/* Add row — the primary action, kept at the top so it is always in reach. */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1">
|
||||
<PlayerField
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onEnter={add}
|
||||
invalid={invalid}
|
||||
disabled={adding}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" className="h-9" onClick={add} disabled={!valid || adding}>
|
||||
{adding ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
{t("access_whitelist_add")}
|
||||
</Button>
|
||||
</div>
|
||||
{invalid && <p className="text-xs text-destructive">{t("access_player_invalid")}</p>}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> {t("log_connecting")}
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-xs text-destructive">{t("access_whitelist_load_error")}</p>
|
||||
) : players.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-border bg-muted/20 px-4 py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">{t("access_whitelist_empty")}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground/80">
|
||||
{t("access_whitelist_empty_hint")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{showSearch && <SearchBox value={query} onChange={onQuery} />}
|
||||
|
||||
<ul className="divide-y divide-border rounded-md border border-border">
|
||||
{shown.length === 0 ? (
|
||||
<li className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
{t("access_search_no_match", { query: query.trim() })}
|
||||
</li>
|
||||
) : (
|
||||
pageItems.map((p) => (
|
||||
<li
|
||||
key={p}
|
||||
className="flex items-center justify-between gap-2 px-3 py-2 transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<span className="truncate font-mono text-sm">{p}</span>
|
||||
{/* 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 ? (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<span className="mr-1 hidden text-xs text-muted-foreground sm:inline">
|
||||
{t("access_whitelist_remove_q")}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2"
|
||||
onClick={() => setConfirming(null)}
|
||||
disabled={removing === p}
|
||||
>
|
||||
{t("access_cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-6 px-2"
|
||||
onClick={() => remove(p)}
|
||||
disabled={removing !== null}
|
||||
>
|
||||
{removing === p ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
t("access_remove")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(p)}
|
||||
disabled={removing !== null}
|
||||
aria-label={t("access_whitelist_remove", { player: p })}
|
||||
title={t("access_whitelist_remove", { player: p })}
|
||||
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-40"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{needFooter && (
|
||||
<PagerFooter
|
||||
q={q}
|
||||
shownCount={shown.length}
|
||||
total={players.length}
|
||||
pageCount={pageCount}
|
||||
clampedPage={clampedPage}
|
||||
onPage={setPage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer select-none text-muted-foreground transition-colors hover:text-foreground">
|
||||
{t("access_whitelist_raw")}
|
||||
</summary>
|
||||
<pre className="mt-1.5 whitespace-pre-wrap break-words rounded-md border border-border bg-muted/30 p-2.5 font-mono text-foreground">
|
||||
{raw}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<FeedbackLine fb={fb} />
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import {
|
||||
useId,
|
||||
useMemo,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronLeft, ChevronRight, Search } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// MC_NAME mirrors the backend's mcNameRe (handlers_access.go): a Minecraft name is
|
||||
// 1–16 chars of [A-Za-z0-9_]. Validating client-side gives instant feedback and
|
||||
// matches exactly what the server accepts, so a well-formed name never round-trips
|
||||
// just to learn the rule. The server re-validates regardless — this is UX, not
|
||||
// trust (the structured field is the whole reason there is no injection surface).
|
||||
export const MC_NAME = /^[A-Za-z0-9_]{1,16}$/;
|
||||
|
||||
export type Feedback = { kind: "ok" | "err"; msg: string } | null;
|
||||
|
||||
/** FeedbackLine is the shared inline result line for a player action: emerald on
|
||||
* success, destructive on failure. There is no toast library — every section
|
||||
* reports here, in place, right under the control that fired. */
|
||||
export function FeedbackLine({ fb }: { fb: Feedback }) {
|
||||
if (!fb) return null;
|
||||
return (
|
||||
<p
|
||||
role="status"
|
||||
className={fb.kind === "ok" ? "text-xs text-emerald-500" : "text-xs text-destructive"}
|
||||
>
|
||||
{fb.msg}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** PlayerField is the shared player-name input: a controlled text box that enforces
|
||||
* the access charset live (showing the rule only once the user has typed something
|
||||
* wrong) and fires onEnter so the keyboard-only path works in every section. */
|
||||
export function PlayerField({
|
||||
value,
|
||||
onChange,
|
||||
onEnter,
|
||||
invalid,
|
||||
disabled,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onEnter: () => void;
|
||||
invalid: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("servers");
|
||||
return (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onEnter();
|
||||
}
|
||||
}}
|
||||
placeholder={t("access_player_placeholder")}
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
maxLength={16}
|
||||
disabled={disabled}
|
||||
aria-invalid={invalid}
|
||||
className={invalid ? "border-destructive focus-visible:ring-destructive" : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** CollapsibleSection is the shared shell for every player-management block. The
|
||||
* page stacks several rosters (online, whitelist, bans); at a few hundred names
|
||||
* each, showing them all expanded buries the one an admin actually wants. So each
|
||||
* block collapses to a single index row — chevron, title, live count, and its
|
||||
* refresh — and expands on click. The count and refresh stay visible while
|
||||
* collapsed so the header doubles as an at-a-glance, refreshable index; `actions`
|
||||
* therefore renders in both states, and only the body (`children`) is hidden. */
|
||||
export function CollapsibleSection({
|
||||
icon,
|
||||
title,
|
||||
count,
|
||||
actions,
|
||||
defaultOpen = false,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
/** Shown as a muted badge beside the title; omit while loading so no stale/empty
|
||||
* badge flashes. This is the number the collapsed index is worth reading. */
|
||||
count?: ReactNode;
|
||||
/** Header controls kept visible in both states (e.g. refresh) — a sibling of the
|
||||
* toggle, so clicking them never folds the section. */
|
||||
actions?: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const contentId = useId();
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 p-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
aria-controls={contentId}
|
||||
className="group flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200 ease-out group-hover:text-foreground motion-reduce:transition-none",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
{icon}
|
||||
<span className="truncate text-base font-semibold tracking-tight">{title}</span>
|
||||
{count != null && (
|
||||
<Badge variant="muted" className="font-normal tabular-nums">
|
||||
{count}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
{/* Expand / collapse animates the body's real height. The trick is the
|
||||
grid-rows 0fr↔1fr transition: it is the one pure-CSS way to ease to an
|
||||
UNKNOWN auto height (no JS measuring, no guessed max-height that would make
|
||||
the easing feel wrong). Three layers, each with one job:
|
||||
1. the grid — animates the track height 0fr↔1fr;
|
||||
2. overflow-hidden + min-h-0 — clips the body while it rolls up (min-h-0
|
||||
defeats a grid item's automatic minimum size so it truly reaches 0);
|
||||
`visibility` rides the SAME duration so, by the CSS visibility-
|
||||
transition rule, the body stays visible until the roll-up finishes and
|
||||
only THEN leaves the a11y tree — collapsed content is neither tabbable
|
||||
nor read by a screen reader, yet still animates;
|
||||
3. the padded body — fades opacity in step so it doesn't pop.
|
||||
motion-reduce collapses all of it to an instant toggle. */}
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none",
|
||||
open ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 overflow-hidden transition-[visibility] duration-200 motion-reduce:transition-none",
|
||||
open ? "visible" : "invisible",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
id={contentId}
|
||||
className={cn(
|
||||
"p-5 pt-0 transition-opacity duration-200 ease-out motion-reduce:transition-none",
|
||||
open ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Defaults shared by every roster list: page over 10 names at a time, and only
|
||||
// show the filter once the list is long enough that the eye can't just scan it.
|
||||
const PAGE_SIZE = 10;
|
||||
const SEARCH_THRESHOLD = 8;
|
||||
|
||||
/** usePagedNames is the shared list engine for every player roster (whitelist,
|
||||
* online, bans): a client-side filter plus paging over the filtered result. A
|
||||
* whitelist or a full server can run to hundreds of names, and paging a screenful
|
||||
* at a time — after search narrows — beats a cramped scroll box. clampedPage keeps
|
||||
* a stale-high page valid after a removal shrinks the list, so the view never
|
||||
* lands on an empty page. Changing the query resets to the first page. */
|
||||
export function usePagedNames(names: string[]) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const shown = useMemo(
|
||||
() => (q ? names.filter((n) => n.toLowerCase().includes(q)) : names),
|
||||
[names, q],
|
||||
);
|
||||
const showSearch = names.length > SEARCH_THRESHOLD;
|
||||
const pageCount = Math.max(1, Math.ceil(shown.length / PAGE_SIZE));
|
||||
const clampedPage = Math.min(page, pageCount - 1);
|
||||
const pageItems = shown.slice(clampedPage * PAGE_SIZE, clampedPage * PAGE_SIZE + PAGE_SIZE);
|
||||
const needFooter = shown.length > PAGE_SIZE || (q !== "" && shown.length > 0);
|
||||
|
||||
const onQuery = (v: string) => {
|
||||
setQuery(v);
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
return {
|
||||
query,
|
||||
onQuery,
|
||||
q,
|
||||
shown,
|
||||
showSearch,
|
||||
pageItems,
|
||||
pageCount,
|
||||
clampedPage,
|
||||
needFooter,
|
||||
setPage,
|
||||
};
|
||||
}
|
||||
|
||||
/** SearchBox is the shared roster filter input — a search-icon-prefixed field. */
|
||||
export function SearchBox({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("servers");
|
||||
return (
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={t("access_search_placeholder")}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="h-8 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** PagerFooter is the shared roster footer: a live count on the left (total, or
|
||||
* filtered-of-total while searching) and prev / page-of / next controls on the
|
||||
* right, shown only when there is more than one page. */
|
||||
export function PagerFooter({
|
||||
q,
|
||||
shownCount,
|
||||
total,
|
||||
pageCount,
|
||||
clampedPage,
|
||||
onPage,
|
||||
}: {
|
||||
q: string;
|
||||
shownCount: number;
|
||||
total: number;
|
||||
pageCount: number;
|
||||
clampedPage: number;
|
||||
onPage: (page: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("servers");
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span className="tabular-nums">
|
||||
{q
|
||||
? t("access_search_count", { shown: shownCount, total })
|
||||
: t("access_total_count", { total })}
|
||||
</span>
|
||||
{pageCount > 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={() => onPage(clampedPage - 1)}
|
||||
disabled={clampedPage === 0}
|
||||
aria-label={t("access_page_prev")}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t("access_page_prev")}</span>
|
||||
</Button>
|
||||
<span className="min-w-[4.5rem] text-center tabular-nums">
|
||||
{t("access_page_indicator", { page: clampedPage + 1, pages: pageCount })}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={() => onPage(clampedPage + 1)}
|
||||
disabled={clampedPage >= pageCount - 1}
|
||||
aria-label={t("access_page_next")}
|
||||
>
|
||||
<span className="hidden sm:inline">{t("access_page_next")}</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user