285 lines
9.7 KiB
TypeScript
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;
|