Polish remaining UX items: avatar fallback, chat truncation, delete confirmations

- PlayerAvatar component with onError fallback to initial badge; used in
  PlayerManager and ChatBridge.
- ChatBridge: truncate long usernames to 16 chars with tooltip, add avatar
  column, bump tinted text colors to -300 for better dark-mode contrast.
- BackupManager: confirm step for Delete (was one-click).
- ModManager: confirm step for snapshot Delete, clearer "Confirm Restore" /
  "Confirm Delete" labels, unify outer spacing with the rest of admin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hurkicorgi 2026-04-13 00:51:35 -06:00
parent dd69c17c3b
commit b6b10159ad
5 changed files with 140 additions and 50 deletions

View file

@ -21,6 +21,7 @@ type Backup = {
export function BackupManager() { export function BackupManager() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [confirmRestore, setConfirmRestore] = useState<string | null>(null); const [confirmRestore, setConfirmRestore] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null); const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
const { data: backups = [] } = useQuery<Backup[]>({ const { data: backups = [] } = useQuery<Backup[]>({
@ -151,6 +152,29 @@ export function BackupManager() {
Cancel Cancel
</Button> </Button>
</> </>
) : confirmDelete === b.name ? (
<>
<Button
size="sm"
variant="destructive"
onClick={() => {
deleteBackup.mutate(b.name);
setConfirmDelete(null);
}}
disabled={isBusy}
className="text-xs h-9"
>
Confirm Delete
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setConfirmDelete(null)}
className="text-xs h-9"
>
Cancel
</Button>
</>
) : ( ) : (
<> <>
<a <a
@ -173,7 +197,7 @@ export function BackupManager() {
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => deleteBackup.mutate(b.name)} onClick={() => setConfirmDelete(b.name)}
disabled={isBusy} disabled={isBusy}
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition" className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
> >

View file

@ -4,7 +4,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { PlayerAvatar } from "@/components/PlayerAvatar";
import { import {
Card, Card,
CardContent, CardContent,
@ -69,12 +69,16 @@ export function ChatBridge() {
const typeColors: Record<string, string> = { const typeColors: Record<string, string> = {
chat: "text-foreground", chat: "text-foreground",
join: "text-emerald-400", join: "text-emerald-300",
leave: "text-amber-400", leave: "text-amber-300",
death: "text-red-400", death: "text-red-300",
server: "text-blue-400", server: "text-blue-300",
}; };
const MAX_NAME = 16;
const truncateName = (n: string) =>
n.length > MAX_NAME ? `${n.slice(0, MAX_NAME - 1)}` : n;
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@ -95,34 +99,43 @@ export function ChatBridge() {
No chat messages yet No chat messages yet
</p> </p>
) : ( ) : (
messages.map((msg, i) => ( messages.map((msg, i) => {
<div key={`${msg.time}-${i}`} className="flex gap-2 text-sm leading-relaxed min-w-0"> const hasPlayer = msg.type !== "server";
<span className="text-muted-foreground text-xs shrink-0 mt-0.5 font-mono"> const shortName = truncateName(msg.player);
{msg.time} return (
</span> <div
{msg.type === "chat" ? ( key={`${msg.time}-${i}`}
<span className={`${typeColors.chat} break-words min-w-0`}> className="flex items-start gap-2 text-sm leading-relaxed min-w-0"
<strong>&lt;{msg.player}&gt;</strong> {msg.message} >
<span className="text-muted-foreground text-xs shrink-0 mt-0.5 font-mono">
{msg.time}
</span> </span>
) : msg.type === "join" ? ( {hasPlayer ? (
<span className={`${typeColors.join} break-words`}> <PlayerAvatar name={msg.player} size={20} className="mt-0.5" />
{msg.player} joined the game ) : (
<div className="w-5 h-5 rounded bg-blue-500/20 shrink-0 mt-0.5 flex items-center justify-center text-[10px] text-blue-300 font-bold">
S
</div>
)}
<span
className={`${typeColors[msg.type]} break-words min-w-0 flex-1`}
title={hasPlayer ? msg.player : undefined}
>
{msg.type === "chat" ? (
<><strong>{shortName}</strong> {msg.message}</>
) : msg.type === "join" ? (
<>{shortName} joined the game</>
) : msg.type === "leave" ? (
<>{shortName} left the game</>
) : msg.type === "death" ? (
<>{shortName} {msg.message}</>
) : (
<>[Server] {msg.message}</>
)}
</span> </span>
) : msg.type === "leave" ? ( </div>
<span className={`${typeColors.leave} break-words`}> );
{msg.player} left the game })
</span>
) : msg.type === "death" ? (
<span className={`${typeColors.death} break-words`}>
{msg.player} {msg.message}
</span>
) : (
<span className={`${typeColors.server} break-words`}>
[Server] {msg.message}
</span>
)}
</div>
))
)} )}
</div> </div>

View file

@ -121,6 +121,7 @@ export function ModManager() {
} | null>(null); } | null>(null);
const [confirmRemove, setConfirmRemove] = useState<string | null>(null); const [confirmRemove, setConfirmRemove] = useState<string | null>(null);
const [confirmRestore, setConfirmRestore] = useState<string | null>(null); const [confirmRestore, setConfirmRestore] = useState<string | null>(null);
const [confirmDeleteSnap, setConfirmDeleteSnap] = useState<string | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null); const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
// Installed mods // Installed mods
@ -340,7 +341,7 @@ export function ModManager() {
}; };
return ( return (
<div className="space-y-6"> <div className="space-y-4 sm:space-y-6">
{/* ── Mod Manager Card ──────────────────────────────── */} {/* ── Mod Manager Card ──────────────────────────────── */}
<Card> <Card>
<CardHeader> <CardHeader>
@ -799,7 +800,7 @@ export function ModManager() {
disabled={isBusy} disabled={isBusy}
className="text-xs h-9" className="text-xs h-9"
> >
Confirm Confirm Restore
</Button> </Button>
<Button <Button
size="sm" size="sm"
@ -810,6 +811,29 @@ export function ModManager() {
Cancel Cancel
</Button> </Button>
</> </>
) : confirmDeleteSnap === snap.dirName ? (
<>
<Button
size="sm"
variant="destructive"
onClick={() => {
deleteSnap.mutate(snap.dirName);
setConfirmDeleteSnap(null);
}}
disabled={isBusy}
className="text-xs h-9"
>
Confirm Delete
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setConfirmDeleteSnap(null)}
className="text-xs h-9"
>
Cancel
</Button>
</>
) : ( ) : (
<> <>
<Button <Button
@ -824,7 +848,7 @@ export function ModManager() {
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={() => deleteSnap.mutate(snap.dirName)} onClick={() => setConfirmDeleteSnap(snap.dirName)}
disabled={isBusy} disabled={isBusy}
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition" className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
> >

View file

@ -0,0 +1,40 @@
"use client";
import { useState } from "react";
export function PlayerAvatar({
name,
size = 24,
className = "",
}: {
name: string;
size?: number;
className?: string;
}) {
const [failed, setFailed] = useState(false);
const dim = `${size}px`;
const initial = name.slice(0, 1).toUpperCase();
if (failed || !name) {
return (
<div
className={`rounded bg-muted text-muted-foreground shrink-0 flex items-center justify-center font-bold ${className}`}
style={{ width: dim, height: dim, fontSize: size * 0.5 }}
aria-label={name}
>
{initial}
</div>
);
}
return (
<img
src={`https://mc-heads.net/avatar/${encodeURIComponent(name)}/${size}`}
alt=""
width={size}
height={size}
onError={() => setFailed(true)}
className={`rounded shrink-0 bg-muted ${className}`}
/>
);
}

View file

@ -14,6 +14,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { PlayerAvatar } from "@/components/PlayerAvatar";
type PlayerData = { type PlayerData = {
ops: { name: string; uuid: string; level: number }[]; ops: { name: string; uuid: string; level: number }[];
@ -183,11 +184,7 @@ export function PlayerManager() {
className="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/50 group" className="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/50 group"
> >
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<img <PlayerAvatar name={p.name} size={24} />
src={`https://mc-heads.net/avatar/${p.name}/24`}
alt=""
className="w-6 h-6 rounded shrink-0"
/>
<span className="text-sm font-medium truncate">{p.name}</span> <span className="text-sm font-medium truncate">{p.name}</span>
<Badge variant="secondary" className="text-xs px-1.5 py-0 shrink-0"> <Badge variant="secondary" className="text-xs px-1.5 py-0 shrink-0">
Lv{p.level} Lv{p.level}
@ -218,11 +215,7 @@ export function PlayerManager() {
className="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/50 group" className="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/50 group"
> >
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<img <PlayerAvatar name={p.name} size={24} />
src={`https://mc-heads.net/avatar/${p.name}/24`}
alt=""
className="w-6 h-6 rounded shrink-0"
/>
<span className="text-sm font-medium truncate">{p.name}</span> <span className="text-sm font-medium truncate">{p.name}</span>
</div> </div>
<Button <Button
@ -251,11 +244,7 @@ export function PlayerManager() {
> >
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<img <PlayerAvatar name={p.name} size={24} />
src={`https://mc-heads.net/avatar/${p.name}/24`}
alt=""
className="w-6 h-6 rounded shrink-0"
/>
<span className="text-sm font-medium truncate">{p.name}</span> <span className="text-sm font-medium truncate">{p.name}</span>
</div> </div>
{p.reason && ( {p.reason && (