refactor(panel): optimize player list layout and horizontal operations

This commit is contained in:
Lemon-miaow committed 2026-07-02 03:30:50 +08:00
1 parent 8ae65ae74a
commit a15ff55342
3 files changed
+51 -53

No files matched your search

+13 -16
View File
@@ -195,33 +195,30 @@ export function BansSection({ name }: { name: string }) {
<div className="space-y-2"> <div className="space-y-2">
{showSearch && <SearchBox value={query} onChange={onQuery} />} {showSearch && <SearchBox value={query} onChange={onQuery} />}
<ul className="divide-y divide-border rounded-md border border-border"> <div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{shown.length === 0 ? ( {shown.length === 0 ? (
<li className="px-3 py-6 text-center text-xs text-muted-foreground"> <div className="col-span-full py-6 text-center text-xs text-muted-foreground">
{t("access_search_no_match", { query: query.trim() })} {t("access_search_no_match", { query: query.trim() })}
</li> </div>
) : ( ) : (
pageItems.map((p) => ( pageItems.map((p) => (
<li <div
key={p} key={p}
className="flex items-center justify-between gap-2 px-3 py-2 transition-colors hover:bg-muted/40" className="flex items-center justify-between gap-3 rounded-lg border border-border bg-card/25 p-2.5 transition-all hover:border-primary/20 hover:bg-accent/40"
> >
<span className="flex min-w-0 items-center gap-2"> <span className="flex min-w-0 items-center gap-2">
<Ban className="h-3.5 w-3.5 shrink-0 text-destructive/70" /> <Ban className="h-3.5 w-3.5 shrink-0 text-destructive/70" />
<span className="truncate font-mono text-sm">{p}</span> <span className="truncate font-mono text-sm font-medium text-muted-foreground line-through decoration-destructive/30">{p}</span>
</span> </span>
{/* Pardon lets a player back in, so it asks once: one tap arms the {/* Pardon lets a player back in, so it asks once: one tap arms the
row (取消 / 解封), a second confirms. Lighter than the ban-by-name row (取消 / 解封), a second confirms. Lighter than the ban-by-name
confirm because a mistaken pardon is re-bannable in one tap. */} confirm because a mistaken pardon is re-bannable in one tap. */}
{confirming === p ? ( {confirming === p ? (
<div className="flex shrink-0 items-center gap-1"> <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 <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-6 px-2" className="h-6 px-1.5 text-[10px]"
onClick={() => setConfirming(null)} onClick={() => setConfirming(null)}
disabled={pardoning === p} disabled={pardoning === p}
> >
@@ -229,12 +226,12 @@ export function BansSection({ name }: { name: string }) {
</Button> </Button>
<Button <Button
size="sm" size="sm"
className="h-6 px-2" className="h-6 px-1.5 text-[10px]"
onClick={() => pardon(p)} onClick={() => pardon(p)}
disabled={pardoning !== null} disabled={pardoning !== null}
> >
{pardoning === p ? ( {pardoning === p ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3 w-3 animate-spin" />
) : ( ) : (
t("access_pardon_btn") t("access_pardon_btn")
)} )}
@@ -244,18 +241,18 @@ export function BansSection({ name }: { name: string }) {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-7 shrink-0 px-2" className="h-7 shrink-0 px-2 text-xs gap-1"
onClick={() => setConfirming(p)} onClick={() => setConfirming(p)}
disabled={pardoning !== null} disabled={pardoning !== null}
> >
<Undo2 className="h-3.5 w-3.5" /> <Undo2 className="h-3.5 w-3.5" />
<span className="hidden sm:inline">{t("access_pardon_btn")}</span> <span>{t("access_pardon_btn")}</span>
</Button> </Button>
)} )}
</li> </div>
)) ))
)} )}
</ul> </div>
{needFooter && ( {needFooter && (
<PagerFooter <PagerFooter
+25 -22
View File
@@ -21,7 +21,7 @@ type RowAction = "kick" | "ban";
* alone — so it reads the RCON "list" reply on demand. Refresh is MANUAL (a button * 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 * + 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. */ * per viewer forever, and the roster does not move fast enough to justify it. */
export function OnlineSection({ name }: { name: string }) { export function OnlineSection({ name, defaultOpen = true }: { name: string; defaultOpen?: boolean }) {
const { t } = useTranslation("servers"); const { t } = useTranslation("servers");
const { data, error, loading, reload } = useAsync(() => api.accessPlayers(name), [name]); const { data, error, loading, reload } = useAsync(() => api.accessPlayers(name), [name]);
const [updatedAt, setUpdatedAt] = useState<Date | null>(null); const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
@@ -73,6 +73,7 @@ export function OnlineSection({ name }: { name: string }) {
icon={<Users className="h-4 w-4" />} icon={<Users className="h-4 w-4" />}
title={t("access_online_title")} title={t("access_online_title")}
count={!loading && !error ? (max > 0 ? `${online} / ${max}` : online) : undefined} count={!loading && !error ? (max > 0 ? `${online} / ${max}` : online) : undefined}
defaultOpen={defaultOpen}
actions={ actions={
<Button <Button
variant="ghost" variant="ghost"
@@ -124,36 +125,36 @@ export function OnlineSection({ name }: { name: string }) {
<div className="space-y-2"> <div className="space-y-2">
{showSearch && <SearchBox value={query} onChange={onQuery} />} {showSearch && <SearchBox value={query} onChange={onQuery} />}
<ul className="divide-y divide-border rounded-md border border-border"> <div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{shown.length === 0 ? ( {shown.length === 0 ? (
<li className="px-3 py-6 text-center text-xs text-muted-foreground"> <div className="col-span-full py-6 text-center text-xs text-muted-foreground">
{t("access_search_no_match", { query: query.trim() })} {t("access_search_no_match", { query: query.trim() })}
</li> </div>
) : ( ) : (
pageItems.map((p) => { pageItems.map((p) => {
// Narrow here so confirming.action is non-null inside the branch. // Narrow here so confirming.action is non-null inside the branch.
const c = confirming && confirming.player === p ? confirming : null; const c = confirming && confirming.player === p ? confirming : null;
const isPending = pending?.player === p; const isPending = pending?.player === p;
return ( return (
<li <div
key={p} key={p}
className="flex items-center justify-between gap-2 px-3 py-2 transition-colors hover:bg-muted/40" className="flex items-center justify-between gap-3 rounded-lg border border-border bg-card/25 p-2.5 transition-all hover:border-primary/20 hover:bg-accent/40"
> >
<span className="flex min-w-0 items-center gap-2"> <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="relative flex h-2 w-2 shrink-0">
<span className="truncate font-mono text-sm">{p}</span> <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500"></span>
</span>
<span className="truncate font-mono text-sm font-medium">{p}</span>
</span> </span>
{/* Both kick and ban are disruptive, so each arms a one-step {/* Both kick and ban are disruptive, so each arms a one-step
inline confirm before it fires (no native confirm()). */} inline confirm before it fires (no native confirm()). */}
{c ? ( {c ? (
<div className="flex shrink-0 items-center gap-1"> <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 <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-6 px-2" className="h-6 px-1.5 text-[10px]"
onClick={() => setConfirming(null)} onClick={() => setConfirming(null)}
disabled={isPending} disabled={isPending}
> >
@@ -162,12 +163,12 @@ export function OnlineSection({ name }: { name: string }) {
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
className="h-6 px-2" className="h-6 px-1.5 text-[10px]"
onClick={() => run(p, c.action)} onClick={() => run(p, c.action)}
disabled={pending !== null} disabled={pending !== null}
> >
{isPending ? ( {isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3 w-3 animate-spin" />
) : c.action === "kick" ? ( ) : c.action === "kick" ? (
t("access_kick_btn") t("access_kick_btn")
) : ( ) : (
@@ -180,30 +181,32 @@ export function OnlineSection({ name }: { name: string }) {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-7 px-2" className="h-7 px-2 text-xs gap-1"
onClick={() => setConfirming({ player: p, action: "kick" })} onClick={() => setConfirming({ player: p, action: "kick" })}
disabled={pending !== null} disabled={pending !== null}
title={t("access_kick_btn")}
> >
<LogOut className="h-3.5 w-3.5" /> <LogOut className="h-3.5 w-3.5 shrink-0" />
<span className="hidden sm:inline">{t("access_kick_btn")}</span> <span>{t("access_kick_btn")}</span>
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-7 px-2 text-destructive hover:bg-destructive/10 hover:text-destructive" className="h-7 px-2 text-xs text-destructive hover:bg-destructive/10 hover:text-destructive gap-1"
onClick={() => setConfirming({ player: p, action: "ban" })} onClick={() => setConfirming({ player: p, action: "ban" })}
disabled={pending !== null} disabled={pending !== null}
title={t("access_ban_btn")}
> >
<Ban className="h-3.5 w-3.5" /> <Ban className="h-3.5 w-3.5 shrink-0" />
<span className="hidden sm:inline">{t("access_ban_btn")}</span> <span>{t("access_ban_btn")}</span>
</Button> </Button>
</div> </div>
)} )}
</li> </div>
); );
}) })
)} )}
</ul> </div>
{needFooter && ( {needFooter && (
<PagerFooter <PagerFooter
@@ -19,7 +19,7 @@ import {
* entry, and read the current list. Built to stay usable at a few hundred names — * 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 * 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. */ * enough to need them, and a two-step remove so a name never vanishes on one tap. */
export function WhitelistSection({ name }: { name: string }) { export function WhitelistSection({ name, defaultOpen = true }: { name: string; defaultOpen?: boolean }) {
const { t } = useTranslation("servers"); const { t } = useTranslation("servers");
const { data, error, loading, reload } = useAsync( const { data, error, loading, reload } = useAsync(
() => api.accessWhitelistList(name), () => api.accessWhitelistList(name),
@@ -89,6 +89,7 @@ export function WhitelistSection({ name }: { name: string }) {
icon={<ListChecks className="h-4 w-4" />} icon={<ListChecks className="h-4 w-4" />}
title={t("access_whitelist_title")} title={t("access_whitelist_title")}
count={!loading && !error ? players.length : undefined} count={!loading && !error ? players.length : undefined}
defaultOpen={defaultOpen}
actions={ actions={
<Button <Button
variant="ghost" variant="ghost"
@@ -147,30 +148,27 @@ export function WhitelistSection({ name }: { name: string }) {
<div className="space-y-2"> <div className="space-y-2">
{showSearch && <SearchBox value={query} onChange={onQuery} />} {showSearch && <SearchBox value={query} onChange={onQuery} />}
<ul className="divide-y divide-border rounded-md border border-border"> <div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{shown.length === 0 ? ( {shown.length === 0 ? (
<li className="px-3 py-6 text-center text-xs text-muted-foreground"> <div className="col-span-full py-6 text-center text-xs text-muted-foreground">
{t("access_search_no_match", { query: query.trim() })} {t("access_search_no_match", { query: query.trim() })}
</li> </div>
) : ( ) : (
pageItems.map((p) => ( pageItems.map((p) => (
<li <div
key={p} key={p}
className="flex items-center justify-between gap-2 px-3 py-2 transition-colors hover:bg-muted/40" className="flex items-center justify-between gap-3 rounded-lg border border-border bg-card/25 p-2.5 transition-all hover:border-primary/20 hover:bg-accent/40"
> >
<span className="truncate font-mono text-sm">{p}</span> <span className="truncate font-mono text-sm font-medium">{p}</span>
{/* Removal asks once before it fires: one click arms the row (X → {/* Removal asks once before it fires: one click arms the row (X →
取消 / 移除), a second confirms. Recoverable, but a name gone on 取消 / 移除), a second confirms. Recoverable, but a name gone on
a single stray tap is exactly the surprise to avoid. */} a single stray tap is exactly the surprise to avoid. */}
{confirming === p ? ( {confirming === p ? (
<div className="flex shrink-0 items-center gap-1"> <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 <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-6 px-2" className="h-6 px-1.5 text-[10px]"
onClick={() => setConfirming(null)} onClick={() => setConfirming(null)}
disabled={removing === p} disabled={removing === p}
> >
@@ -179,12 +177,12 @@ export function WhitelistSection({ name }: { name: string }) {
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
className="h-6 px-2" className="h-6 px-1.5 text-[10px]"
onClick={() => remove(p)} onClick={() => remove(p)}
disabled={removing !== null} disabled={removing !== null}
> >
{removing === p ? ( {removing === p ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3 w-3 animate-spin" />
) : ( ) : (
t("access_remove") t("access_remove")
)} )}
@@ -202,10 +200,10 @@ export function WhitelistSection({ name }: { name: string }) {
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</button> </button>
)} )}
</li> </div>
)) ))
)} )}
</ul> </div>
{needFooter && ( {needFooter && (
<PagerFooter <PagerFooter