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:
parent
dd69c17c3b
commit
b6b10159ad
5 changed files with 140 additions and 50 deletions
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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><{msg.player}></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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
40
components/PlayerAvatar.tsx
Normal file
40
components/PlayerAvatar.tsx
Normal 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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue