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">
|
<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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user