diff --git a/client/App.css b/client/App.css new file mode 100644 index 000000000..e317a6850 --- /dev/null +++ b/client/App.css @@ -0,0 +1,34 @@ +.messages { + display: flex; + flex-direction: column-reverse; + min-height: 200px; + max-height: 200px; + overflow: scroll; +} + +.input { + display: flex; + gap: 24px; +} + +.inputs { + display: flex; + gap: 24px; +} + +.room { + display: flex; + gap: 8px; +} + +.roomsList { + display: flex; + flex-direction: column; + + gap: 8px; +} + +.roomName { + margin: 0; + margin-right: 16px; +} \ No newline at end of file diff --git a/client/App.tsx b/client/App.tsx new file mode 100644 index 000000000..c570843f4 --- /dev/null +++ b/client/App.tsx @@ -0,0 +1,259 @@ +import React, { useEffect, useState } from 'react'; +import './App.css'; +import Username from './Username'; + +type Message = { + id: number, + text: string, + author: string, + time: Date, + roomId: number, +} + +type Room = { + id: number, + name: string, +} + +const App: React.FC = () => { + const [messages, setMessages] = useState([]); + const [rooms, setRooms] = useState([]); + + const [currentRoom, setCurrentRoom] = useState(null); + + const [messageInputValue, setMessageInputValue] = useState(''); + const [roomNameInputValue, setRoomNameInputValue] = useState(''); + + const [roomRenameInputValue, setRoomRenameInputValue] = useState(''); + + const [username, setUsername] = + useState(localStorage.getItem('username') || ''); + + const [socket, setSocket] = useState(null); + + const handleMessageSend = () => { + if (!username || !currentRoom) return; + if (messageInputValue && socket) { + const message = { + text: messageInputValue, + time: new Date(), + author: username, + roomId: currentRoom, + }; + socket.send(JSON.stringify(message)); + setMessageInputValue(''); + } + }; + + const handleCreateRoom = () => { + if (!roomNameInputValue) return; + + fetch('http://localhost:3005/rooms', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name: roomNameInputValue }), + }) + .then(response => response.json()) + .then(data => setRooms(prev => [data, ...prev])) + .then(() => setRoomNameInputValue('')) + .catch(error => console.log(error)); + } + + const handleRenameRoom = () => { + if (!roomRenameInputValue) return; + + fetch(`http://localhost:3005/rooms/${currentRoom}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ name: roomRenameInputValue }), + }) + .then(response => response.json()) + .then(data => { + setRooms((prev) => + prev.map((room) => + room.id === data.id ? { ...room, name: data.name } : room + ) + ); + }) + .then(() => { + setRoomRenameInputValue(''); + }) + .catch(error => console.log(error)); + } + + const handleDeleteRoom = (id: number) => { + if (id === currentRoom) { + setCurrentRoom(null); + setMessages([]); + } + + fetch(`http://localhost:3005/messages/${id}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + } + }) + .then(() => { + setMessages(prev => prev.filter(message => message.roomId !== id)); + }) + .catch(error => console.log(error)); + + fetch(`http://localhost:3005/rooms/${id}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + } + }) + .then(() => { + setRooms(prev => prev.filter(room => room.id !== id)); + }) + .catch(error => console.log(error)); + + + } + + const handleJoinRoom = (id: number) => { + fetch(`http://localhost:3005/rooms/${id}/join`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + } + }) + .then(response => { + if (response.ok) { + return response.json(); + } else { + throw new Error('Room not found or join failed'); + } + }) + .then(data => { + console.log('Room join confirmed:', data.message); + setCurrentRoom(id); + + if (socket) { + socket.send(JSON.stringify({ + type: 'join', + roomId: id + })); + } + + return fetch(`http://localhost:3005/messages/${id}`); + }) + .then(response => response.json()) + .then(data => setMessages(data.reverse())) + } + + useEffect(() => { + const newSocket = new WebSocket('ws://localhost:3005'); + + newSocket.onmessage = (event) => { + const data = JSON.parse(event.data); + + if (data.type === 'history') { + setMessages(data.messages); + return; + } + + if (data.type === 'join-success' || data.type === 'join-error') return; + + setMessages(prev => [data, ...prev]); + }; + + setSocket(newSocket); + + if (currentRoom) { + newSocket.onopen = () => { + newSocket.send(JSON.stringify({ + type: 'join', + roomId: currentRoom + })); + }; + } + + return () => newSocket.close(); + }, [currentRoom]); + + useEffect(() => { + fetch('http://localhost:3005/rooms') + .then(response => response.json()) + .then(data => setRooms(data.reverse())) + .catch(err => console.log(err)) + }, []); + + return ( +
+ +
    + {messages.map(message => ( +
  • + {message.text} + {` - FROM ${message.author} AT `} + {new Date(message.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +
  • + ))} +
+
+ setMessageInputValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + handleMessageSend(); + } + }} + /> + +
+ +
+

Current room: {rooms.find(room => room.id === currentRoom)?.name}

+

Rooms:

+
+
+ setRoomNameInputValue(event?.target.value) + } + /> + +
+ +
+ setRoomRenameInputValue(event?.target.value) + } + /> + +
+
+ +
    + {rooms.map((room) => ( +
  • +

    {room.name}

    + + +
  • + ))} +
+
+
+ ); +} + +export default App; diff --git a/client/Username.tsx b/client/Username.tsx new file mode 100644 index 000000000..66573ac02 --- /dev/null +++ b/client/Username.tsx @@ -0,0 +1,47 @@ +import React, { useState } from 'react'; + +type Props = { + username: string; + setUsername: React.Dispatch>; + socket: WebSocket | null; +} +const Username: React.FC = ({ username, setUsername, socket }) => { + const [inputValue, setInputValue] = useState(''); + + function saveUsername() { + if (inputValue) { + setUsername(inputValue); + localStorage.setItem('username', inputValue); + + + if (socket) { + socket.send(JSON.stringify({ + type: 'set-username', + username: inputValue + })); + } + + setInputValue(''); + } + } + + return ( +
+ + setInputValue(e.target.value)} + placeholder="Enter your username" + /> + + + {username.length !== 0 && ( +
Your username: {username}
+ )} +
+ ); +}; + +export default Username; diff --git a/client/index.css b/client/index.css new file mode 100644 index 000000000..92080891a --- /dev/null +++ b/client/index.css @@ -0,0 +1,3 @@ +ul { + list-style: none; +} \ No newline at end of file diff --git a/client/index.html b/client/index.html new file mode 100644 index 000000000..f1114253e --- /dev/null +++ b/client/index.html @@ -0,0 +1,13 @@ + + + + + + + Chat + + +
+ + + diff --git a/client/main.tsx b/client/main.tsx new file mode 100644 index 000000000..c1954d427 --- /dev/null +++ b/client/main.tsx @@ -0,0 +1,11 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' +import React from 'react'; + +createRoot(document.getElementById('root')!).render( + + + +) diff --git a/client/tsconfig.app.json b/client/tsconfig.app.json new file mode 100644 index 000000000..227a6c672 --- /dev/null +++ b/client/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 000000000..1ffef600d --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/client/tsconfig.node.json b/client/tsconfig.node.json new file mode 100644 index 000000000..d7d419871 --- /dev/null +++ b/client/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["../client/vite.config.ts"] +} diff --git a/client/vite-env.d.ts b/client/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/client/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/client/vite.config.ts b/client/vite.config.ts new file mode 100644 index 000000000..e42d7a8a8 --- /dev/null +++ b/client/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + root: __dirname, + server: { + proxy: { + '/api': 'http://localhost:3005', + }, + }, +}); \ No newline at end of file diff --git a/src/README.md b/src/README.md new file mode 100644 index 000000000..d5e3c12de --- /dev/null +++ b/src/README.md @@ -0,0 +1,69 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/src/controllers/message.controller.js b/src/controllers/message.controller.js new file mode 100644 index 000000000..6e11bff7e --- /dev/null +++ b/src/controllers/message.controller.js @@ -0,0 +1,44 @@ +import { messageService } from '../services/message.service.js'; + +const getAll = async (req, res) => { + const messages = await messageService.getAll(); + + res.send(messages); +}; + +const getByRoom = async (req, res) => { + const messages = await messageService.getByRoom(req.params.roomId); + + res.send(messages); +}; + +const add = async (req, res) => { + try { + const messageData = req.body; + const newMessage = await messageService.add(messageData); + res.status(201).send(newMessage); + } catch (error) { + res.status(500).send({ error: error.message }); + } +}; + +const deleteByRoom = async (req, res) => { + try { + const { roomId } = req.params; + const deleted = await messageService.deleteByRoom(roomId); + if (deleted) { + res.sendStatus(200); + } else { + res.status(404).send({ error: 'Messages for this room not found' }); + } + } catch (error) { + res.status(500).send({ error: error.message }); + } +}; + +export const messageController = { + getAll, + getByRoom, + add, + deleteByRoom, +}; diff --git a/src/controllers/room.controller.js b/src/controllers/room.controller.js new file mode 100644 index 000000000..7b59a6fad --- /dev/null +++ b/src/controllers/room.controller.js @@ -0,0 +1,69 @@ +import { roomService } from '../services/room.service.js'; + +const getAll = async (req, res) => { + const rooms = await roomService.getAll(); + + res.send(rooms); +}; + +const add = async (req, res) => { + try { + const newRoom = await roomService.add(req.body); + res.status(201).send(newRoom); + } catch (error) { + res.status(500).send({ error: error.message }); + } +}; + +const rename = async (req, res) => { + try { + const renamedRoom = await roomService.rename(req.body.name, req.params.id); + if (!renamedRoom) { + return res.status(404).send({ error: 'Room not found' }); + } + res.status(200).send(renamedRoom); + } catch (error) { + res.status(500).send({ error: error.message }); + } +}; + +const deleteRoom = async (req, res) => { + try { + const { id } = req.params; + const deleted = await roomService.deleteRoom(id); + if (deleted) { + res.status(200).send({ message: 'Room deleted successfully' }); + } else { + res.status(404).send({ error: 'Room not found' }); + } + } catch (error) { + res.status(500).send({ error: error.message }); + } +}; + +const joinRoom = async (req, res) => { + try { + const { id } = req.params; + const rooms = await roomService.getAll(); + const room = rooms.find(r => r.id === parseInt(id)); + + if (!room) { + return res.status(404).send({ error: 'Room not found' }); + } + + res.status(200).send({ + message: 'Successfully joined room', + room: room + }); + } catch (error) { + res.status(500).send({ error: error.message }); + } +}; + +export const roomController = { + getAll, + add, + rename, + deleteRoom, + joinRoom, +}; diff --git a/src/index.js b/src/index.js index ad9a93a7c..1bfadc1a0 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,96 @@ -'use strict'; +import 'dotenv/config'; +import express from 'express'; +import cors from 'cors'; +import { messageRouter } from './routes/message.route.js'; +import { WebSocketServer } from 'ws'; +import { messageService } from './services/message.service.js'; +import { client } from './utils/db.js'; +import { roomRouter } from './routes/room.route.js'; +import { roomService } from './services/room.service.js'; + +const app = express(); +const PORT = process.env.PORT || 3005; + +await client.sync({ alter: true }); + +app.use(cors()); +app.use(express.json()); +app.use(messageRouter); +app.use(roomRouter); + +const server = app.listen(PORT); +const wss = new WebSocketServer({ server }); + +const connectionRooms = new Map(); + +function broadcastToRoom(message, roomId) { + wss.clients.forEach((cl) => { + if (cl.readyState === cl.OPEN && connectionRooms.get(cl) === roomId) { + cl.send(JSON.stringify(message)); + } + }); +} + +wss.on('connection', (connection) => { + connection.on('message', async (messageBuffer) => { + try { + const messageData = JSON.parse(messageBuffer.toString()); + + if (messageData.type === 'set-username') { + connection.username = messageData.username; + return; + } + + if (messageData.type === 'join') { + const id = messageData.roomId; + + try { + const rooms = await roomService.getAll(); + const roomExists = rooms.some(room => room.id === id); + + if (!roomExists) { + connection.send(JSON.stringify({ + type: 'join-error', + error: 'Room not found' + })); + return; + } + + connectionRooms.set(connection, id); + + connection.send(JSON.stringify({ + type: 'join-success', + roomId: id, + message: 'Successfully joined room' + })); + + const history = await messageService.getByRoom(id); + connection.send(JSON.stringify({ + type: 'history', + messages: history.reverse() + })); + } catch (error) { + connection.send(JSON.stringify({ + type: 'join-error', + error: 'Failed to join room' + })); + } + return; + } + + const newMessage = await messageService.add(messageData); + const roomId = connectionRooms.get(connection); + if (roomId) { + broadcastToRoom(newMessage, roomId); + } + } catch (error) { + connection.send( + JSON.stringify({ error: 'Invalid message format or server error.' }), + ); + } + }); + + connection.on('close', () => { + connectionRooms.delete(connection); + }); +}); diff --git a/src/models/Message.js b/src/models/Message.js new file mode 100644 index 000000000..f34361452 --- /dev/null +++ b/src/models/Message.js @@ -0,0 +1,37 @@ +import { DataTypes } from 'sequelize'; +import { client } from '../utils/db.js'; +import { Room } from './Room.js'; + +export const Message = client.define( + 'Message', + { + text: { + type: DataTypes.STRING, + allowNull: false, + }, + author: { + type: DataTypes.STRING, + allowNull: false, + }, + time: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + roomId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: Room, + key: 'id', + }, + }, + }, + { + tableName: 'messages', + timestamps: false, + }, +); + +Message.belongsTo(Room, { foreignKey: 'roomId' }); +Room.hasMany(Message, { foreignKey: 'roomId' }); diff --git a/src/models/Room.js b/src/models/Room.js new file mode 100644 index 000000000..a68da4ab9 --- /dev/null +++ b/src/models/Room.js @@ -0,0 +1,16 @@ +import { DataTypes } from 'sequelize'; +import { client } from '../utils/db.js'; + +export const Room = client.define( + 'Room', + { + name: { + type: DataTypes.STRING, + allowNull: false, + }, + }, + { + tableName: 'rooms', + timestamps: false, + }, +); diff --git a/src/routes/message.route.js b/src/routes/message.route.js new file mode 100644 index 000000000..a81c774cd --- /dev/null +++ b/src/routes/message.route.js @@ -0,0 +1,11 @@ +import { Router } from 'express'; +import { messageController } from '../controllers/message.controller.js'; + +const messageRouter = Router(); + +messageRouter.get('/messages', messageController.getAll); +messageRouter.get('/messages/:roomId', messageController.getByRoom); +messageRouter.post('/messages', messageController.add); +messageRouter.delete('/messages/:roomId', messageController.deleteByRoom); + +export { messageRouter }; diff --git a/src/routes/room.route.js b/src/routes/room.route.js new file mode 100644 index 000000000..3d78e96d0 --- /dev/null +++ b/src/routes/room.route.js @@ -0,0 +1,12 @@ +import { Router } from 'express'; +import { roomController } from '../controllers/room.controller.js'; + +const roomRouter = Router(); + +roomRouter.get('/rooms', roomController.getAll); +roomRouter.post('/rooms', roomController.add); +roomRouter.post('/rooms/:id/join', roomController.joinRoom); +roomRouter.patch('/rooms/:id', roomController.rename); +roomRouter.delete('/rooms/:id', roomController.deleteRoom); + +export { roomRouter }; diff --git a/src/services/message.service.js b/src/services/message.service.js new file mode 100644 index 000000000..5d90e2e25 --- /dev/null +++ b/src/services/message.service.js @@ -0,0 +1,34 @@ +import { Message } from '../models/Message.js'; + +const getAll = async () => { + const result = await Message.findAll(); + return result; +}; + +const getByRoom = async (roomId) => { + const result = await Message.findAll({ where: { roomId } }); + return result; +}; + +const add = async (message) => { + try { + const newMessage = await Message.create(message); + return newMessage; + } catch (error) { + throw new Error('Failed to add message: ' + error.message); + } +}; + +const deleteByRoom = async (roomId) => { + const deletedCount = await Message.destroy({ + where: { roomId }, + }); + return deletedCount > 0; +}; + +export const messageService = { + getAll, + getByRoom, + add, + deleteByRoom, +}; diff --git a/src/services/room.service.js b/src/services/room.service.js new file mode 100644 index 000000000..d62fe35c6 --- /dev/null +++ b/src/services/room.service.js @@ -0,0 +1,38 @@ +import { Room } from '../models/Room.js'; + +const getAll = async () => { + const result = await Room.findAll(); + return result; +}; + +const add = async (room) => { + try { + const newRoom = await Room.create(room); + return newRoom; + } catch (error) { + throw new Error('Failed to add room: ' + error.message); + } +}; + +const rename = async (name, id) => { + const [updatedCount] = await Room.update({ name }, { where: { id } }); + if (updatedCount > 0) { + const updatedRoom = await Room.findByPk(id); + return updatedRoom; + } + return null; +}; + +const deleteRoom = async (id) => { + const deletedCount = await Room.destroy({ + where: { id }, + }); + return deletedCount > 0; +}; + +export const roomService = { + getAll, + add, + deleteRoom, + rename, +}; diff --git a/src/utils/db.js b/src/utils/db.js new file mode 100644 index 000000000..59d5dc398 --- /dev/null +++ b/src/utils/db.js @@ -0,0 +1,10 @@ +import 'dotenv/config'; +import { Sequelize } from 'sequelize'; + +export const client = new Sequelize({ + host: process.env.DB_HOST, + username: process.env.DB_USER, + password: process.env.DB_PASSWORD, + database: process.env.DB_DATABASE, + dialect: 'postgres', +});