Loading panel/src/components/players/BansSection.tsx +13 −16 Changes for panel/src/components/players/BansSection.tsx: 13 added lines, 16 removed lines. Original line number Diff line number Diff line Loading @@ -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} > Loading @@ -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") )} Loading @@ -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 Loading panel/src/components/players/OnlineSection.tsx +25 −22 Changes for panel/src/components/players/OnlineSection.tsx: 25 added lines, 22 removed lines. Original line number Diff line number Diff line Loading @@ -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); Loading Loading @@ -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" Loading Loading @@ -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} > Loading @@ -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") ) : ( Loading @@ -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 Loading panel/src/components/players/WhitelistSection.tsx +13 −15 Changes for panel/src/components/players/WhitelistSection.tsx: 13 added lines, 15 removed lines. Original line number Diff line number Diff line Loading @@ -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), Loading Loading @@ -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" Loading Loading @@ -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} > Loading @@ -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") )} Loading @@ -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 Loading Loading
panel/src/components/players/BansSection.tsx +13 −16 Changes for panel/src/components/players/BansSection.tsx: 13 added lines, 16 removed lines. Original line number Diff line number Diff line Loading @@ -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} > Loading @@ -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") )} Loading @@ -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 Loading
panel/src/components/players/OnlineSection.tsx +25 −22 Changes for panel/src/components/players/OnlineSection.tsx: 25 added lines, 22 removed lines. Original line number Diff line number Diff line Loading @@ -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); Loading Loading @@ -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" Loading Loading @@ -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} > Loading @@ -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") ) : ( Loading @@ -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 Loading
panel/src/components/players/WhitelistSection.tsx +13 −15 Changes for panel/src/components/players/WhitelistSection.tsx: 13 added lines, 15 removed lines. Original line number Diff line number Diff line Loading @@ -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), Loading Loading @@ -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" Loading Loading @@ -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} > Loading @@ -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") )} Loading @@ -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 Loading