mc-dashboard/components/ChatBridge.tsx
hurkicorgi 359a12ef9d SSE events bridge, PWA service worker, offline banner, lazy admin tabs
- New /api/events SSE endpoint (authed): pushes status every 5s and chat
  on log-file mtime change (~1.5s poll). Heartbeat every 15s, hard-caps
  each stream at 10min so the browser gets a clean auth refresh on
  reconnect. Auto-aborts on client disconnect.
- Factored shared helpers out of the existing routes:
  - lib/server-status.ts (probeStatus, reused by /api/status + SSE)
  - lib/chat-log.ts (parseLogLine, readChatMessages, logMtime, reused by
    /api/chat + SSE)
- EventsBridge client (mounted in Providers) opens one EventSource per
  authed session and writes live data into the TanStack Query cache for
  ["status"] and ["chat"] — no refactor needed in consuming components,
  they keep reading their usual query keys.
- Now that SSE pushes updates, polling intervals bumped: StatusCard and
  ServerControls 10s -> 60s, ChatBridge 5s -> 30s. SSE handles realtime,
  polling is safety fallback.
- OfflineBanner: sticky amber bar when navigator.onLine flips false.
- PWA: minimal public/sw.js with shell + asset cache (network-first for
  HTML, stale-while-revalidate for static assets, never touches /api/*
  or text/event-stream). ServiceWorkerRegister client registers it in
  production only.
- AdminTabs now uses next/dynamic with skeleton fallbacks for Players /
  Chat / Mods / Backups / Logs, keeping initial /admin bundle smaller.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:48:00 -06:00

162 lines
5.1 KiB
TypeScript

"use client";
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 { PlayerAvatar } from "@/components/PlayerAvatar";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
type ChatMessage = {
time: string;
type: "chat" | "join" | "leave" | "death" | "server";
player: string;
message: string;
};
export function ChatBridge() {
const queryClient = useQueryClient();
const chatRef = useRef<HTMLDivElement>(null);
const [message, setMessage] = useState("");
const autoScrollRef = useRef(true);
const { data: messages = [] } = useQuery<ChatMessage[]>({
queryKey: ["chat"],
queryFn: () => fetch("/api/chat?lines=50").then((r) => r.json()),
refetchInterval: 30_000,
staleTime: 2000,
});
const send = useMutation({
mutationFn: async (msg: string) => {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: msg }),
});
if (!res.ok) throw new Error("Failed to send");
return res.json();
},
onSuccess: () => {
setMessage("");
autoScrollRef.current = true;
setTimeout(() => queryClient.invalidateQueries({ queryKey: ["chat"] }), 500);
},
});
useEffect(() => {
if (chatRef.current && autoScrollRef.current) {
chatRef.current.scrollTop = chatRef.current.scrollHeight;
}
}, [messages]);
const handleScroll = () => {
if (!chatRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = chatRef.current;
autoScrollRef.current = scrollHeight - scrollTop - clientHeight < 60;
};
const handleSend = () => {
const msg = message.trim();
if (!msg) return;
send.mutate(msg);
};
const typeColors: Record<string, string> = {
chat: "text-foreground",
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>
<CardTitle>Chat Bridge</CardTitle>
<CardDescription>
Live in-game chat auto-refreshes every 3 seconds
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{/* Chat window */}
<div
ref={chatRef}
onScroll={handleScroll}
className="h-[250px] sm:h-[300px] overflow-y-auto rounded-lg border border-border bg-background p-3 space-y-0.5"
>
{messages.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No chat messages yet
</p>
) : (
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>
{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>
</div>
);
})
)}
</div>
{/* Send message */}
<div className="flex gap-2">
<Input
placeholder="Send a message as [Server]..."
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSend();
}}
maxLength={256}
className="flex-1"
/>
<Button onClick={handleSend} disabled={!message.trim() || send.isPending}>
Send
</Button>
</div>
</CardContent>
</Card>
);
}