refactor(panel): optimize player list layout and horizontal operations
This commit is contained in:
3 files changed
+51
-53
No files matched your search
@@ -195,33 +195,30 @@ export function BansSection({ name }: { name: string }) {
|
||||
<div className="space-y-2">
|
||||
{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 ? (
|
||||
<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() })}
|
||||
</li>
|
||||
</div>
|
||||
) : (
|
||||
pageItems.map((p) => (
|
||||
<li
|
||||
<div
|
||||
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">
|
||||
<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>
|
||||
{/* 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"
|
||||
className="h-6 px-1.5 text-[10px]"
|
||||
onClick={() => setConfirming(null)}
|
||||
disabled={pardoning === p}
|
||||
>
|
||||
@@ -229,12 +226,12 @@ export function BansSection({ name }: { name: string }) {
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-6 px-2"
|
||||
className="h-6 px-1.5 text-[10px]"
|
||||
onClick={() => pardon(p)}
|
||||
disabled={pardoning !== null}
|
||||
>
|
||||
{pardoning === p ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
t("access_pardon_btn")
|
||||
)}
|
||||
@@ -244,18 +241,18 @@ export function BansSection({ name }: { name: string }) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 px-2"
|
||||
className="h-7 shrink-0 px-2 text-xs gap-1"
|
||||
onClick={() => setConfirming(p)}
|
||||
disabled={pardoning !== null}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
</li>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{needFooter && (
|
||||
<PagerFooter
|
||||
|
||||
@@ -21,7 +21,7 @@ type RowAction = "kick" | "ban";
|
||||
* 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 }) {
|
||||
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<Date | null>(null);
|
||||
@@ -73,6 +73,7 @@ export function OnlineSection({ name }: { name: string }) {
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
title={t("access_online_title")}
|
||||
count={!loading && !error ? (max > 0 ? `${online} / ${max}` : online) : undefined}
|
||||
defaultOpen={defaultOpen}
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -124,36 +125,36 @@ export function OnlineSection({ name }: { name: string }) {
|
||||
<div className="space-y-2">
|
||||
{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 ? (
|
||||
<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() })}
|
||||
</li>
|
||||
</div>
|
||||
) : (
|
||||
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
|
||||
<div
|
||||
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="h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500" />
|
||||
<span className="truncate font-mono text-sm">{p}</span>
|
||||
<span className="relative flex h-2 w-2 shrink-0">
|
||||
<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>
|
||||
{/* 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"
|
||||
className="h-6 px-1.5 text-[10px]"
|
||||
onClick={() => setConfirming(null)}
|
||||
disabled={isPending}
|
||||
>
|
||||
@@ -162,12 +163,12 @@ export function OnlineSection({ name }: { name: string }) {
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-6 px-2"
|
||||
className="h-6 px-1.5 text-[10px]"
|
||||
onClick={() => run(p, c.action)}
|
||||
disabled={pending !== null}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : c.action === "kick" ? (
|
||||
t("access_kick_btn")
|
||||
) : (
|
||||
@@ -180,30 +181,32 @@ export function OnlineSection({ name }: { name: string }) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
className="h-7 px-2 text-xs gap-1"
|
||||
onClick={() => setConfirming({ player: p, action: "kick" })}
|
||||
disabled={pending !== null}
|
||||
title={t("access_kick_btn")}
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{t("access_kick_btn")}</span>
|
||||
<LogOut className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{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"
|
||||
className="h-7 px-2 text-xs text-destructive hover:bg-destructive/10 hover:text-destructive gap-1"
|
||||
onClick={() => setConfirming({ player: p, action: "ban" })}
|
||||
disabled={pending !== null}
|
||||
title={t("access_ban_btn")}
|
||||
>
|
||||
<Ban className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">{t("access_ban_btn")}</span>
|
||||
<Ban className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{t("access_ban_btn")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{needFooter && (
|
||||
<PagerFooter
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
* 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 }) {
|
||||
export function WhitelistSection({ name, defaultOpen = true }: { name: string; defaultOpen?: boolean }) {
|
||||
const { t } = useTranslation("servers");
|
||||
const { data, error, loading, reload } = useAsync(
|
||||
() => api.accessWhitelistList(name),
|
||||
@@ -89,6 +89,7 @@ export function WhitelistSection({ name }: { name: string }) {
|
||||
icon={<ListChecks className="h-4 w-4" />}
|
||||
title={t("access_whitelist_title")}
|
||||
count={!loading && !error ? players.length : undefined}
|
||||
defaultOpen={defaultOpen}
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -147,30 +148,27 @@ export function WhitelistSection({ name }: { name: string }) {
|
||||
<div className="space-y-2">
|
||||
{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 ? (
|
||||
<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() })}
|
||||
</li>
|
||||
</div>
|
||||
) : (
|
||||
pageItems.map((p) => (
|
||||
<li
|
||||
<div
|
||||
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 →
|
||||
取消 / 移除), 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"
|
||||
className="h-6 px-1.5 text-[10px]"
|
||||
onClick={() => setConfirming(null)}
|
||||
disabled={removing === p}
|
||||
>
|
||||
@@ -179,12 +177,12 @@ export function WhitelistSection({ name }: { name: string }) {
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-6 px-2"
|
||||
className="h-6 px-1.5 text-[10px]"
|
||||
onClick={() => remove(p)}
|
||||
disabled={removing !== null}
|
||||
>
|
||||
{removing === p ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
t("access_remove")
|
||||
)}
|
||||
@@ -202,10 +200,10 @@ export function WhitelistSection({ name }: { name: string }) {
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{needFooter && (
|
||||
<PagerFooter
|
||||
|
||||
Reference in new issue
Block a user