UI Design

Jazz up your UI with Tailwind.

Developing the UI

With your React application ready, you can start creating React components. You'll use Tailwind CSS for styling, so add it to your project by including the CDN link in the <head> section of your public/index.html file:

<link
	href="https://cdn.jsdelivr.net/npm/tailwindcss@latest/dist/tailwind.min.css"
	rel="stylesheet"
/>

Now, it's time to create the chat UI. The UI consists of the following components, each of which communicates with the Gin backend in different ways:

  • Login.js allows a user to log in by sending a POST request with their username and password to the /login endpoint.
  • CreateUser.js lets a new user sign up by sending a POST request with their username and password to the /users endpoint.
  • MainChat.js displays the chat interface. It doesn't directly communicate with the backend but orchestrates the communication of its child components.
  • ChannelsList.js retrieves and displays a list of chat channels by sending a GET request to the /channels endpoint and allows users to create new channels by POSTing the channel name to the same endpoint.
  • MessagesPanel.js fetches and displays messages for the selected channel by sending a GET request to the /messages endpoint.
  • MessageEntry.js enables users to send messages to the current channel by sending a POST request to the /messages endpoint.

Next, you'll see the code for each component, along with a description of what the component does.

For a preview of where the project ends up at the end of this tutorial, see the final GitHub repository for this part of the series.

If you're not fully familiar React or Tailwind, here are a few key docs and tutorials you may find helpful:

React

Tailwind

Login.js

Create each component file in the src subdirectory of the chat-ui directory. You can do this in GoLand by right-clicking src and selecting New | JavaScript File:

New | JavaScript File selection

Start by adding the Login component:

import React, { useState } from "react";
import { useNavigate } from "react-router-dom";

const Login = () => {
	const [username, setUsername] = useState("");
	const [password, setPassword] = useState("");
	const navigate = useNavigate();

	const handleSubmit = async (e) => {
		e.preventDefault();

		const response = await fetch("/login", {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({ username, password }),
		});

		if (response.ok) {
			const data = await response.json();
			localStorage.setItem("userId", data.id);
			localStorage.setItem("userName", username);
			navigate("/chat");
		} else {
			alert("Login failed");
		}
	};

	return (
		<div className="min-h-screen flex items-center justify-center bg-gray-50">
			<form onSubmit={handleSubmit} className="p-8 border rounded shadow-md">
				<div className="mb-4">
					<label
						htmlFor="username"
						className="block text-sm font-medium text-gray-700"
					>
						Username
					</label>
					<input
						type="text"
						id="username"
						value={username}
						onChange={(e) => setUsername(e.target.value)}
						className="mt-1 p-2 w-full border rounded-md"
						required
					/>
				</div>
				<div className="mb-4">
					<label
						htmlFor="password"
						className="block text-sm font-medium text-gray-700"
					>
						Password
					</label>
					<input
						type="password"
						id="password"
						value={password}
						onChange={(e) => setPassword(e.target.value)}
						className="mt-1 p-2 w-full border rounded-md"
						required
					/>
				</div>
				<button
					type="submit"
					className="w-full p-2 bg-blue-500 text-white rounded-md"
				>
					Log In
				</button>
				<div className="mt-4 text-center">
					<span className="text-sm text-gray-600">Don't have an account? </span>
					<a href="/create-user" className="text-blue-500 hover:underline">
						Create one
					</a>
				</div>
			</form>
		</div>
	);
};

export default Login;

When Login renders, it initializes two pieces of state, username and password, and accesses browsing history via the useHistory hook.

It then renders a form with fields for the username and password. It sets their values to be controlled components by binding them to the username and password states.

On form submission, the handleSubmit function makes a POST request to the /login endpoint, with the username and password as the payload, and redirects to the chat page upon successful login.

CreateUser.js

Next, add the CreateUser component the same way you added Login:

import React, { useState } from "react";
import { useNavigate } from "react-router-dom";

