diff --git a/backend/db/crud.py b/backend/db/crud.py index a78139b403d3c246eaf9d2ed95443c5e9a4bc016..82fa7c6e7c530ead5c848ce632d72f4c9da63000 100644 --- a/backend/db/crud.py +++ b/backend/db/crud.py @@ -184,7 +184,9 @@ def get_comments(place: str, page: int, db: Session): comments = db.query(models.Comments).order_by(models.Comments.published_at.desc(), models.Comments.id.desc()).all() else: comments = db.query(models.Comments, models.Users.username).join(models.Users).filter(models.Comments.place == place).order_by(models.Comments.published_at.desc(), models.Comments.id.desc()).slice((page - 1) * 20, page * 20).all() - return list(schemas.Comment(**comment.__dict__, username=username) for comment, username in comments) + comments_list = list(schemas.Comment(**comment.__dict__, username=username) for comment, username in comments) + comments_list.reverse() + return comments_list def create_comment(user: schemas.User, place: str, new_comments: schemas.CommentBase, db: Session): diff --git a/backend/main.py b/backend/main.py index c0ec2d235f673af74d2dada934a4785fbaef76d2..52115e318ec2fda92c8d44aab7bf4d17f4db3f50 100644 --- a/backend/main.py +++ b/backend/main.py @@ -40,6 +40,7 @@ app.include_router(stats.router) app.include_router(comments.router) app.include_router(news.router) app.include_router(authentication.router) +app.include_router(websocket.router) @app.get('/api/records', response_model=List[schemas.Record]) diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py index 92c71e361349ea060f8bf331df5f9155acd4706e..9788c31828a59ba4d992599c639a68fb7ece5e45 100644 --- a/backend/routers/__init__.py +++ b/backend/routers/__init__.py @@ -2,4 +2,5 @@ from . import authentication from . import comments from . import news from . import opening_hours -from . import stats \ No newline at end of file +from . import stats +from . import websocket \ No newline at end of file diff --git a/backend/routers/comments.py b/backend/routers/comments.py index 44033c9671aff30381ce830a132617c44378a63b..cdd859918f057d91800741f616988e5794496449 100644 --- a/backend/routers/comments.py +++ b/backend/routers/comments.py @@ -1,9 +1,11 @@ from fastapi import APIRouter, Body, Cookie, Depends from sqlalchemy.orm import Session from typing import List +import json from db import schemas, crud from db.database import get_db +from routers.websocket import manager router = APIRouter(prefix="/api", tags=["comments"]) @@ -18,7 +20,9 @@ async def get_comments(place: str, page: int = 1, db: Session = Depends(get_db)) async def create_comment(place: str, connect_id: str = Cookie(...), comment: schemas.CommentBase = Body(...), db: Session = Depends(get_db)): user = crud.get_user(connect_id, db) if user: - return crud.create_comment(user, place, comment, db) + saved_comment = crud.create_comment(user, place, comment, db) + await manager.broadcast(json.dumps(saved_comment.dict(), default=str)) + return saved_comment else: raise Exception diff --git a/backend/routers/websocket.py b/backend/routers/websocket.py new file mode 100644 index 0000000000000000000000000000000000000000..bbc383f9b68e6a86083296813e6c1068b805320c --- /dev/null +++ b/backend/routers/websocket.py @@ -0,0 +1,38 @@ +from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from typing import List + + +class ConnectionManager: + def __init__(self): + self.active_connections: List[WebSocket] = [] + + async def connect(self, websocket: WebSocket): + await websocket.accept() + self.active_connections.append(websocket) + + def disconnect(self, websocket: WebSocket): + self.active_connections.remove(websocket) + + async def send_personal_message(self, message: str, websocket: WebSocket): + await websocket.send_text(message) + + async def broadcast(self, message: str): + for connection in self.active_connections: + try: + await connection.send_text(message) + except WebSocketDisconnect: + manager.disconnect(connection) + + +manager = ConnectionManager() +router = APIRouter(tags=["websocket"]) + + +@router.websocket("/api/ws") +async def websocket_endpoint(websocket: WebSocket): + await manager.connect(websocket) + try: + while True: + await websocket.receive_text() + except WebSocketDisconnect: + manager.disconnect(websocket) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8f0ea8c00bba8e0219867b8c14478d42d4f63362..c531868cc5fc6a98934d6cff0dbd5c1e05bd2121 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22,6 +22,7 @@ "react-router-dom": "^6.3.0", "react-scripts": "5.0.1", "react-spring": "^9.4.5", + "react-use-websocket": "^4.2.0", "recharts": "^2.1.12" }, "devDependencies": { @@ -17521,6 +17522,15 @@ "react-dom": ">=16.6.0" } }, + "node_modules/react-use-websocket": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-4.2.0.tgz", + "integrity": "sha512-ZovaTlc/tWX6a590fi3kMWImhyoWj46BWJWvO5oucZJzRnVVhYtes2D9g+5MKXjSdR7Es3456hB89v4/1pcBKg==", + "peerDependencies": { + "react": ">= 18.0.0", + "react-dom": ">= 18.0.0" + } + }, "node_modules/react-zdog": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/react-zdog/-/react-zdog-1.0.11.tgz", @@ -34103,6 +34113,12 @@ "prop-types": "^15.6.2" } }, + "react-use-websocket": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-4.2.0.tgz", + "integrity": "sha512-ZovaTlc/tWX6a590fi3kMWImhyoWj46BWJWvO5oucZJzRnVVhYtes2D9g+5MKXjSdR7Es3456hB89v4/1pcBKg==", + "requires": {} + }, "react-zdog": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/react-zdog/-/react-zdog-1.0.11.tgz", diff --git a/frontend/package.json b/frontend/package.json index b186d81007259bf519b154be475885bdcec69e98..b2c0c09a105cc352576337ccc234bd6e941daadb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "react-router-dom": "^6.3.0", "react-scripts": "5.0.1", "react-spring": "^9.4.5", + "react-use-websocket": "^4.2.0", "recharts": "^2.1.12" }, "scripts": { diff --git a/frontend/src/components/Comments.js b/frontend/src/components/Comments.js index 2b93bcf04f8db3b753cee4c1fb3904a4058ec2d1..e0a52e00181886f8f4f91b7bec8a2fda5fead7fc 100644 --- a/frontend/src/components/Comments.js +++ b/frontend/src/components/Comments.js @@ -9,7 +9,7 @@ import { getSiblings } from "../utils"; import "../styles/Comments.css"; -export default function Messages({ place, infos }) { +export default function Messages({ place, infos, lastMessage }) { const [user] = useContext(User); const [messages, setMessages] = useState([]); const [newComment, setNewComment] = useState(""); @@ -30,13 +30,10 @@ export default function Messages({ place, infos }) { { withCredentials: true }, ) .then((res) => { - if (messages.length) { - let update = messages.map((_, index) => (index ? messages[index - 1] : res.data)); - update.push(messages[messages.length - 1]); - setMessages(update); - } else { - setMessages([res.data]); - } + setMessages((messages) => { + messages.push(res.data); + return messages; + }); updateValue(""); }) .catch((e) => { @@ -108,7 +105,7 @@ export default function Messages({ place, infos }) { let position = chat.current.scrollHeight - chat.current.clientHeight; chat.current.scrollTop = position; } - }, [chat.current]); + }, [chat.current, messages.length]); useEffect(() => { if (input.current) { @@ -117,6 +114,18 @@ export default function Messages({ place, infos }) { } }, [newComment]); + useEffect(() => { + if (lastMessage?.data) { + let new_message = JSON.parse(lastMessage.data); + if (new_message.username != user) { + setMessages((messages) => { + messages.push(new_message); + return messages; + }); + } + } + }, [lastMessage]); + return ( <div className="comments-side-bar"> <div className="comments-title"> @@ -148,11 +157,13 @@ export default function Messages({ place, infos }) { ) ) : ( messages.map((message, index) => { - let [date, hour] = message.published_at.split("T"); + let [date, hour] = message.published_at.split(/[T\s]/); let [year, month, day] = date.split("-"); return ( <div key={index} className="comment"> - <div className={`comment-title${infos ? "-infos" : ""}`}>{infos ? message.title : message.username}</div> + <div className={`comment-title${infos ? "-infos" : ""}`}> + {infos ? message.title : message.username} + </div> <div className="comment-content">{message.content}</div> <div className="comment-date"> {`À ${hour.substring(0, 5)} le ${day}/${month}/${year}`} diff --git a/frontend/src/index.js b/frontend/src/index.js index fae03242592ae3c43d94c0c78dd90c046f09c20d..c793ad7462c0020ac6ad20348c92f0186743150d 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -1,6 +1,7 @@ import React, { createContext, useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; import { BrowserRouter as Router, Routes, Route } from "react-router-dom"; +import useWebSocket from "react-use-websocket"; import axios from "axios"; import { Footer, Header } from "./components"; @@ -9,6 +10,7 @@ import { HomePage, RestaurantPage, NotFoundPage, DetailsPage } from "./views"; import "bootstrap/dist/css/bootstrap.min.css"; import "./styles/index.css"; +const socketUrl = `${process.env.REACT_APP_SOCKET_URL}/ws`; export const User = createContext(null); export default function App() { @@ -16,6 +18,9 @@ export default function App() { const [selection, setSelection] = useState(null); const [loading, setLoading] = useState(true); const [user, setUser] = useState(localStorage.getItem("user")); + const { lastMessage } = useWebSocket(socketUrl, { + shouldReconnect: () => true, + }); useEffect(() => { axios @@ -55,7 +60,7 @@ export default function App() { /> <Route path="/:restaurant" - element={<RestaurantPage {...{ selection, setSelection }} />} + element={<RestaurantPage {...{ selection, setSelection, lastMessage }} />} > <Route path="details" element={<DetailsPage selection={selection} />} /> </Route> diff --git a/frontend/src/styles/Comments.css b/frontend/src/styles/Comments.css index ffb1833dd575047db57d77acf6363966cd581a2b..1fdfc1e9098d2d040173f8b93c0f60601546ff3a 100644 --- a/frontend/src/styles/Comments.css +++ b/frontend/src/styles/Comments.css @@ -35,13 +35,12 @@ .comments-scroll-bar { flex: 1; display: flex; - flex-direction: column-reverse; + flex-direction: column; overflow-y: auto; } .infos-scroll-bar { direction: rtl; - flex-direction: column; } .comment { diff --git a/frontend/src/views/Restaurant.js b/frontend/src/views/Restaurant.js index 827eaf7e3aba8e3a13b34a54d620c6f48367bd12..5b0d73f7ce1cd7e907ce13a6164b21daa6af5486 100644 --- a/frontend/src/views/Restaurant.js +++ b/frontend/src/views/Restaurant.js @@ -4,17 +4,17 @@ import { Graph, WaitingTime, Comments } from "../components"; import "../styles/restaurant.css"; -export default function RestaurantPage({ selection }) { +export default function RestaurantPage({ selection, lastMessage }) { return ( <> {selection && ( <div className="restaurant-container"> <Comments place={selection.name} infos /> <div className="restaurant-container" id="restaurant-main-page"> - <WaitingTime place={selection.name} /> - <Graph place={selection.name} type="current" /> + <WaitingTime place={selection.name} lastMessage={lastMessage} /> + <Graph place={selection.name} type="current" lastMessage={lastMessage} /> </div> - <Comments place={selection.name} /> + <Comments place={selection.name} lastMessage={lastMessage} /> {/*<Graph place={selection.name} type="avg" />*/} </div> )}