Files
kubernetes/prod/kafkachat/chat-frontend/src/App.tsx
T
2026-05-31 16:07:30 +02:00

285 lines
9.7 KiB
TypeScript

import React, { useState, useEffect, useRef } from "react";
import Sidebar from "./Sidebar";
import ChatPanel from "./ChatPanel";
import { Room, ChatMessage } from "./types";
function App() {
const [rooms, setRooms] = useState<Room[]>([]);
const [user, setUser] = useState<string | null>(() => localStorage.getItem("chatUser"));
const [userProfile, setUserProfile] = useState<any>(() => {
const saved = localStorage.getItem("chatProfile");
return saved ? JSON.parse(saved) : null;
});
const [usernameInput, setUsernameInput] = useState("");
const [passwordInput, setPasswordInput] = useState("");
const [currentRoom, setCurrentRoom] = useState<Room | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [wsKey, setWsKey] = useState(0);
const [wsStatus, setWsStatus] = useState<string>("connecting");
const ws = useRef<WebSocket | null>(null);
const currentRoomRef = useRef<Room | null>(null);
const intentionalClose = useRef(false);
const handleLogin = async () => {
try {
const res = await fetch("/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: usernameInput, password: passwordInput }),
});
if (!res.ok) { alert("Invalid credentials"); return; }
const profile = await res.json();
localStorage.setItem("chatUser", profile.username);
localStorage.setItem("chatProfile", JSON.stringify(profile));
setUserProfile(profile);
setUser(profile.username);
} catch (err) {
alert("Login failed");
}
};
const fetchSubscribedRooms = async (username: string) => {
try {
const res = await fetch(`/rooms/subscribed/${username}`);
const data = await res.json();
setRooms(data.map((r: any) => ({ id: r._id, topic: r.name, ...r })));
const saved = localStorage.getItem("chatRoom");
if (saved) {
const found = data.find((r: any) => r._id === saved);
if (found) {
const room = { id: found._id, topic: found.name, ...found };
setCurrentRoom(room);
currentRoomRef.current = room;
}
}
} catch (err) {
console.error("Failed to fetch subscribed rooms:", err);
}
};
useEffect(() => {
if (!user) return;
fetchSubscribedRooms(user);
const interval = setInterval(() => fetchSubscribedRooms(user), 10000);
return () => clearInterval(interval);
}, [user]);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const token = params.get("invite");
if (!token) return;
const handleInvite = async (username: string) => {
try {
const infoRes = await fetch(`/invite/${token}`);
if (!infoRes.ok) { console.error("Invalid invite"); return; }
const { room, invitedUsername } = await infoRes.json();
if (invitedUsername !== username) {
alert(`This invite is for user "${invitedUsername}". Please log in as that user.`);
return;
}
const acceptRes = await fetch(`/invite/${token}/accept`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username }),
});
if (acceptRes.ok) {
window.history.replaceState({}, "", window.location.pathname);
await fetchSubscribedRooms(username);
const newRoom = { id: room._id, topic: room.name, ...room };
setCurrentRoom(newRoom);
currentRoomRef.current = newRoom;
localStorage.setItem("chatRoom", room._id);
}
} catch (err) {
console.error("Failed to handle invite:", err);
}
};
if (user) {
handleInvite(user);
} else {
localStorage.setItem("pendingInvite", token);
}
}, [user]);
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
intentionalClose.current = false;
setWsKey((k) => k + 1);
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => document.removeEventListener("visibilitychange", handleVisibilityChange);
}, []);
useEffect(() => {
if (!user) return;
intentionalClose.current = false;
const proto = window.location.protocol === "https:" ? "wss" : "ws";
const socket = new WebSocket(`${proto}://${window.location.host}`);
ws.current = socket;
socket.onopen = () => {
setWsStatus("connected");
if (currentRoomRef.current) {
socket.send(JSON.stringify({ type: "join", roomId: currentRoomRef.current.id }));
fetch(`/messages/${currentRoomRef.current.id}`)
.then(r => r.json())
.then(msgs => setMessages(msgs))
.catch(console.error);
}
};
socket.onmessage = (event) => {
try {
const msg: ChatMessage & { roomId: string } = JSON.parse(event.data);
if (currentRoomRef.current && msg.roomId === currentRoomRef.current.id) {
setMessages((prev) => [...prev, msg]);
}
} catch (e) {
console.error("Failed to parse WS message:", e);
}
};
socket.onclose = (event) => {
setWsStatus(`closed (${event.code})`);
if (!intentionalClose.current) {
setTimeout(() => setWsKey((k) => k + 1), 2000);
}
intentionalClose.current = false;
};
socket.onerror = (e) => console.error("WebSocket error", e);
return () => {
intentionalClose.current = true;
socket.onclose = null;
socket.close();
};
}, [user, wsKey]);
const handleSend = (text: string) => {
if (!currentRoom || !ws.current || !user) return;
ws.current.send(JSON.stringify({
roomId: currentRoom.id,
user,
text,
ts: Date.now(),
}));
};
const handleRoomSelect = async (room: Room) => {
setCurrentRoom(room);
currentRoomRef.current = room;
localStorage.setItem("chatRoom", room.id);
if (ws.current?.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify({ type: "join", roomId: room.id }));
} else {
ws.current?.addEventListener("open", () => {
ws.current?.send(JSON.stringify({ type: "join", roomId: room.id }));
}, { once: true });
}
try {
const res = await fetch(`/messages/${room.id}`);
const msgs: ChatMessage[] = await res.json();
setMessages(msgs);
} catch (err) {
setMessages([]);
}
};
const handleRoomsUpdate = () => {
if (user) fetchSubscribedRooms(user);
};
const handleProfileUpdate = (profile: any) => {
setUserProfile(profile);
localStorage.setItem("chatProfile", JSON.stringify(profile));
};
const handleSignOut = () => {
localStorage.clear();
window.location.reload();
};
if (!user) {
return (
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
<div className="bg-white rounded-2xl shadow-xl p-8 w-full max-w-sm">
<div className="flex items-center gap-3 mb-8">
<div className="w-10 h-10 rounded-xl bg-teal-500 flex items-center justify-center">
<svg viewBox="0 0 24 24" className="w-6 h-6 fill-white">
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/>
</svg>
</div>
<div>
<h1 className="text-lg font-semibold text-gray-900">KafkaChat</h1>
<p className="text-xs text-gray-400">Sign in to continue</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="text-xs font-medium text-gray-600 uppercase tracking-wide block mb-1">Username</label>
<input
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-teal-500 focus:ring-1 focus:ring-teal-500 bg-gray-50"
placeholder="Enter username"
value={usernameInput}
onChange={(e) => setUsernameInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleLogin()}
/>
</div>
<div>
<label className="text-xs font-medium text-gray-600 uppercase tracking-wide block mb-1">Password</label>
<input
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-teal-500 focus:ring-1 focus:ring-teal-500 bg-gray-50"
type="password"
placeholder="Enter password"
value={passwordInput}
onChange={(e) => setPasswordInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleLogin()}
/>
</div>
<button
onClick={handleLogin}
className="w-full py-2.5 bg-teal-500 hover:bg-teal-600 active:bg-teal-700 text-white rounded-lg text-sm font-medium transition-colors"
>
Sign In
</button>
</div>
</div>
</div>
);
}
return (
<div className="flex h-screen bg-white overflow-hidden">
<div className={`fixed top-3 right-3 w-2 h-2 rounded-full z-50 transition-colors ${wsStatus === "connected" ? "bg-teal-500" : "bg-red-400"}`} />
<Sidebar
rooms={rooms}
currentRoomId={currentRoom?.id || null}
onSelect={handleRoomSelect}
currentUser={user}
userProfile={userProfile}
onSignOut={handleSignOut}
onProfileUpdate={handleProfileUpdate}
onRoomsUpdate={handleRoomsUpdate}
/>
<ChatPanel
room={currentRoom}
messages={messages}
onSend={handleSend}
currentUser={user}
userProfile={userProfile}
/>
</div>
);
}
export default App;