const CreateUser = () => {
	const [username, setUsername] = useState("");
	const [password, setPassword] = useState("");
	const navigate = useNavigate();

	const handleSubmit = async (e) => {
		e.preventDefault();

		const response = await fetch("/users", {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({ username, password }),
		});

		if (response.ok) {
			navigate("/");
		} else {
			alert("Account creation failed");
		}
	};

	return (
		<div className="min-h-screen flex items-center justify-center bg-gray-50">
			<form onSubmit={handleSubmit} className="p-8 border rounded shadow-md">
				<div className="mb-4">
					<label
						htmlFor="username"
						className="block text-sm font-medium text-gray-700"
					>
						Username
					</label>
					<input
						type="text"
						id="username"
						value={username}
						onChange={(e) => setUsername(e.target.value)}
						className="mt-1 p-2 w-full border rounded-md"
						required
					/>
				</div>
				<div className="mb-4">
					<label
						htmlFor="password"
						className="block text-sm font-medium text-gray-700"
					>
						Password
					</label>
					<input
						type="password"
						id="password"
						value={password}
						onChange={(e) => setPassword(e.target.value)}
						className="mt-1 p-2 w-full border rounded-md"
						required
					/>
				</div>
				<button
					type="submit"
					className="w-full p-2 bg-blue-500 text-white rounded-md"
				>
					Create Account
				</button>
			</form>
		</div>
	);
};

export default CreateUser;

CreateUser renders a form with fields for the username and password, binding them to the respective states.

On form submission, the component sends a POST request to /users with the username and password. The user is redirected to the login page if account creation succeeds.

MainChat.js

Add the MainChat component that renders the chat UI:

