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() {
const queryClient = useQueryClient();
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 { data: backups = [] } = useQuery<Backup[]>({
@ -151,6 +152,29 @@ export function BackupManager() {
Cancel
</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
@ -173,7 +197,7 @@ export function BackupManager() {
<Button
size="sm"
variant="ghost"
onClick={() => deleteBackup.mutate(b.name)}
onClick={() => setConfirmDelete(b.name)}
disabled={isBusy}
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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { PlayerAvatar } from "@/components/PlayerAvatar";
import {
Card,
CardContent,
@ -69,12 +69,16 @@ export function ChatBridge() {
const typeColors: Record<string, string> = {
chat: "text-foreground",
join: "text-emerald-400",
leave: "text-amber-400",
death: "text-red-400",
server: "text-blue-400",
join: "text-emerald-300",
leave: "text-amber-300",
death: "text-red-300",
server: "text-blue-300",
};
const MAX_NAME = 16;
const truncateName = (n: string) =>
n.length > MAX_NAME ? `${n.slice(0, MAX_NAME - 1)}` : n;
return (
<Card>
<CardHeader>
@ -95,34 +99,43 @@ export function ChatBridge() {
No chat messages yet
</p>
) : (
messages.map((msg, i) => (
<div key={`${msg.time}-${i}`} className="flex gap-2 text-sm leading-relaxed min-w-0">
<span className="text-muted-foreground text-xs shrink-0 mt-0.5 font-mono">
{msg.time}
</span>
{msg.type === "chat" ? (
<span className={`${typeColors.chat} break-words min-w-0`}>
<strong>&lt;{msg.player}&gt;</strong> {msg.message}
messages.map((msg, i) => {
const hasPlayer = msg.type !== "server";
const shortName = truncateName(msg.player);
return (
<div
key={`${msg.time}-${i}`}
className="flex items-start gap-2 text-sm leading-relaxed min-w-0"
>
<span className="text-muted-foreground text-xs shrink-0 mt-0.5 font-mono">
{msg.time}
</span>
) : msg.type === "join" ? (
<span className={`${typeColors.join} break-words`}>
{msg.player} joined the game
{hasPlayer ? (
<PlayerAvatar name={msg.player} size={20} className="mt-0.5" />
) : (
<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>
) : msg.type === "leave" ? (
<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);
const [confirmRemove, setConfirmRemove] = 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);
// Installed mods
@ -340,7 +341,7 @@ export function ModManager() {
};
return (
<div className="space-y-6">
<div className="space-y-4 sm:space-y-6">
{/* ── Mod Manager Card ──────────────────────────────── */}
<Card>
<CardHeader>
@ -799,7 +800,7 @@ export function ModManager() {
disabled={isBusy}
className="text-xs h-9"
>
Confirm
Confirm Restore
</Button>
<Button
size="sm"
@ -810,6 +811,29 @@ export function ModManager() {
Cancel
</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
@ -824,7 +848,7 @@ export function ModManager() {
<Button
size="sm"
variant="ghost"
onClick={() => deleteSnap.mutate(snap.dirName)}
onClick={() => setConfirmDeleteSnap(snap.dirName)}
disabled={isBusy}
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,
CardTitle,
} from "@/components/ui/card";
import { PlayerAvatar } from "@/components/PlayerAvatar";
type PlayerData = {
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"
>
<div className="flex items-center gap-2 min-w-0">
<img
src={`https://mc-heads.net/avatar/${p.name}/24`}
alt=""
className="w-6 h-6 rounded shrink-0"
/>
<PlayerAvatar name={p.name} size={24} />
<span className="text-sm font-medium truncate">{p.name}</span>
<Badge variant="secondary" className="text-xs px-1.5 py-0 shrink-0">
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"
>
<div className="flex items-center gap-2 min-w-0">
<img
src={`https://mc-heads.net/avatar/${p.name}/24`}
alt=""
className="w-6 h-6 rounded shrink-0"
/>
<PlayerAvatar name={p.name} size={24} />
<span className="text-sm font-medium truncate">{p.name}</span>
</div>
<Button
@ -251,11 +244,7 @@ export function PlayerManager() {
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<img
src={`https://mc-heads.net/avatar/${p.name}/24`}
alt=""
className="w-6 h-6 rounded shrink-0"
/>
<PlayerAvatar name={p.name} size={24} />
<span className="text-sm font-medium truncate">{p.name}</span>
</div>
{p.reason && (