- 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>
162 lines
5.1 KiB
TypeScript
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>
|
|
);
|
|
}
|