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() {
|
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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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><{msg.player}></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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
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,
|
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 && (
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue