diff --git a/initdb/schema.sql b/initdb/schema.sql index dde59cf1175b9c54897f17ea8bfe88cfb7787162..06e9a7a1bb3473be60367488fceb419beea5f494 100644 --- a/initdb/schema.sql +++ b/initdb/schema.sql @@ -3,7 +3,7 @@ use engage; CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, - username VARCHAR(30) UNIQUE, +username VARCHAR(30) UNIQUE, email VARCHAR(50) UNIQUE, password VARCHAR(250), role VARCHAR(10), @@ -20,7 +20,7 @@ CREATE TABLE videos( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, fileName text NOT NULL, PRIMARY KEY(id), - FOREIGN KEY (creator_id) REFERENCES users(id) ON DELETE CASCADE + FOREIGN KEY (creator_id) REFERENCES users(id) ); CREATE TABLE comments ( @@ -50,6 +50,7 @@ CREATE TABLE reply_likes( FOREIGN KEY (reply_id) REFERENCES reply(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); + CREATE TABLE likes ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, @@ -66,4 +67,27 @@ CREATE TABLE video_views ( PRIMARY KEY(id), FOREIGN KEY (video_id) REFERENCES videos(id), FOREIGN KEY (user_id) REFERENCES users(id) +); + +CREATE TABLE IF NOT EXISTS comments ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + video_id INT NOT NULL, + comment TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(255) NOT NULL, + password VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL +); + +CREATE TABLE comment_likes( + id INT PRIMARY KEY AUTO_INCREMENT, + user_id INT NOT NULL, + comment_id INT NOT NULL, + FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); \ No newline at end of file diff --git a/login-server.js b/login-server.js index 7693b3899b421adaf7cfc8a330cd93c401330b81..504885777161630bfc5dee00cfdc8ff5a3a26118 100644 --- a/login-server.js +++ b/login-server.js @@ -630,6 +630,89 @@ app.get("/video-views/:fileName", (req, res) => { }); }); +app.get("/fetch-reply-liked", authenticateTokenGet, (req, res) => { + const user_id = req.user.userId; + const { reply_id } = req.query; + + const db = dbRequest(dbHost); + + const query = "SELECT * FROM reply_likes WHERE user_id = ? AND reply_id = ?"; + db.query(query, [user_id, reply_id], (err, results) => { + if (err) { + console.error("Database error:", err); + db.destroy(); + return res.status(500).json({ message: "Database error" }); + } + + db.destroy(); + return res.status(200).json({ liked: results.length > 0 }); + }); +}); + +// Updated like-video endpoint +app.post("/like-reply", authenticateTokenGet, (req, res) => { + const { fileName, reply_id } = req.body; + const userId = req.user.userId; + const db = dbRequest(dbHost); + + console.log("User ID:", userId); + + // Check if user already liked the reply + const checkLikeQuery = + "SELECT * FROM reply_likes WHERE user_id = ? AND reply_id = ?"; + db.query(checkLikeQuery, [userId, reply_id], (err, results) => { + if (err) { + console.error("Database error:", err); + db.destroy(); + return res.status(500).json({ message: "Database error" }); + } + + if (results.length > 0) { + // User already liked the video -> Unlike it + const unlikeQuery = + "DELETE FROM reply_likes WHERE user_id = ? AND reply_id = ?"; + db.query(unlikeQuery, [userId, reply_id], (err) => { + if (err) { + console.error("Database error:", err); + db.destroy(); + return res.status(500).json({ message: "Database error" }); + } + db.destroy(); + return res.status(200).json({ message: "Reply unliked successfully" }); + }); + } else { + // User hasn't liked the comment -> Like it + const likeQuery = + "INSERT INTO reply_likes (user_id, reply_id) VALUES (?, ?)"; + db.query(likeQuery, [userId, reply_id], (err) => { + if (err) { + console.error("Database error:", err); + db.destroy(); + return res.status(500).json({ message: "Database error" }); + } + db.destroy(); + return res.status(200).json({ message: "Reply liked successfully" }); + }); + } + }); +}); + +app.get("/reply-like-count", authenticateTokenGet, (req, res) => { + const { reply_id } = req.query; + const db = dbRequest(dbHost); + const query = + "SELECT COUNT(*) AS like_count FROM reply_likes WHERE reply_id = ?"; + db.query(query, [reply_id], (err, results) => { + db.destroy(); + if (err) { + console.error("Database error:", err); + + return res.status(500).json({ message: "Database error" }); + } + res.json({ like_count: results[0].like_count }); // Send response + }); +}); + // Anonymous version of record-view that doesn't require authentication app.post("/record-anonymous-view", (req, res) => { const db = dbRequest(dbHost); @@ -664,11 +747,48 @@ app.post("/record-anonymous-view", (req, res) => { }); }); +// addReply const +export const addReply = async (req, res) => { + const db = dbRequest(dbHost); + const { commentId, content } = req.body; + const userId = req.user.userId; + + if (!commentId || !content) { + db.destroy(); + return res + .status(400) + .json({ message: "Comment ID and content are required" }); + } + + try { + const addReplyQuery = + "INSERT INTO REPLY (creator_id, content, comment_id) VALUES (?, ?, ?)"; + db.query(addReplyQuery, [userId, content, commentId], (err, result) => { + if (err) { + console.error("Database error:", err); + db.destroy(); + return res.status(500).json({ message: "Database error" }); + } + + db.destroy(); + return res.status(201).json({ + message: "Reply added successfully", + replyId: result.insertId, + }); + }); + } catch (error) { + console.error("Error:", error.message); + db.destroy(); + return res.status(400).json({ message: error.message }); + } +}; + // Register routes app.post("/signup", signup); +app.post("/addReply", addReply); // app.post("/login", login); // Start the Server app.listen(port, () => { console.log(`Login Server is running at http://localhost:${port}`); -}); +}); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 96723452868f71daeadac39705cf2f6ffd4ccfbc..e0214aa5934bf4496f447ce404d046cd144288cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "@testing-library/user-event": "^14.6.1", "@types/react-swipeable": "^4.3.0", "axios": "^1.7.9", - "axios-mock-adapter": "^2.1.0", "bcryptjs": "^2.4.3", "concurrently": "^9.1.2", "cors": "^2.8.5", @@ -44,7 +43,6 @@ "react-swipeable": "^7.0.2", "routes": "^2.1.0", "save-dev": "^0.0.1-security", - "socket.io": "^4.8.1", "socket.io-client": "^4.8.1", "uuid": "^11.1.0", "vite-plugin-fs": "^1.1.0" @@ -60,7 +58,7 @@ "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", - "cross-env": "^7.0.3", + "axios-mock-adapter": "^2.1.0", "eslint": "^9.17.0", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.16", @@ -2258,15 +2256,6 @@ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "license": "MIT" }, - "node_modules/@types/cors": { - "version": "2.8.17", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", - "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -2969,6 +2958,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-2.1.0.tgz", "integrity": "sha512-AZUe4OjECGCNNssH8SOdtneiQELsqTsat3SQQCWLPjN436/H+L9AjWfV7bF+Zg/YL9cgbhrz5671hoh+Tbn98w==", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -2984,15 +2974,6 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, - "node_modules/base64id": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", - "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", - "license": "MIT", - "engines": { - "node": "^4.5.0 || >= 5.9" - } - }, "node_modules/bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", @@ -3593,25 +3574,6 @@ "node": ">= 0.10" } }, - "node_modules/cross-env": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", - "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.1" - }, - "bin": { - "cross-env": "src/bin/cross-env.js", - "cross-env-shell": "src/bin/cross-env-shell.js" - }, - "engines": { - "node": ">=10.14", - "npm": ">=6", - "yarn": ">=1" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3892,26 +3854,6 @@ "node": ">= 0.8" } }, - "node_modules/engine.io": { - "version": "6.6.4", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", - "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", - "license": "MIT", - "dependencies": { - "@types/cors": "^2.8.12", - "@types/node": ">=10.0.0", - "accepts": "~1.3.4", - "base64id": "2.0.0", - "cookie": "~0.7.2", - "cors": "~2.8.5", - "debug": "~4.3.1", - "engine.io-parser": "~5.2.1", - "ws": "~8.17.1" - }, - "engines": { - "node": ">=10.2.0" - } - }, "node_modules/engine.io-client": { "version": "6.6.3", "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", @@ -3972,53 +3914,6 @@ "node": ">=10.0.0" } }, - "node_modules/engine.io/node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/engine.io/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/engine.io/node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -4424,6 +4319,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -5148,6 +5044,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "dev": true, "funding": [ { "type": "github", @@ -7847,72 +7744,6 @@ "node": ">=10" } }, - "node_modules/socket.io": { - "version": "4.8.1", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", - "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.4", - "base64id": "~2.0.0", - "cors": "~2.8.5", - "debug": "~4.3.2", - "engine.io": "~6.6.0", - "socket.io-adapter": "~2.5.2", - "socket.io-parser": "~4.2.4" - }, - "engines": { - "node": ">=10.2.0" - } - }, - "node_modules/socket.io-adapter": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", - "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", - "license": "MIT", - "dependencies": { - "debug": "~4.3.4", - "ws": "~8.17.1" - } - }, - "node_modules/socket.io-adapter/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/socket.io-adapter/node_modules/ws": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", - "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/socket.io-client": { "version": "4.8.1", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", @@ -7975,23 +7806,6 @@ } } }, - "node_modules/socket.io/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index 2e651ee06926fd8f833bdab862d3469a2d0d65ab..943e12cb7c6b0baad0c0c32c7215301f2ac4726f 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ "@testing-library/user-event": "^14.6.1", "@types/react-swipeable": "^4.3.0", "axios": "^1.7.9", - "axios-mock-adapter": "^2.1.0", "bcryptjs": "^2.4.3", "concurrently": "^9.1.2", "cors": "^2.8.5", @@ -51,7 +50,6 @@ "react-swipeable": "^7.0.2", "routes": "^2.1.0", "save-dev": "^0.0.1-security", - "socket.io": "^4.8.1", "socket.io-client": "^4.8.1", "uuid": "^11.1.0", "vite-plugin-fs": "^1.1.0" @@ -67,7 +65,7 @@ "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", - "cross-env": "^7.0.3", + "axios-mock-adapter": "^2.1.0", "eslint": "^9.17.0", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.16", diff --git a/src/App.tsx b/src/App.tsx index 1fc1017f5e47952ee3ceeecdeac083ef3f9f5fac..1537b20bc2a9334d28633063b4a59994e2f639e9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,18 +1,13 @@ import "./styles/App.scss"; // Import global and App-specific styles - -import { BrowserRouter, Routes, Route } from "react-router-dom"; -// React Router for navigation between different pages (Home and User page) - +import { BrowserRouter, Routes, Route, useNavigate } from "react-router-dom"; import { useState, useEffect } from "react"; -// React hooks: useState (state), useEffect (side effects), useRef (persistent value) import Login from "./login.tsx"; import Signup from "./signup.tsx"; -// import Dashboard from "./Dashboard"; -import PrivateRoute from "./PrivateRoute"; // +import PrivateRoute from "./PrivateRoute"; import ResetPassword from "./resetPassword.tsx"; -import ReactPlayer from "react-player"; // Library for embedding and playing videos +import ReactPlayer from "react-player"; import User from "./User"; -import path from "path-browserify"; // Path library to work with file paths in the browser +import path from "path-browserify"; import Upload from "./upload.tsx"; import VerifyEmail from "./VerifyEmail.tsx"; import axios from "axios"; @@ -28,72 +23,51 @@ const videos = import.meta.glob("../media/*trans.mp4"); let uploadServer = "http://localhost:3001"; if (import.meta.env.VITE_UPLOAD_SERVER !== undefined) { - // console.log(import.meta.env.VITE_UPLOAD_SERVER); uploadServer = import.meta.env.VITE_UPLOAD_SERVER; } let loginServer = "http://localhost:8081"; - if (import.meta.env.VITE_LOGIN_SERVER !== undefined) { - // console.log(import.meta.env.VITE_UPLOAD_SERVER); loginServer = import.meta.env.VITE_LOGIN_SERVER; } // Asynchronously create an array of video paths from imported media folder async function createVideoArray() { - const vidPaths: Array<string | null> = []; // Array to hold video paths + const vidPaths: Array<string | null> = []; const dbPaths: Array<string> = []; try { const response = await axios.get(`${uploadServer}/video-list`); - // console.log(response.data); response.data.forEach((video: { fileName: string }) => { dbPaths.push(video.fileName); }); } catch (error) { console.error(`Error fetching video info:`, error); - return []; // Continue to the next video if the request fails + return []; } - - // console.log(dbPaths); - // Loop through all imported videos for (const videoKey of Object.keys(videos)) { - const ext = path.extname(videoKey).toLowerCase(); // Get the extension (e.g., .mp4) + const ext = path.extname(videoKey).toLowerCase(); if (ext === ".mp4") { const videoFileName: string = path.posix.basename(videoKey); - // console.log(videoFileName) - // console.log(dbPaths.includes(videoFileName)) if (dbPaths.includes(videoFileName)) { vidPaths.push(videoKey); } } - // console.log(vidPaths); } return vidPaths; } -//randomize the elements of an array + function randomizeArray(array: Array<string | null>) { let index = array.length; - - // While elements remain to shuffle while (index !== 0) { const randomIndex = Math.floor(Math.random() * index); - - // Shuffle the positions [array[index], array[randomIndex]] = [array[randomIndex], array[index]]; - - // Bring the index down by 1 index--; } } -// Create a randomized array of video paths const array: Array<string | null> = await createVideoArray(); randomizeArray(array); - -// Remove any undefined items (extra safety) const filteredArray = array.filter((item) => item !== undefined); -// let userChanged:boolean = false; - // Function to check if the auth token is expired function isTokenExpired(token: string) { try { @@ -114,13 +88,42 @@ if (token && isTokenExpired(token)) { } function Home() { - // Home Page Component - Displays random videos, "Next", "Engager", and "Download" buttons - - const initState = filteredArray.length < 2 ? 0 : 1; // Set initial video - - const [videoIndex, setVideoIndex] = useState(initState); // State for current video index + const initState = filteredArray.length < 2 ? 0 : 1; + const [videoIndex, setVideoIndex] = useState(initState); const [currentVideo, setCurrentVideo] = useState(""); - // const currentVideoRef = useRef(filteredArray[0] || ''); // Reference to the current video path + const [notification, setNotification] = useState(""); + const [comment, setComment] = useState(""); + + // Comment type now includes an id, username, comment text, created_at, and optional replies. + interface CommentType { + id: number; + username: string; + comment: string; + created_at: string; + replies?: ReplyType[]; + } + interface ReplyType { + id: number; + username: string; + reply: string; + created_at: string; + } + const [comments, setComments] = useState<CommentType[]>([]); + + // For reply functionality: + // - replyInputs holds reply text per comment. + // - replyVisible toggles showing the reply input field. + // - repliesVisible toggles showing/hiding the entire replies list. + const [replyInputs, setReplyInputs] = useState<{ [key: number]: string }>({}); + const [replyVisible, setReplyVisible] = useState<{ [key: number]: boolean }>( + {} + ); + const [repliesVisible, setRepliesVisible] = useState<{ + [key: number]: boolean; + }>({}); + + // The comment section is toggled by the COMMENT button. + // const [showComments, setShowComments] = useState(false); const [loggedIn, setLoggedIn] = useState(false); const [username, setUsername] = useState(""); @@ -130,6 +133,43 @@ function Home() { const [viewCount, setViewCount] = useState(0); const [viewRecorded, setViewRecorded] = useState(false); + const [replyLikeCount, setReplyLikeCount] = useState<{ + [key: number]: number; + }>({}); // Like counts are stored with replyId as keys + const [replyLiked, setReplyLiked] = useState<{ [key: number]: boolean }>({}); + + // const getReplyLikeCount = async (replyId: number): Promise<number | string> => { + // try { + // const response = await axios.get( + // `${loginServer}/reply-like-count/${replyId}` + // ); + + // // Update the state with the fetched like count + // setReplyLikeCount(prev => ({ + // ...prev, + // [replyId]: response.data.likeCount + // })); + + // // Return the like count immediately + // return response.data.likeCount; + // } catch (error) { + // console.error("Error fetching like count:", error); + + // // Return a fallback value in case of error + // return "Error"; + // } + // }; + + // // Function to toggle the like status for a specific reply + // const handleReplyLikeToggle = (replyId: number) => { + // setReplyLiked((prev) => ({ + // ...prev, + // [replyId]: !prev[replyId], // Toggle the like status for the reply + // })); + // }; + + const navigate = useNavigate(); + // current video use states const [currentVideoTitle, setCurrentVideoTitle] = useState(""); @@ -138,32 +178,81 @@ function Home() { const [currentVideoCreatorName, setCurrentVideoCreatorName] = useState(""); useEffect(() => { - // Immediately reset states when changing videos setLiked(false); setViewRecorded(false); - - // Set the current video setCurrentVideo(filteredArray[videoIndex] || ""); - - // Use a separate effect for fetching like data to ensure it runs AFTER the currentVideo is set }, [videoIndex]); - // Add a separate useEffect that depends on currentVideo useEffect(() => { - // Only fetch like data if there's a valid video if (currentVideo) { console.log("Video changed to:", currentVideo.split("/").pop()); getViewCount(); - // Only check if user has liked if they're logged in + if (loggedIn && userID) { + checkIfLiked(); + } + // Fetch comments for current video. + displayComments(); } }, [currentVideo]); - // Switch to the next video in the array + useEffect(() => { + const fetchReplyLikes = async () => { + if (!loggedIn || !comments.length) return; + + const token = localStorage.getItem("authToken"); + if (!token) return; + + const initialLikedState = { ...replyLiked }; // Preserve existing state + const initialLikeCountState = { ...replyLikeCount }; // Preserve existing state + + for (const comment of comments) { + if (Array.isArray(comment.replies)) { + for (const reply of comment.replies) { + // Only fetch for replies we don't already have data for + if (initialLikedState[reply.id] === undefined) { + try { + const likeStatusResponse = await axios.get( + `${loginServer}/fetch-reply-liked`, + { + params: { auth: token, reply_id: reply.id }, + } + ); + initialLikedState[reply.id] = likeStatusResponse.data.liked; + + const likeCountResponse = await axios.get( + `${loginServer}/reply-like-count`, + { + params: { reply_id: reply.id, auth: token }, + } + ); + initialLikeCountState[reply.id] = + likeCountResponse.data.like_count; + } catch (err) { + console.error( + `Error fetching data for reply ${reply.id}:`, + err + ); + initialLikedState[reply.id] = false; + initialLikeCountState[reply.id] = 0; + } + } + } + } + } + + setReplyLiked(initialLikedState); + setReplyLikeCount(initialLikeCountState); + }; + + fetchReplyLikes(); + }, [comments, loggedIn]); + const handleNext = () => { + // toggleComments(); setVideoIndex( (prevIndex) => (prevIndex + initState) % filteredArray.length ); - // console.log(videoIndex); + displayComments(); }; // const navigate = useNavigate(); // Hook to navigate to other pages @@ -174,14 +263,11 @@ function Home() { // navigate("/login"); // }; - // Function to get user info from API async function getUsername(userid: number) { let creatorName = ""; await axios .get(`${uploadServer}/user`, { - params: { - userID: userid, - }, + params: { userID: userid }, }) .then((response) => { creatorName = response.data.username; @@ -221,23 +307,15 @@ function Home() { } - // const token = localStorage.getItem("authToken"); - // useEffect(() => { - // setLoggedIn(!!token); - // }, []); - async function getLoggedInUserId() { const token = localStorage.getItem("authToken"); if (token) { try { const response = await axios.get(`${loginServer}/current-user-id`, { - params: { - auth: token ? token : "", - }, + params: { auth: token }, }); setUserID(response.data.userId); setLoggedIn(true); - // userChanged = true; return response.data.userId; } catch (error) { console.error("Error fetching user ID:", error); @@ -247,83 +325,132 @@ function Home() { return null; } } - - // const authButtons = async ()=>{ - // let button = ""; - // const userId = await getLoggedInUserId() - - // if (userId !== null) { - - // const username = await getUsername(userId); - // button = "<button className='control-button' onClick={() => navigate('/user')}" + username + " <i className='fa-solid fa-user'></i> </button>" - - // } else { - // button = "<button className='control-button' onClick={handleBackToLogin}>Log In <i className='fa solid fa-right-to-bracket'></i></button>" - // } - // const sanitizedHTML = DOMPurify.sanitize(button); - // return ( - // <div className="login-button-section" dangerouslySetInnerHTML={{ __html: sanitizedHTML }} /> - // ) - - // } - getLoggedInUserId(); async function assignUsername() { if (loggedIn) { - const username = await getUsername(userID); - setUsername(username); - // console.log(username); + const name = await getUsername(userID); + setUsername(name); } } assignUsername(); - async function getViewCount() { + async function getLikeCount() { try { const fileName = currentVideo.split("/").pop(); if (!fileName) { console.error("Error: fileName is missing."); return; } + const response = await axios.get( + `${loginServer}/video-likes-by-filename/${fileName}` + ); + setLikeCount(response.data.likeCount); + } catch (error) { + console.error("Error fetching like count:", error); + setLikeCount(0); + } + } + async function checkIfLiked() { + if (!loggedIn) { + setLiked(false); + return; + } + const token = localStorage.getItem("authToken"); + if (!token) { + setLiked(false); + return; + } + const fileName = currentVideo.split("/").pop(); + if (!fileName) { + setLiked(false); + return; + } + try { + console.log("Making API request to check like status for:", fileName); + const response = await axios.get(`${loginServer}/check-like-status`, { + params: { auth: token, fileName: fileName }, + }); + console.log("Like status response:", response.data); + setLiked(response.data.liked); + } catch (error) { + console.error("Error checking like status:", error); + setLiked(false); + } + } + + async function handleLike() { + if (!userID || !loggedIn) { + alert("You must be logged in to like videos."); + return; + } + const fileName = currentVideo.split("/").pop(); + if (!fileName) { + console.error("Error: fileName is missing."); + return; + } + const token = localStorage.getItem("authToken"); + if (!token) { + alert("Authentication error. Please log in again."); + setLoggedIn(false); + return; + } + try { + const response = await axios.post( + `${loginServer}/like-video`, + { fileName: fileName }, + { params: { auth: token } } + ); + if (response.data.message.includes("unliked")) { + setLiked(false); + setLikeCount((prev) => Math.max(0, prev - 1)); + } else { + setLiked(true); + setLikeCount((prev) => prev + 1); + } + } catch (error) { + console.error("Error liking/unliking video:", error); + alert("Failed to process like. Please try again."); + } + } + + async function getViewCount() { + try { + const fileName = currentVideo.split("/").pop(); + if (!fileName) { + console.error("Error: fileName is missing."); + return; + } const response = await axios.get( `${loginServer}/video-views/${fileName}` ); setViewCount(response.data.viewCount); } catch (error) { console.error("Error fetching view count:", error); - setViewCount(0); // Default to 0 if there's an error + setViewCount(0); } } async function recordView() { try { - if (viewRecorded) return; // Prevent multiple view records for the same video session - + if (viewRecorded) return; const fileName = currentVideo.split("/").pop(); if (!fileName) { console.error("Error: fileName is missing."); return; } - if (loggedIn) { const token = localStorage.getItem("authToken"); if (!token) return; - - // For logged-in users await axios.post( `${loginServer}/record-view`, { fileName }, - { - params: { auth: token }, - } + { params: { auth: token } } ); } else { - // For anonymous users await axios.post(`${loginServer}/record-anonymous-view`, { fileName }); } - - // Update view count locally after recording setViewCount((prev) => prev + 1); setViewRecorded(true); } catch (error) { @@ -331,14 +458,195 @@ function Home() { } } + async function handleReplyLike(reply_id:number) { + if (!userID || !loggedIn) { + alert("You must be logged in to like replies."); + return; + } + + const token = localStorage.getItem("authToken"); + if (!token) { + alert("Authentication error. Please log in again."); + setLoggedIn(false); + return; + } + + const fileName = currentVideo.split("/").pop(); + if (!fileName) { + console.error("Error: fileName is missing."); + return; + } + + try { + const response = await axios.post( + `${loginServer}/like-reply`, + { fileName, reply_id }, + { params: { auth: token } } + ); + + // Update both states atomically + setReplyLiked((prev) => { + const newState = { ...prev, [reply_id]: !prev[reply_id] }; + + // Update like count based on the new liked state + setReplyLikeCount((prevCounts) => { + const currentCount = prevCounts[reply_id] || 0; + return { + ...prevCounts, + [reply_id]: newState[reply_id] + ? currentCount + 1 + : Math.max(0, currentCount - 1), + }; + }); + + return newState; + }); + } catch (error) { + console.error("Error liking/unliking reply:", error); + alert("Failed to process like. Please try again."); + } + } + + // Toggle the comment section using the COMMENT button. + // const toggleComments = () => { + // setShowComments((prev) => !prev); + // if (!showComments) displayComments(); + // }; + + // Post a comment and refresh the comments list. + const postComment = async () => { + if (comment.trim() === "") return; + try { + const token = localStorage.getItem("authToken"); + const fileName = currentVideo.split("/").pop(); + const videoRes = await axios.get(`${uploadServer}/video`, { + params: { fileName }, + }); + if (!videoRes.data || !videoRes.data.id) { + setNotification("âš ï¸ Video not found."); + setTimeout(() => setNotification(""), 3000); + return; + } + const videoId = videoRes.data.id; + await axios.post( + `${uploadServer}/post-comment`, + { video_id: videoId, comment }, + { headers: { Authorization: token } } + ); + setComment(""); + setNotification("✅ Successfully commented!"); + setTimeout(() => setNotification(""), 3000); + } catch (error) { + console.error("Error posting comment:", error); + setNotification("âš ï¸ Failed to post comment."); + setTimeout(() => setNotification(""), 3000); + } + displayComments(); + }; + const handleVideoStart = () => { recordView(); }; + + // Fetch comments along with their replies. + async function displayComments() { + try { + const fileName = currentVideo.split("/").pop(); + if (!fileName) return; + const response = await axios.get(`${uploadServer}/get-comments`, { + params: { fileName }, + }); + const fetchedComments = response.data; + const commentsWithUsernames = await Promise.all( + fetchedComments.map(async (comment: any) => { + const userResponse = await axios.get(`${uploadServer}/user`, { + params: { userID: comment.user_id }, + }); + let replies: any[] = []; + try { + const repliesResponse = await axios.get( + `${uploadServer}/get-replies`, + { + params: { comment_id: comment.id }, + } + ); + replies = await Promise.all( + repliesResponse.data.map(async (reply: any) => { + const replyUserResponse = await axios.get( + `${uploadServer}/user`, + { + params: { userID: reply.creator_id }, + } + ); + return { + id: reply.id, + username: replyUserResponse.data.username, + reply: reply.content, + created_at: reply.created_at, + }; + }) + ); + } catch (e) { + console.error("Error fetching replies for comment", comment.id, e); + } + return { + id: comment.id, + username: userResponse.data.username, + comment: comment.content, + created_at: comment.created_at, + replies: replies, + }; + }) + ); + setComments(commentsWithUsernames); + } catch (error) { + console.error("Error fetching comments:", error); + } + } + + // Post a reply to a specific comment. + async function postReply(commentId: number) { + const replyText = replyInputs[commentId]; + if (!replyText || replyText.trim() === "") return; + try { + const token = localStorage.getItem("authToken"); + await axios.post( + `${uploadServer}/post-reply`, + { comment_id: commentId, reply: replyText }, + { headers: { Authorization: token } } + ); + setReplyInputs((prev) => ({ ...prev, [commentId]: "" })); + displayComments(); + } catch (error) { + console.error("Error posting reply:", error); + } + toggleReplyInput(commentId); + } + + // Toggle visibility of replies for a specific comment. + const toggleRepliesVisible = (commentId: number) => { + setRepliesVisible((prev) => ({ + ...prev, + [commentId]: !prev[commentId], + })); + }; + + // Toggle the reply input for a specific comment. + const toggleReplyInput = (commentId: number) => { + setReplyVisible((prev) => ({ + ...prev, + [commentId]: !prev[commentId], + })); + }; + useEffect(() => { if (currentVideo) { setVideoInfo(); } }, [currentVideo]); + + console.log("Reply Liked State Before Rendering:", replyLiked); + return ( <div className="app"> @@ -419,37 +727,173 @@ function Home() { <h3>Upload one to kick things off.</h3> </> )} - </div> - <div className="details-comments"> - - </div> - </div> + {/* Comment Section toggled by the COMMENT button */} + {/* {showComments && ( */} + <div + className="comment-section" + style={{ + position: "fixed", + bottom: "13%", + right: "28%", + background: "white", + padding: "10px", + borderRadius: "5px", + maxHeight: "40vh", + overflowY: "auto", + }} + > + <div className="comments-list"> + {comments.map((c) => ( + <div key={c.id} className="comment-box" style={{color:"black", textAlign: "left"}}> + <p> + <strong>{c.username}</strong> ({c.created_at}): {c.comment} + </p> + + <div style={{ display: "flex", gap: "5x" }}> + {/* Toggle button for showing/hiding replies using icons */} + {c.replies && c.replies.length > 0 && ( + <div style={{ width: "24px", textAlign: "left", color:"black" }}> + <button + onClick={() => toggleRepliesVisible(c.id)} + style={{ + border: "none", + background: "transparent", + cursor: "pointer", + }} + > + {repliesVisible[c.id] ? ( + <i + className="fa-solid fa-chevron-up" + style={{ fontSize: "1.2em", color: "#333" }} + ></i> + ) : ( + <i + className="fa-solid fa-chevron-down" + style={{ fontSize: "1.2em", color: "#333" }} + ></i> + )} + </button> + </div> + )} + + {loggedIn && ( + <div> + <button onClick={() => toggleReplyInput(c.id)}> + <i className="fa-regular fa-comments"></i> + </button> + {replyVisible[c.id] && ( + <div + style={{ + marginTop: "5px", + display: "flex", + alignItems: "center", + gap: "8px", + minHeight: "40px", + }} + > + <input + type="text" + value={replyInputs[c.id] || ""} + onChange={(e) => + setReplyInputs((prev) => ({ + ...prev, + [c.id]: e.target.value, + })) + } + placeholder="Write a reply..." + /> + <button onClick={() => postReply(c.id)}> + <i className="fa-regular fa-paper-plane"></i> + </button> + </div> + )} + </div> + )} + </div> + + {repliesVisible[c.id] && + c.replies && + c.replies.length > 0 && ( + <div style={{ marginLeft: "20px" }}> + {c.replies.map((r) => ( + <div> + <div> + <p key={r.id}> + <strong>{r.username}</strong> ({r.created_at}):{" "} + {r.reply} + </p> + </div> + <div + style={{ + display: "flex", + gap: "3px", + position: "relative", + top: "-10px", + marginBottom: "-10px", + }} + > + <button + onClick={() => handleReplyLike(r.id)} + style={{ + color: replyLiked[r.id] ? "red" : "black", + }} + > + <i className="fa-regular fa-thumbs-up"></i> + </button> + <div id={`like-count-${r.id}`}> + {replyLikeCount[r.id] !== undefined + ? replyLikeCount[r.id] + : ""} + </div>{" "} + {/* Unique ID for like count */} + </div> + </div> + ))} + </div> + )} + </div> + ))} + </div> + {loggedIn && ( + <div className="comment-input-div"> + <textarea + id="comment-input" + value={comment} + onChange={(e) => setComment(e.target.value)} + placeholder="Write a comment..." + ></textarea> + <button onClick={postComment}> + <i className="fa-solid fa-paper-plane"></i> + </button> + </div> + )} + </div> + {/* )} */} + + {notification && ( + <div + className="notification" + style={{ + position: "fixed", + bottom: "80px", + right: "20px", + background: "#28a745", + color: "white", + padding: "10px", + borderRadius: "5px", + }} + > + {notification} + </div> + )} </div> </div> + </div> + </div> ); } -// Main App Component - Sets up routing between Home and User page - function App() { - // const [userVideos, setUserVideos] = useState<string[]>([]); - - // useEffect(() => { - - // }, []); - - // return ( - // <Router> - // <Routes> - // {/* Home Page Route */} - // <Route path="/" element={<Home />} /> - // {/* User Page Route */} - // <Route path="/user" element={<User userVideos={userVideos} />} /> - // </Routes> - // </Router> - // ); - // } - return ( <BrowserRouter> <TopBar /> @@ -474,4 +918,4 @@ function App() { ); } -export default App; +export default App; \ No newline at end of file diff --git a/src/components/FileUploader.tsx b/src/components/FileUploader.tsx index c5688fffcc2db6a8dc9531362c29d3f3f90dea24..c67e3437b1a987d037bb69ebfe3bc20b365d7df5 100644 --- a/src/components/FileUploader.tsx +++ b/src/components/FileUploader.tsx @@ -237,4 +237,4 @@ export default function FileUploader() { `}</style> */} </div> ); -} +} \ No newline at end of file diff --git a/src/styles/App.scss b/src/styles/App.scss index 986c49a119739b8bd696dfb3fa1cc9793403b8d3..5bedfe5c71aba2dfc64c6a411d16acc38e418049 100644 --- a/src/styles/App.scss +++ b/src/styles/App.scss @@ -355,3 +355,34 @@ body { text-align: center; } } + +.comment-section{ + position:fixed; + max-width: 250px; + max-height: 400px; + bottom:13%; + right:28%; + background-color: white; + padding:10px; + overflow-y: scroll; +} + +.comment { + background-color: white; + padding-bottom: 20px; /* Padding inside each comment */ + border-bottom: 2px solid black; /* Optional: Separator */ +} + +.comment-box { + background-color: white; + padding-bottom: 15px; /* Padding inside each comment */ +} + +.reply-icon{ + margin-left: 20px; + cursor: pointer; +} + +.comment-input-div { + margin-top: 20px; +} \ No newline at end of file diff --git a/tests/commentSection.test.tsx b/tests/commentSection.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f87ffcbb17d8b01f069250878eb59988df53e041 --- /dev/null +++ b/tests/commentSection.test.tsx @@ -0,0 +1,107 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import axios from "axios"; +import AxiosMockAdapter from "axios-mock-adapter"; +import App from "../src/App"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import React from "react"; +import ReactPlayer from 'react-player'; +import '@testing-library/jest-dom'; + + +// Create an axios mock adapter instance. +const mock = new AxiosMockAdapter(axios); + +vi.mock('react-player', () => { + return { + __esModule: true, + default: () => <div>Mocked ReactPlayer</div>, + }; +}); + +describe("Comment Section Interactions", () => { + beforeEach(() => { + mock.reset(); + }); + + + it("Comment input field does not exist if user is not logged in", async () => { + + render(<App />); + + // Click the COMMENT button to reveal the comment section. + const commentButton = screen.getByRole("button", { name: /comment/i }); + fireEvent.click(commentButton); + + expect(screen.queryByPlaceholderText("Write a comment...")).toBeNull() + }); + + + it("Comment input field exists if user is logged in", async () => { + localStorage.setItem("authToken", "dummy.jwt.token"); + + mock.onGet("http://localhost:8081/current-user-id").reply(200, { userId: 1 }); + + // --- Mock backend responses --- + mock.onGet("http://localhost:3001/video-list").reply(200, [ + { fileName: "video.mp4" } + ]); + mock.onGet("http://localhost:3001/get-comments").reply(200, [ + { + id: 1, + user_id: 1, + content: "Test comment", + created_at: "2025-01-01", + likeCount: 0, + liked: 0, + replies: [] + } + ]); + mock.onGet("http://localhost:3001/user").reply(200, { id: 1, username: "TestUser" }); + + // Render the App with the loggedIn state set to true + render(<App />); + + // Click the COMMENT button to reveal the comment section. + const commentButton = screen.getByRole("button", { name: /comment/i }); + fireEvent.click(commentButton); + + // Wait until the comment section is visible (by checking for the comment input). + await waitFor(() => { + expect(screen.getByPlaceholderText("Write a comment...")).toBeInTheDocument(); + }); + }); + + // it("submits a new comment successfully", async () => { + // mock.onPost("/api/comments").reply(201, { + // id: 2, + // username: "User2", + // created_at: "2025-03-18", + // comment: "New comment", + // }); + + // render(<App />); + + // // Simulate login + // const loginButton = screen.queryByText(/log in/i); + // if (loginButton) { + // fireEvent.click(loginButton); + // } + + // // Open comment section + // const commentButton = screen.getByRole("button", { name: /comment/i }); + // fireEvent.click(commentButton); + + // // Enter a comment + // const input = screen.getByPlaceholderText("Write a comment..."); + // fireEvent.change(input, { target: { value: "New comment" } }); + + // // Submit comment + // const submitButton = screen.getByRole("button", { name: /send/i }); + // fireEvent.click(submitButton); + + // // Wait for the comment to appear + // await waitFor(() => { + // expect(screen.getByText("New comment")).toBeInTheDocument(); + // }); + // }); +}); diff --git a/tests/login.test.tsx b/tests/login.test.tsx deleted file mode 100644 index 2d9e225e99fed2513456a26551ec52213ad1c2f8..0000000000000000000000000000000000000000 --- a/tests/login.test.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, it, expect } from "vitest"; -import "@testing-library/jest-dom"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { BrowserRouter } from "react-router-dom"; // Import BrowserRouter -import Login from "../src/login.tsx"; - -describe("Login Component", () => { - // Test case 1: Empty email and password - it("should display validation errors for empty email and password", () => { - // Step 1: Render the component wrapped in BrowserRouter - render( - <BrowserRouter> - <Login /> - </BrowserRouter> - ); - - // Step 2: Simulate form submission without filling in the fields - const loginButton = screen.getByRole("button", { name: /login/i }); - fireEvent.click(loginButton); - - // Step 3: Check for email validation error - const emailError = screen.getByText(/email is required/i); - expect(emailError).toBeInTheDocument(); - - // Step 4: Check for password validation error - const passwordError = screen.getByText(/password is required/i); - expect(passwordError).toBeInTheDocument(); - }); - // Test case 2: Invalid password format - it("should display validation error for invalid password format", () => { - render( - <BrowserRouter> - <Login /> - </BrowserRouter> - ); - - // Enter an invalid password - const passwordInput = screen.getByPlaceholderText(/enter password/i); - fireEvent.change(passwordInput, { target: { value: "weak" } }); - - // Simulate form submission - const loginButton = screen.getByRole("button", { name: /login/i }); - fireEvent.click(loginButton); - - // Check for password validation error - const passwordError = screen.getByText(/password is invalid/i); - expect(passwordError).toBeInTheDocument(); - }); - // Test case 3: Valid email and password - it("should not display validation errors for valid email and password", () => { - render( - <BrowserRouter> - <Login /> - </BrowserRouter> - ); - - // Enter a valid email - const uesrnameOrEmail = screen.getByPlaceholderText(/Enter Username OR Email/i); - fireEvent.change(uesrnameOrEmail, { target: { value: "valid@example.com" } }); - - // Enter a valid password - const passwordInput = screen.getByPlaceholderText(/enter password/i); - fireEvent.change(passwordInput, { target: { value: "StrongPassword1@" } }); - - // Simulate form submission - const loginButton = screen.getByRole("button", { name: /login/i }); - fireEvent.click(loginButton); - - // Check that no validation errors are displayed - const emailError = screen.queryByText(/email is required/i); - expect(emailError).not.toBeInTheDocument(); - - const passwordError = screen.queryByText(/password is required/i); - expect(passwordError).not.toBeInTheDocument(); - }); -}); diff --git a/tests/react-player.d.ts b/tests/react-player.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..21206d76bad39f4ac15dd98984d07ed810486d2f --- /dev/null +++ b/tests/react-player.d.ts @@ -0,0 +1,4 @@ +declare module 'react-player' { + const ReactPlayer: any; + export default ReactPlayer; + } \ No newline at end of file diff --git a/tests/signup.test.tsx b/tests/signup.test.tsx deleted file mode 100644 index 1176b624e2961d567fadee62dbe46b76aadf65da..0000000000000000000000000000000000000000 --- a/tests/signup.test.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import { describe, it, expect } from "vitest"; -import "@testing-library/jest-dom"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { BrowserRouter } from "react-router-dom"; // Import BrowserRouter -import Signup from "../src/signup"; // Adjust path if necessary - -describe("Signup Component", () => { - // Test case 1: Empty name - it("should display validation error for missing name", () => { - render( - <BrowserRouter> - <Signup /> - </BrowserRouter> - ); - - // Simulate checking the "I agree to the Terms" checkbox to enable the submit button - const checkbox = screen.getByLabelText( - /i agree to the terms and conditions/i - ); - fireEvent.click(checkbox); - - // Simulate form submission without entering a name - const submitButton = screen.getByRole("button", { name: /sign up/i }); - fireEvent.click(submitButton); - - // Check for name validation error - const nameError = screen.getByText(/name is required/i); - expect(nameError).toBeInTheDocument(); - }); - // Test case 2: Invalid password format - it("should display validation error for password not meeting the requirements", () => { - render( - <BrowserRouter> - <Signup /> - </BrowserRouter> - ); - - // Simulate checking the checkbox to enable the submit button - const checkbox = screen.getByLabelText( - /i agree to the terms and conditions/i - ); - fireEvent.click(checkbox); - - // Enter a password that doesn't meet the requirements - const passwordInput = screen.getByPlaceholderText(/enter password/i); - fireEvent.change(passwordInput, { target: { value: "password" } }); - - // Simulate form submission - const submitButton = screen.getByRole("button", { name: /sign up/i }); - fireEvent.click(submitButton); - - // Check for password validation error - const passwordError = screen.getByText( - /password must be at least 8 characters long/i - ); - expect(passwordError).toBeInTheDocument(); - }); - // Test case 3: Passwords do not match - it("should display validation error when passwords do not match", () => { - render( - <BrowserRouter> - <Signup /> - </BrowserRouter> - ); - - // Simulate checking the checkbox to enable the submit button - const checkbox = screen.getByLabelText( - /i agree to the terms and conditions/i - ); - fireEvent.click(checkbox); - - // Enter different passwords in the password and confirm password fields - const passwordInput = screen.getByPlaceholderText(/enter password/i); - fireEvent.change(passwordInput, { target: { value: "password123!" } }); - - const confirmPasswordInput = - screen.getByPlaceholderText(/confirm password/i); - fireEvent.change(confirmPasswordInput, { - target: { value: "differentPassword123!" }, - }); - - // Simulate form submission - const submitButton = screen.getByRole("button", { name: /sign up/i }); - fireEvent.click(submitButton); - - // Check for password mismatch error - const confirmPasswordError = screen.getByText(/passwords do not match/i); - expect(confirmPasswordError).toBeInTheDocument(); - }); - // Test case 4: Disabled submit button - it("should disable submit button if terms and conditions are not checked", () => { - render( - <BrowserRouter> - <Signup /> - </BrowserRouter> - ); - - // Simulate form submission with terms unchecked - const submitButton = screen.getByRole("button", { name: /sign up/i }); - expect(submitButton).toBeDisabled(); - }); - // Test case 5: Enabled submit button - it("should enable submit button if terms and conditions are checked", () => { - render( - <BrowserRouter> - <Signup /> - </BrowserRouter> - ); - - // Check the checkbox to agree to terms - const agreeToTermsCheckbox = screen.getByLabelText( - /i agree to the terms and conditions/i - ); - fireEvent.click(agreeToTermsCheckbox); - - // Now the submit button should be enabled - const submitButton = screen.getByRole("button", { name: /sign up/i }); - expect(submitButton).toBeEnabled(); - }); -}); diff --git a/tsconfig.app.json b/tsconfig.app.json index 358ca9ba93f089b0133f05933f133a446402eb17..d1067e7731cfc5a4ba7328c288f5488a9c7c17b5 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -22,5 +22,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src"] + "include": ["src", "tests/react-player.d.ts"] } diff --git a/upload-server.js b/upload-server.js index ae2cf910d0393589a812c732c454f841b9e1040f..aea6657b19dc0eeb9e5a319cd529d96c4ae84a25 100644 --- a/upload-server.js +++ b/upload-server.js @@ -6,19 +6,9 @@ import cors from "cors"; import jwt from "jsonwebtoken"; import fs from "fs"; import child_process from "child_process"; -import { Server } from "socket.io"; -import http from "http"; const app = express(); -const server = http.createServer(app); -const io = new Server(server, { - cors: { - origin: "*", // In production, restrict this to your front-end domain - methods: ["GET", "POST"], - }, -}); const port = 3001; - const { spawn } = child_process; let dbHost = "localhost"; @@ -26,11 +16,9 @@ if (process.env.DATABASE_HOST) { dbHost = process.env.DATABASE_HOST; } -app.use(express.json()); -app.use(cors()); - -import dbRequest from "./db.js"; +app.use(express.json()); // Parse JSON bodies +// Enable CORS for your React app (localhost:5173) – for dev, using wildcard. app.use( cors({ origin: "*", @@ -39,6 +27,8 @@ app.use( }) ); +import dbRequest from "./db.js"; + // Set up multer storage configuration const storage = multer.diskStorage({ destination: (req, file, cb) => { @@ -48,7 +38,6 @@ const storage = multer.diskStorage({ cb(null, Date.now() + path.extname(file.originalname)); }, }); - const upload = multer({ storage: storage }); // Middleware to authenticate JWT @@ -58,211 +47,110 @@ const authenticateToken = (req, res, next) => { if (!token) { return res.status(401).json({ message: "Unauthorized: No token provided" }); } - jwt.verify(token, "secretkey", (err, decoded) => { if (err) { return res.status(403).json({ message: "Invalid or expired token" }); } - req.user = decoded; // Attach decoded user info to request + req.user = decoded; next(); }); }; -// Socket connection handler -io.on("connection", (socket) => { - console.log("Client connected:", socket.id); - - socket.on("disconnect", () => { - console.log("Client disconnected:", socket.id); - }); -}); +// ------------------------------ +// VIDEO UPLOAD & RELATED ENDPOINTS +// ------------------------------ // Upload video with authentication app.post("/upload", authenticateToken, upload.single("file"), (req, res) => { + const filePath = path.join("./media", req.file.filename); + const outputPath = filePath.replace(".mp4", "trans.mp4"); + const outputFile = req.file.filename.replace(".mp4", "trans.mp4"); + if (!req.file) { return res.status(400).json({ message: "No file uploaded" }); } const { title, description } = req.body; const creatorId = req.user.userId; - const sessionId = req.body.sessionId || "unknown"; // Client should send a sessionId to identify the connection - if (!creatorId) { - deleteFile(req.file.path); + fs.unlink(filePath, (err) => { + if (err) console.error("Error deleting file: ", err); + else console.log("File deleted successfully"); + }); return res.status(400).json({ message: "Invalid creator ID" }); } if (!title) { - deleteFile(req.file.path); + fs.unlink(filePath, (err) => { + if (err) console.error("Error deleting file: ", err); + else console.log("File deleted successfully"); + }); return res .status(400) .json({ message: "Title and description are required" }); } - const filePath = path.join("./media", req.file.filename); - const outputPath = filePath.replace(".mp4", "trans.mp4"); - const outputFile = req.file.filename.replace(".mp4", "trans.mp4"); - - // Get duration of the video to calculate progress - const ffprobe = spawn("ffprobe", [ - "-v", - "error", - "-show_entries", - "format=duration", - "-of", - "default=noprint_wrappers=1:nokey=1", + // Transcode media with ffmpeg + const ffmpeg = spawn("ffmpeg", [ + "-i", filePath, + "-c:v", + "libx264", + "-preset", + "slow", + "-crf", + "22", + "-c:a", + "copy", + outputPath, ]); - - let duration = 0; - ffprobe.stdout.on("data", (data) => { - duration = parseFloat(data.toString().trim()); - console.log(`Video duration: ${duration} seconds`); + ffmpeg.stderr.on("data", (data) => { + console.error(`stderr: ${data}`); }); - - ffprobe.on("close", (code) => { + ffmpeg.on("close", (code) => { + console.log(code); if (code !== 0) { - console.log("Could not determine video duration"); - // Continue with transcoding anyway - } - - // Now start the transcoding process - const ffmpeg = spawn("ffmpeg", [ - "-i", - filePath, - "-c:v", - "libx264", - "-preset", - "slow", - "-crf", - "22", - "-c:a", - "copy", - "-progress", - "pipe:1", // Output progress information to stdout - outputPath, - ]); - - let lastProgress = 0; - - // Extract progress information from ffmpeg output - ffmpeg.stdout.on("data", (data) => { - const output = data.toString(); - const timeMatch = output.match(/time=(\d+:\d+:\d+\.\d+)/); - - if (timeMatch && duration > 0) { - const timeStr = timeMatch[1]; - const [hours, minutes, seconds] = timeStr.split(":").map(parseFloat); - const currentTime = hours * 3600 + minutes * 60 + seconds; - const progressPercent = Math.min( - Math.round((currentTime / duration) * 100), - 99 - ); - - if (progressPercent > lastProgress) { - lastProgress = progressPercent; - io.emit("transcode-progress", { - sessionId: sessionId, - progress: progressPercent, - }); - console.log(`Transcoding progress: ${progressPercent}%`); - } - } - }); - - ffmpeg.stderr.on("data", (data) => { - // ffmpeg outputs detailed information to stderr - const output = data.toString(); - console.log(`ffmpeg stderr: ${output}`); - - // Parse progress from stderr if needed (as alternative to stdout progress) - const frameMatch = output.match(/frame=\s*(\d+)/); - const fpsMatch = output.match(/fps=\s*(\d+)/); - const timeMatch = output.match(/time=\s*(\d+:\d+:\d+\.\d+)/); - - if (timeMatch && duration > 0) { - const timeStr = timeMatch[1]; - const [hours, minutes, seconds] = timeStr.split(":").map(parseFloat); - const currentTime = hours * 3600 + minutes * 60 + seconds; - const progressPercent = Math.min( - Math.round((currentTime / duration) * 100), - 99 - ); - - if (progressPercent > lastProgress) { - lastProgress = progressPercent; - io.emit("transcode-progress", { - sessionId: sessionId, - progress: progressPercent, - }); - } - } - }); - - ffmpeg.on("close", (code) => { - console.log(`Transcoding process exited with code ${code}`); - io.emit("transcode-progress", { - sessionId: sessionId, - progress: 100, - complete: true, + fs.unlink(filePath, (err) => { + if (err) console.error("Error deleting file: ", err); }); - - if (code === 0) { - // Transcoding successful - now insert into database - const db = dbRequest(dbHost); - const insertQuery = - "INSERT INTO videos (creator_id, title, description, fileName) VALUES (?, ?, ?, ?)"; - - db.query( - insertQuery, - [creatorId, title, description, outputFile], - (err, result) => { - // Delete original file regardless of DB outcome - deleteFile(filePath); - - if (err) { - console.error("Error inserting video into database: ", err); - // If DB insertion fails, also delete the transcoded file - deleteFile(outputPath); - db.destroy(); - return res - .status(500) - .json({ message: "Database error", error: err }); - } - - console.log("Insert result:", result); - db.destroy(); - return res.status(200).json({ - message: "File uploaded and transcoded successfully!", - videoId: result.insertId, - }); - } - ); - } else { - // Transcoding failed - clean up files and return error - deleteFile(filePath); - deleteFile(outputPath); - return res.status(400).json({ message: "Transcoding failed" }); - } + fs.unlink(outputPath, (err) => { + if (err) console.error("Error deleting file: ", err); + }); + return res.status(400).json({ message: "Transcoding failed" }); + } + fs.unlink(filePath, (err) => { + if (err) console.error("Error deleting file: ", err); + else console.log("File deleted successfully"); }); }); -}); -function deleteFile(filePath) { - fs.unlink(filePath, (err) => { - if (err) { - console.error(`Error deleting file ${filePath}: `, err); - } else { - console.log(`File ${filePath} deleted successfully`); + const db = dbRequest(dbHost); + const insertQuery = + "INSERT INTO videos (creator_id, title, description, fileName) VALUES (?, ?, ?, ?)"; + db.query( + insertQuery, + [creatorId, title, description, outputFile], + (err, result) => { + if (err) { + fs.unlink(filePath, (err) => { + if (err) console.error("Error deleting file: ", err); + else console.log("File deleted successfully"); + }); + console.error("Error inserting video into database: ", err); + db.destroy(); + return res.status(500).json({ message: "Database error", error: err }); + } + console.log("Insert result:", result); + db.destroy(); + return res.status(200).json({ message: "File uploaded successfully!" }); } - }); -} + ); +}); // Get user info app.get("/user", (req, res) => { const db = dbRequest(dbHost); const { userID: userid } = req.query; - if (!userid) { return res.status(400).json({ message: "UserID is required" }); } @@ -273,7 +161,6 @@ app.get("/user", (req, res) => { db.destroy(); return res.status(500).json({ message: "Database error", error: err }); } - if (results.length === 0) { db.destroy(); return res.status(404).json({ message: "User not found" }); @@ -283,16 +170,14 @@ app.get("/user", (req, res) => { }); }); -// Get Video info +// Get video info app.get("/video", (req, res) => { const db = dbRequest(dbHost); const { fileName: filename } = req.query; - if (!filename) { db.destroy(); return res.status(400).json({ message: "Filename is required" }); } - const selectQuery = "SELECT * FROM videos WHERE fileName = ?"; db.query(selectQuery, [filename], (err, results) => { if (err) { @@ -300,7 +185,6 @@ app.get("/video", (req, res) => { db.destroy(); return res.status(500).json({ message: "Database error", error: err }); } - if (results.length === 0) { db.destroy(); return res.status(404).json({ message: "Video not found" }); @@ -310,7 +194,7 @@ app.get("/video", (req, res) => { }); }); -// Get Video info +// Get video list app.get("/video-list", (req, res) => { const db = dbRequest(dbHost); const selectQuery = "SELECT fileName FROM videos"; @@ -320,7 +204,6 @@ app.get("/video-list", (req, res) => { db.destroy(); return res.status(500).json({ message: "Database error", error: err }); } - if (results.length === 0) { db.destroy(); return res.status(404).json({ message: "Video not found" }); @@ -330,7 +213,105 @@ app.get("/video-list", (req, res) => { }); }); -// Use server.listen instead of app.listen to enable socket.io -server.listen(port, () => { - console.log(`Upload Server is running at http://localhost:${port}`); +// ------------------------------ +// COMMENT & REPLY ENDPOINTS +// ------------------------------ + +// Post a comment (requires authentication) +app.post("/post-comment", authenticateToken, async (req, res) => { + const db = dbRequest(dbHost); + const { video_id, comment } = req.body; + const userId = req.user.userId; + console.log("Received Comment Request:"); + console.log("User ID:", userId); + console.log("Video ID:", video_id); + console.log("Comment:", comment); + if (!video_id || !comment) { + db.destroy(); + return res.status(400).json({ message: "Video ID and comment are required" }); + } + try { + const insertQuery = "INSERT INTO comments (user_id, video_id, content) VALUES (?, ?, ?)"; + await db.promise().query(insertQuery, [userId, video_id, comment]); + console.log("Comment successfully stored in database!"); + db.destroy(); + return res.status(200).json({ message: "Comment posted successfully!" }); + } catch (error) { + console.error("Error inserting comment:", error); + db.destroy(); + return res.status(500).json({ message: "Database error", error }); + } +}); + +// Get comments for a video +app.get("/get-comments", async (req, res) => { + const db = dbRequest(dbHost); + const { fileName } = req.query; + if (!fileName) { + db.destroy(); + return res.status(400).json({ message: "File name is required" }); + } + try { + const videoQuery = "SELECT id FROM videos WHERE fileName = ?"; + const [videoResult] = await db.promise().query(videoQuery, [fileName]); + if (videoResult.length === 0) { + db.destroy(); + return res.status(404).json({ message: "Video not found" }); + } + const videoId = videoResult[0].id; + const selectQuery = "SELECT * FROM comments WHERE video_id = ?"; + const [results] = await db.promise().query(selectQuery, [videoId]); + return res.status(200).json(results); + } catch (error) { + console.error("Error fetching comments: ", error); + return res.status(500).json({ message: "Database error", error }); + } finally { + db.destroy(); + } }); + +// Post a reply to a comment (requires authentication) +app.post("/post-reply", authenticateToken, async (req, res) => { + const db = dbRequest(dbHost); + const { comment_id, reply } = req.body; + const userId = req.user.userId; + if (!comment_id || !reply) { + db.destroy(); + return res.status(400).json({ message: "Comment ID and reply content are required" }); + } + try { + const insertQuery = "INSERT INTO reply (creator_id, content, comment_id) VALUES (?, ?, ?)"; + await db.promise().query(insertQuery, [userId, reply, comment_id]); + db.destroy(); + return res.status(200).json({ message: "Reply posted successfully!" }); + } catch (error) { + console.error("Error inserting reply:", error); + db.destroy(); + return res.status(500).json({ message: "Database error", error }); + } +}); + +// Get replies for a given comment +app.get("/get-replies", async (req, res) => { + const db = dbRequest(dbHost); + const { comment_id } = req.query; + if (!comment_id) { + db.destroy(); + return res.status(400).json({ message: "Comment ID is required" }); + } + try { + const selectQuery = "SELECT * FROM reply WHERE comment_id = ?"; + const [results] = await db.promise().query(selectQuery, [comment_id]); + db.destroy(); + return res.status(200).json(results); + } catch (error) { + console.error("Error fetching replies:", error); + db.destroy(); + return res.status(500).json({ message: "Database error", error }); + } +}); + +// Start the server +app.listen(port, () => { + console.log(`Upload Server is running at http://localhost:${port}`); +}); \ No newline at end of file