import React, { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import ChannelsList from "./ChannelsList";
import MessagesPanel from "./MessagesPanel";

const MainChat = () => {
	const { channelId } = useParams();
	const navigate = useNavigate();
	const [selectedChannel, setSelectedChannel] = useState(
		parseInt(channelId) || null,
	);

	// If the component loads with a channel ID in the URL, set it as the selected channel.
	useEffect(() => {
		if (selectedChannel) {
			navigate(`/chat/${selectedChannel.id}`);
		}
	}, [selectedChannel, navigate]);

	const handleChannelSelect = (channelId) => {
		setSelectedChannel(channelId);
	};

	return (
		<div className="flex h-screen">
			<div className="w-1/4 border-r">
				<ChannelsList
					selectedChannel={selectedChannel}
					setSelectedChannel={handleChannelSelect}
				/>
			</div>
			<div className="w-3/4">
				<MessagesPanel selectedChannel={selectedChannel} />
			</div>
		</div>
	);
};

export default MainChat;

MainChat initializes its selectedChannel state as null and retrieves the channel ID from the URL if present.

It renders two child components:

  • ChannelsList displays a list of channels and allows the user to select one.
  • MessagesPanel displays the messages of the selected channel.

When the user selects a channel in the list, it updates the selectedChannel state in MainChat and updates the URL to include the selected channel ID. This selected channel ID is also passed as a prop to MessagesPanel to display messages for that channel.

ChannelsList.js

The following code adds the ChannelsList component that lets users select which chat channel to join:

import React, { useState, useEffect } from "react";
import { useParams } from "react-router-dom";

const ChannelsList = ({ selectedChannel, setSelectedChannel }) => {
	const { channelId } = useParams();
	const [channels, setChannels] = useState([]);
	const [newChannelName, setNewChannelName] = useState("");

	useEffect(() => {
		if (channelId) {
			const channel = channels.find(
				(channel) => channel.id === parseInt(channelId),
			);
			if (channel) {
				setSelectedChannel({ name: channel.name, id: parseInt(channelId) });
			}
		}
	}, [channelId, channels]);

	useEffect(() => {
		const fetchChannels = async () => {
			const response = await fetch("/channels");
			const data = await response.json();
			setChannels(data || []);
		};
		fetchChannels();
	}, []);

	const handleAddChannel = async () => {
		const response = await fetch("/channels", {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({ name: newChannelName }),
		});

		if (response.ok) {
			const newChannel = await response.json();
			setChannels([...channels, { id: newChannel.id, name: newChannelName }]);
			setNewChannelName("");
		}
	};

	return (
		<div className="flex flex-col h-full bg-gray-100 border-r">
			<div className="bg-gray-700 text-white p-2">Channels</div>
			<div className="overflow-y-auto flex-grow p-4">
				{channels ? (
					<ul className="w-full">
						{channels.map((channel) => (
							<li
								key={channel.id}
								className={`p-2 rounded-md w-full cursor-pointer ${
									parseInt(channelId) === channel.id
										? "bg-blue-500 text-white"
										: "hover:bg-gray-200"
								}`}
								onClick={() => setSelectedChannel(channel)}
							>
								{channel.name}
							</li>
						))}
					</ul>
				) : (
					<div className="text-center text-gray-600">Please add a Channel</div>
				)}
			</div>
			<div className="flex flex-col p-4">
				<input
					type="text"
					value={newChannelName}
					onChange={(e) => setNewChannelName(e.target.value)}
					placeholder="New channel..."
					className="mb-4 p-2 w-full border rounded-md bg-white"
				/>
				<button
					onClick={handleAddChannel}
					className="p-2 bg-blue-500 text-white rounded-md"
				>
					Add Channel
				</button>
			</div>
		</div>
	);
};

export default ChannelsList;

When ChannelsList renders, it initializes the state for the list of channels and the name of a new channel.

It fetches the list of channels from the /channels endpoint and displays them. Users can click a channel to select it, which updates selectedChannel in the parent component.

Users can type a new channel name and click a button labeled Add Channel to send a POST request to /channels, which creates a new channel and adds it to the list.

MessagesPanel.js

You'll now add the MessagesPanel component, which lets users see the most recent messages in the chat channel they have selected:

import React, { useState, useEffect, useRef } from "react";
import MessageEntry from "./MessageEntry";

const MessagesPanel = ({ selectedChannel }) => {
	const [messages, setMessages] = useState([]);
	const lastMessageIdRef = useRef(null); // Keep track of the last message ID

	useEffect(() => {
		if (!selectedChannel) return;

		let isMounted = true; // flag to prevent state updates after unmount
		let intervalId = null;

		const fetchMessages = async () => {
			const response = await fetch(`/messages?channelID=${selectedChannel.id}`);
			const data = await response.json();
			if (isMounted) {
				let messageData = data || [];
				setMessages(messageData);
				lastMessageIdRef.current =
					messageData.length > 0
						? messageData[messageData.length - 1].id
						: null;
			}
		};

		fetchMessages();

		intervalId = setInterval(() => {
			if (lastMessageIdRef.current !== null) {
				fetch(
					`/messages?channelID=${selectedChannel.id}&lastMessageID=${lastMessageIdRef.current}`,
				)
					.then((response) => response.json())
					.then((newMessages) => {
						if (
							isMounted &&
							Array.isArray(newMessages) &&
							newMessages.length > 0
						) {
							setMessages((messages) => {
								const updatedMessages = [...messages, ...newMessages];
								lastMessageIdRef.current =
									updatedMessages[updatedMessages.length - 1].id;
								return updatedMessages;
							});
						}
					});
			}
		}, 5000); // Poll every 5 seconds

		return () => {
			isMounted = false; // prevent further state updates
			clearInterval(intervalId); // clear interval on unmount
		};
	}, [selectedChannel]);

	return (
		<div className="flex flex-col h-full">
			{selectedChannel && (
				<div className="bg-gray-700 text-white p-2">
					Messages for {selectedChannel.name}
				</div>
			)}
			<div
				className={`overflow-auto flex-grow ${
					selectedChannel && messages.length === 0
						? "flex items-center justify-center"
						: ""
				}`}
			>
				{selectedChannel ? (
					messages.length > 0 ? (
						messages.map((message) => (
							<div key={message.id} className="p-2 border-b">
								<strong>{message.user_name}</strong>: {message.text}
							</div>
						))
					) : (
						<div className="text-center text-gray-600">
							No messages yet! Why not send one?
						</div>
					)
				) : (
					<div className="p-2">Please select a channel</div>
				)}
			</div>
			{selectedChannel && (
				<MessageEntry
					selectedChannel={selectedChannel}
					onNewMessage={(message) => {
						lastMessageIdRef.current = message.id;
						setMessages([...messages, message]);
					}}
				/>
			)}
		</div>
	);
};

export default MessagesPanel;

When MessagesPanel renders, it initializes an empty array for messages. The component prompts the user to select a channel if no channel is selected.

If a channel is selected, the component fetches the channel's messages with a GET request to the /messages endpoint. It then maps through the fetched messages and displays each message with the username and text.

The component uses setInterval to check for new messages in the channel every five seconds.

Finally, it renders the MessageEntry component so users can write new messages to the channel.

MessageEntry.js

Finally, the MessageEntry component lets users enter new chat messages in the channel:

import React, { useState } from "react";

const MessageEntry = ({ selectedChannel, onNewMessage }) => {
	const [text, setText] = useState("");

	const handleSendMessage = async () => {
		const userID = localStorage.getItem("userId");
		const userName = localStorage.getItem("userName");

		const response = await fetch("/messages", {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({
				channel_id: parseInt(selectedChannel.id),
				user_id: parseInt(userID),
				text,
			}),
		});

		if (response.ok) {
			const message = await response.json();
			onNewMessage({
				id: message.id,
				channel_id: selectedChannel,
				user_id: userID,
				user_name: userName,
				text,
			});
			setText("");
		} else {
			alert("Failed to send message");
		}
	};

	const handleKeyDown = (event) => {
		if (event.key === "Enter" && !event.shiftKey) {
			handleSendMessage();
			event.preventDefault(); // Prevent the default behavior (newline)
		}
	};

	return (
		<div className="p-4 border-t flex">
			<input
				type="text"
				placeholder="Type a message..."
				value={text}
				onChange={(e) => setText(e.target.value)}
				onKeyDown={handleKeyDown}
				className="p-2 flex-grow border rounded-md mr-2"
			/>
			<button
				onClick={handleSendMessage}
				className="p-2 bg-blue-500 text-white rounded-md"
			>
				Send
			</button>
		</div>
	);
};

export default MessageEntry;

MessageEntry renders an input field so the user can enter a new message, as well as a Send button. The input value is bound to the text state.

On clicking the Send button, the handleSendMessage function is triggered. This function retrieves the user ID from local storage, then makes a POST request to the /messages endpoint with the selected channel ID, user ID, and text content as the payload.

If the message is sent successfully, the onNewMessage callback is invoked with the new message so the parent MessagesPanel component can render it, and the input field clears. An alert displays if the message fails to send.

Updating App.js

You need to update src/App.js to import the Login, CreateUser, and MainChat components and add them as routes:

import React from "react";
import { BrowserRouter as Router, Route, Routes } from "react-router-dom";
import Login from "./Login";
import CreateUser from "./CreateUser";
import MainChat from "./MainChat";

const App = () => {
	return (
		<Router>
			<Routes>
				<Route path="/create-user" element={<CreateUser />} />
				<Route path="/chat" element={<MainChat />} />
				<Route path="/chat/:channelId" element={<MainChat />} />
				<Route path="/" element={<Login />} />
			</Routes>
		</Router>
	);
};

export default App;

With these changes, the App component creates a router so the browser URL will match the page the user is on.

Updating index.js

Finally, update src/index.js to remove some create-react-app boilerplate:

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
	<React.StrictMode>
		<App />
	</React.StrictMode>,
);

And with that, all the React components for your chat app are complete.