Next.js 16 + Tailwind v4 + shadcn v4 dashboard for managing a modded Forge 1.20.1 server. Includes server controls, player management, mod manager with Modrinth search and dependency resolution, world backups, snapshots, analytics, logs, and chat bridge. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
148 lines
4.6 KiB
TypeScript
148 lines
4.6 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 { Badge } from "@/components/ui/badge";
|
|
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: 3000,
|
|
});
|
|
|
|
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-400",
|
|
leave: "text-amber-400",
|
|
death: "text-red-400",
|
|
server: "text-blue-400",
|
|
};
|
|
|
|
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) => (
|
|
<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}
|
|
</span>
|
|
) : msg.type === "join" ? (
|
|
<span className={`${typeColors.join} break-words`}>
|
|
{msg.player} joined the game
|
|
</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>
|
|
|
|
{/* 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>
|
|
);
|
|
}
|