diff --git a/.gitignore b/.gitignore index 978aadcdb779a40e0d0cf90e4204bb0e5f49db0b..d002406f02fa1713aa9e0eeb6a4d7aa2f7eb84e3 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,9 @@ dist-ssr # Engage data files data +data.bkp media +media.bkp # Environment File .env \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 05e7c8a4fb17006d857de1be46b6d939b965ece4..bf717c863f2fa4edc9641126970f5f402ea8be42 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,10 @@ services: environment: - VITE_UPLOAD_SERVER=https://upload.ngage.lol - VITE_LOGIN_SERVER=https://login.ngage.lol + - VITE_FRONTEND_URL=https://ngage.lol + - TZ="America/Detroit" + env_file: + - .env volumes: - ./media:/app/media depends_on: @@ -23,6 +27,10 @@ services: image: upload-server:latest environment: - DATABASE_HOST=db + - TZ="America/Detroit" + - VITE_FRONTEND_URL=https://ngage.lol + env_file: + - .env build: context: . dockerfile: upload-server.dockerfile @@ -43,6 +51,10 @@ services: image: login-server:latest environment: - DATABASE_HOST=db + - TZ="America/Detroit" + - VITE_FRONTEND_URL=https://ngage.lol + env_file: + - .env build: context: . dockerfile: login-server.dockerfile @@ -57,6 +69,7 @@ services: db: image: mysql:latest environment: + TZ: "America/Detroit" MYSQL_AUTHENTICATION_PLUGIN: mysql_native_password MYSQL_ROOT_PASSWORD: pass123 MYSQL_DATABASE: engage @@ -72,4 +85,4 @@ services: interval: 10s timeout: 10s retries: 3 - restart: unless-stopped \ No newline at end of file + restart: unless-stopped diff --git a/initdb/schema.sql b/initdb/schema.sql index dcb7500039617dbc7e049430bfbb897036550feb..06e9a7a1bb3473be60367488fceb419beea5f494 100644 --- a/initdb/schema.sql +++ b/initdb/schema.sql @@ -7,6 +7,9 @@ username VARCHAR(30) UNIQUE, email VARCHAR(50) UNIQUE, password VARCHAR(250), role VARCHAR(10), + isVerified BOOLEAN DEFAULT FALSE, + verificationToken VARCHAR(255), -- This column stores the verification token + recoveryToken VARCHAR(255), -- This column stores the password recovery token dateCreated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE videos( diff --git a/login-server.js b/login-server.js index 1d41262050118d770dd1fa98b8d991e18a6d4e11..504885777161630bfc5dee00cfdc8ff5a3a26118 100644 --- a/login-server.js +++ b/login-server.js @@ -3,7 +3,9 @@ import express from "express"; import cors from "cors"; import bcrypt from "bcryptjs"; // For hashing passwords import jwt from "jsonwebtoken"; // For generating tokens - +import nodemailer from "nodemailer"; +import dotenv from "dotenv"; +dotenv.config(); const app = express(); const port = 8081; @@ -12,6 +14,11 @@ if (process.env.DATABASE_HOST) { dbHost = process.env.DATABASE_HOST; } +let frontendUrl = "http://localhost:5173"; // Default for development +if (process.env.VITE_FRONTEND_URL) { + frontendUrl = process.env.VITE_FRONTEND_URL; // Use environment variable in production +} + // Middleware to parse incoming JSON requests app.use(express.json()); @@ -20,6 +27,19 @@ app.use(cors()); import dbRequest from "./db.js"; + +// Nodemailer setup +const emailUser = process.env.VITE_EMAIL_USER; +const emailPassword = process.env.VITE_EMAIL_PASSWORD; + +const transporter = nodemailer.createTransport({ + service: "gmail", + auth: { + user: emailUser, // Replace with your email + pass: emailPassword, // Replace with your app password( watch the video to know how to get app password) + }, +}); + // Signup Route export const signup = async (req, res) => { const db = dbRequest(dbHost); @@ -65,10 +85,22 @@ export const signup = async (req, res) => { return res.status(500).json({ message: "Server error" }); } + // Generate a verification token + const verificationToken = jwt.sign({ email }, "secretkey", { + expiresIn: "1d", + }); + // Insert new user into the database const query = - "INSERT INTO users (username, email, password, role) VALUES (?, ?, ?, ?)"; - const values = [username, email, hashedPassword, "user"]; + "INSERT INTO users (username, email, password, role, isVerified, verificationToken) VALUES (?, ?, ?, ?, ?, ?)"; + const values = [ + username, + email, + hashedPassword, + "user", + false, + verificationToken, + ]; db.query(query, values, (err, result) => { if (err) { @@ -78,9 +110,25 @@ export const signup = async (req, res) => { .status(500) .json({ message: "Database error", error: err }); } - db.destroy(); - return res.status(201).json({ - message: "User signed up successfully", + // Send verification email + const verificationLink = `${frontendUrl}/verify-email/${verificationToken}`; // Change to your frontend URL when deploying + const mailOptions = { + from: emailUser, // your email + to: email, + subject: "Verify Your Email", + text: `Click this link to verify your email: ${verificationLink}`, + }; + + transporter.sendMail(mailOptions, (error, info) => { + if (error) { + console.error("Error sending email: ", error); + return res.status(500).json({ message: "Error sending email" }); + } + db.destroy(); + return res.status(201).json({ + message: + "User signed up successfully. Please check your email to verify your account.", + }); }); }); }); @@ -98,6 +146,132 @@ export const signup = async (req, res) => { }); }; +// Recover Account Route +app.get("/recover-account", (req, res) => { + const db = dbRequest(dbHost); + const { token } = req.query; + + if (!token) { + db.destroy(); + return res.status(400).json({ message: "Recovery token is required" }); + } + + jwt.verify(token, "secretkey", (err, decoded) => { + if (err) { + db.destroy(); + return res.status(400).json({ message: "Invalid or expired token" }); + } + + const email = decoded.email; + + const updateQuery = + "UPDATE users SET recoveryToken = NULL WHERE email = ?"; + db.query(updateQuery, [email], (err, result) => { + if (err) { + db.destroy(); + return res.status(500).json({ message: "Database error" }); + } + db.destroy(); + return res + .status(200) + .json({ message: email }); + }); + }); +}); + +// Send Recovery Link Route +app.post("/send-recovery-link", (req, res) => { + const db = dbRequest(dbHost); + const { email } = req.body; + + if (!email) { + db.destroy(); + return res.status(400).json({ message: "Email is required" }); + } + + const findUserQuery = "SELECT * FROM users WHERE email = ?"; + db.query(findUserQuery, [email], (err, results) => { + if (err) { + console.error("Database error:", err); + db.destroy(); + return res.status(500).json({ message: "Database error" }); + } + + if (results.length === 0) { + db.destroy(); + return res.status(404).json({ message: "Email does not exist" }); + } + + const user = results[0]; + const recoveryToken = jwt.sign({ email: user.email }, "secretkey", { + expiresIn: "1h", + }); + + const recoveryLink = `${frontendUrl}/recover-account/${recoveryToken}`; + const mailOptions = { + from: emailUser, + to: email, + subject: "Password Recovery", + text: `The link will expire in 1 hour. Click this link to reset your password: ${recoveryLink}`, + }; + const attachTokenQuery = "UPDATE users SET recoveryToken = ? WHERE email = ?"; + db.query(attachTokenQuery, [recoveryToken, email], (err) => { + if (err) { + console.error("Database error:", err); + db.destroy(); + return res.status(500).json({ message: "Database error" }); + } + }); + + transporter.sendMail(mailOptions, (error, info) => { + if (error) { + console.error("Error sending email:", error); + db.destroy(); + return res.status(500).json({ message: "Error sending email" }); + } + + db.destroy(); + return res.status(200).json({ + message: "Recovery link sent successfully. Please check your email.", + }); + }); + }); +}); + + +// Email Verification Route +app.get("/verify-email", (req, res) => { + const db = dbRequest(dbHost); + const { token } = req.query; + + if (!token) { + db.destroy(); + return res.status(400).json({ message: "Missing token" }); + } + + jwt.verify(token, "secretkey", (err, decoded) => { + if (err) { + db.destroy(); + return res.status(400).json({ message: "Invalid or expired token" }); + } + + const email = decoded.email; + const updateQuery = + "UPDATE users SET isVerified = true, verificationToken = NULL WHERE email = ?"; + + db.query(updateQuery, [email], (err, result) => { + if (err) { + db.destroy(); + return res.status(500).json({ message: "Database error" }); + } + db.destroy(); + return res + .status(200) + .json({ message: "Email verified successfully! You can now log in." }); + }); + }); +}); + const authenticateTokenGet = (req, res, next) => { const { auth: token } = req.query; console.log("Token received:", token); @@ -143,6 +317,13 @@ app.post("/login", (req, res) => { const user = results[0]; + // Check if user is verified + if (!user.isVerified) { + return res + .status(403) + .json({ message: "Please verify your email before logging in." }); + } + // Compare passwords bcrypt.compare(password, user.password, (err, isMatch) => { if (err) { diff --git a/package-lock.json b/package-lock.json index b1653b7ea5653db7282b41eb0f3adc07ce2c769c..54dee33be5c811e9774e45cb0f24431e88eb3777 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "fs": "^0.0.1-security", "get-all-files": "^5.0.0", "i": "^0.3.7", + "jest-mock": "^29.7.0", "jsonwebtoken": "^9.0.2", "jwt-decode": "^4.0.0", "mkdirp": "^3.0.1", @@ -32,6 +33,7 @@ "mysql": "^2.18.1", "mysql2": "^3.12.0", "node": "^18.20.6", + "nodemailer": "^6.10.0", "nodemon": "^3.1.9", "path-browserify": "^1.0.1", "react": "^18.3.1", @@ -66,7 +68,7 @@ "typescript": "~5.6.2", "typescript-eslint": "^8.18.2", "vite": "^6.0.5", - "vitest": "^3.0.5" + "vitest": "^3.0.8" } }, "node_modules/@adobe/css-tools": { @@ -1332,6 +1334,35 @@ } } }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", @@ -2050,6 +2081,12 @@ "win32" ] }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "license": "MIT" + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", @@ -2218,6 +2255,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2254,7 +2315,6 @@ "version": "22.13.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.4.tgz", "integrity": "sha512-ywP2X0DYtX3y08eFVx5fNIw7/uIv8hYUKgXoK8oayJlLnKcRfEYCxWMVE1XagUdVtCJlZT1AU4LXEABW+L1Peg==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.20.0" @@ -2321,6 +2381,21 @@ "license": "MIT", "optional": true }, + "node_modules/@types/yargs": { + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.24.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.24.1.tgz", @@ -2561,15 +2636,15 @@ } }, "node_modules/@vitest/expect": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.5.tgz", - "integrity": "sha512-nNIOqupgZ4v5jWuQx2DSlHLEs7Q4Oh/7AYwNyE+k0UQzG7tSmjPXShUikn1mpNGzYEN2jJbTvLejwShMitovBA==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.8.tgz", + "integrity": "sha512-Xu6TTIavTvSSS6LZaA3EebWFr6tsoXPetOWNMOlc7LO88QVVBwq2oQWBoDiLCN6YTvNYsGSjqOO8CAdjom5DCQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.5", - "@vitest/utils": "3.0.5", - "chai": "^5.1.2", + "@vitest/spy": "3.0.8", + "@vitest/utils": "3.0.8", + "chai": "^5.2.0", "tinyrainbow": "^2.0.0" }, "funding": { @@ -2577,13 +2652,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.5.tgz", - "integrity": "sha512-CLPNBFBIE7x6aEGbIjaQAX03ZZlBMaWwAjBdMkIf/cAn6xzLTiM3zYqO/WAbieEjsAZir6tO71mzeHZoodThvw==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.8.tgz", + "integrity": "sha512-n3LjS7fcW1BCoF+zWZxG7/5XvuYH+lsFg+BDwwAz0arIwHQJFUEsKBQ0BLU49fCxuM/2HSeBPHQD8WjgrxMfow==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "3.0.5", + "@vitest/spy": "3.0.8", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, @@ -2604,9 +2679,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.5.tgz", - "integrity": "sha512-CjUtdmpOcm4RVtB+up8r2vVDLR16Mgm/bYdkGFe3Yj/scRfCpbSi2W/BDSDcFK7ohw8UXvjMbOp9H4fByd/cOA==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.8.tgz", + "integrity": "sha512-BNqwbEyitFhzYMYHUVbIvepOyeQOSFA/NeJMIP9enMntkkxLgOcgABH6fjyXG85ipTgvero6noreavGIqfJcIg==", "dev": true, "license": "MIT", "dependencies": { @@ -2617,38 +2692,38 @@ } }, "node_modules/@vitest/runner": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.5.tgz", - "integrity": "sha512-BAiZFityFexZQi2yN4OX3OkJC6scwRo8EhRB0Z5HIGGgd2q+Nq29LgHU/+ovCtd0fOfXj5ZI6pwdlUmC5bpi8A==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.8.tgz", + "integrity": "sha512-c7UUw6gEcOzI8fih+uaAXS5DwjlBaCJUo7KJ4VvJcjL95+DSR1kova2hFuRt3w41KZEFcOEiq098KkyrjXeM5w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "3.0.5", - "pathe": "^2.0.2" + "@vitest/utils": "3.0.8", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.5.tgz", - "integrity": "sha512-GJPZYcd7v8QNUJ7vRvLDmRwl+a1fGg4T/54lZXe+UOGy47F9yUfE18hRCtXL5aHN/AONu29NGzIXSVFh9K0feA==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.8.tgz", + "integrity": "sha512-x8IlMGSEMugakInj44nUrLSILh/zy1f2/BgH0UeHpNyOocG18M9CWVIFBaXPt8TrqVZWmcPjwfG/ht5tnpba8A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.5", + "@vitest/pretty-format": "3.0.8", "magic-string": "^0.30.17", - "pathe": "^2.0.2" + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/spy": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.5.tgz", - "integrity": "sha512-5fOzHj0WbUNqPK6blI/8VzZdkBlQLnT25knX0r4dbZI9qoZDf3qAdjoMmDcLG5A83W6oUUFJgUd0EYBc2P5xqg==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.8.tgz", + "integrity": "sha512-MR+PzJa+22vFKYb934CejhR4BeRpMSoxkvNoDit68GQxRLSf11aT6CTj3XaqUU9rxgWJFnqicN/wxw6yBRkI1Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2659,14 +2734,14 @@ } }, "node_modules/@vitest/utils": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.5.tgz", - "integrity": "sha512-N9AX0NUoUtVwKwy21JtwzaqR5L5R5A99GAbrHfCCXK1lp593i/3AZAXhSP43wRQuxYsflrdzEfXZFo1reR1Nkg==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.8.tgz", + "integrity": "sha512-nkBC3aEhfX2PdtQI/QwAWp8qZWwzASsU4Npbcd5RdMPBSSLCpkZp52P3xku3s3uA0HIEhGvEcF8rNkBsz9dQ4Q==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "3.0.5", - "loupe": "^3.1.2", + "@vitest/pretty-format": "3.0.8", + "loupe": "^3.1.3", "tinyrainbow": "^2.0.0" }, "funding": { @@ -3178,6 +3253,21 @@ "node": ">= 16" } }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -4576,6 +4666,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -5007,6 +5103,37 @@ "dev": true, "license": "ISC" }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5968,6 +6095,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.0.tgz", + "integrity": "sha512-SQ3wZCExjeSatLE/HBaXS5vqUOQk6GtBdIIKxiFdmm01mOQZX/POJkO3SUX1wDiYcwUOJwT23scFSC9fY2H8IA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.9", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz", @@ -7998,7 +8134,6 @@ "version": "6.20.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", - "devOptional": true, "license": "MIT" }, "node_modules/universalify": { @@ -8174,16 +8309,16 @@ } }, "node_modules/vite-node": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.5.tgz", - "integrity": "sha512-02JEJl7SbtwSDJdYS537nU6l+ktdvcREfLksk/NDAqtdKWGqHl+joXzEubHROmS3E6pip+Xgu2tFezMu75jH7A==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.8.tgz", + "integrity": "sha512-6PhR4H9VGlcwXZ+KWCdMqbtG649xCPZqfI9j2PsK1FcXgEzro5bGHcVKFCTqPLaNKZES8Evqv4LwvZARsq5qlg==", "dev": true, "license": "MIT", "dependencies": { "cac": "^6.7.14", "debug": "^4.4.0", "es-module-lexer": "^1.6.0", - "pathe": "^2.0.2", + "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0" }, "bin": { @@ -8216,31 +8351,31 @@ } }, "node_modules/vitest": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.5.tgz", - "integrity": "sha512-4dof+HvqONw9bvsYxtkfUp2uHsTN9bV2CZIi1pWgoFpL1Lld8LA1ka9q/ONSsoScAKG7NVGf2stJTI7XRkXb2Q==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.8.tgz", + "integrity": "sha512-dfqAsNqRGUc8hB9OVR2P0w8PZPEckti2+5rdZip0WIz9WW0MnImJ8XiR61QhqLa92EQzKP2uPkzenKOAHyEIbA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "3.0.5", - "@vitest/mocker": "3.0.5", - "@vitest/pretty-format": "^3.0.5", - "@vitest/runner": "3.0.5", - "@vitest/snapshot": "3.0.5", - "@vitest/spy": "3.0.5", - "@vitest/utils": "3.0.5", - "chai": "^5.1.2", + "@vitest/expect": "3.0.8", + "@vitest/mocker": "3.0.8", + "@vitest/pretty-format": "^3.0.8", + "@vitest/runner": "3.0.8", + "@vitest/snapshot": "3.0.8", + "@vitest/spy": "3.0.8", + "@vitest/utils": "3.0.8", + "chai": "^5.2.0", "debug": "^4.4.0", "expect-type": "^1.1.0", "magic-string": "^0.30.17", - "pathe": "^2.0.2", + "pathe": "^2.0.3", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinypool": "^1.0.2", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0", - "vite-node": "3.0.5", + "vite-node": "3.0.8", "why-is-node-running": "^2.3.0" }, "bin": { @@ -8256,8 +8391,8 @@ "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.0.5", - "@vitest/ui": "3.0.5", + "@vitest/browser": "3.0.8", + "@vitest/ui": "3.0.8", "happy-dom": "*", "jsdom": "*" }, diff --git a/package.json b/package.json index 48e7c0c12ede4e68b6243eb0caacc4ac2033f4b8..93e88e627a07048eb431aea8f16a69951e13d5c6 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "fs": "^0.0.1-security", "get-all-files": "^5.0.0", "i": "^0.3.7", + "jest-mock": "^29.7.0", "jsonwebtoken": "^9.0.2", "jwt-decode": "^4.0.0", "mkdirp": "^3.0.1", @@ -39,6 +40,7 @@ "mysql": "^2.18.1", "mysql2": "^3.12.0", "node": "^18.20.6", + "nodemailer": "^6.10.0", "nodemon": "^3.1.9", "path-browserify": "^1.0.1", "react": "^18.3.1", @@ -73,6 +75,6 @@ "typescript": "~5.6.2", "typescript-eslint": "^8.18.2", "vite": "^6.0.5", - "vitest": "^3.0.5" + "vitest": "^3.0.8" } } diff --git a/src/App.tsx b/src/App.tsx index 603155cd96eb30543c9195ba05d6b758d2d07986..203cfb7cf29f939dbc8ebbab1491de090d6c8148 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,8 +9,14 @@ import ReactPlayer from "react-player"; import User from "./User"; import path from "path-browserify"; import Upload from "./upload.tsx"; +import VerifyEmail from "./VerifyEmail.tsx"; import axios from "axios"; import Terms from "./terms.tsx"; +import LikeButton from "./components/likeButton.tsx"; +import TopBar from "./components/TopBar.tsx"; +import RecoverAccount from "./recoverAccount.tsx"; +// import { createContext, useContext } from 'react'; +// import VideoPlayer from './components/VideoPlayerUser.tsx'; // Dynamically import all video files from the media folder const videos = import.meta.glob("../media/*trans.mp4"); @@ -164,6 +170,13 @@ function Home() { const navigate = useNavigate(); + + // current video use states + const [currentVideoTitle, setCurrentVideoTitle] = useState(""); + const [currentVideoDesc, setCurrentVideoDesc] = useState(""); + const [currentVideoDate, setCurrentVideoDate] = useState(""); + const [currentVideoCreatorName, setCurrentVideoCreatorName] = useState(""); + useEffect(() => { setLiked(false); setViewRecorded(false); @@ -173,7 +186,6 @@ function Home() { useEffect(() => { if (currentVideo) { console.log("Video changed to:", currentVideo.split("/").pop()); - getLikeCount(); getViewCount(); if (loggedIn && userID) { checkIfLiked(); @@ -181,7 +193,7 @@ function Home() { // Fetch comments for current video. displayComments(); } - }, [currentVideo, loggedIn, userID]); + }, [currentVideo]); useEffect(() => { const fetchReplyLikes = async () => { @@ -242,9 +254,13 @@ function Home() { ); }; - const handleBackToLogin = () => { - navigate("/login"); - }; + // const navigate = useNavigate(); // Hook to navigate to other pages + // const handleBackToDashboard = () => { + // navigate("/dashboard"); + // }; + // const handleBackToLogin = () => { + // navigate("/login"); + // }; async function getUsername(userid: number) { let creatorName = ""; @@ -257,33 +273,37 @@ function Home() { }); return creatorName as string; } + // Function to grab video information from API + async function setVideoInfo() { - async function getVideoInfo() { - let title = ""; - let desc = ""; - let userid = 0; - let creatorName = ""; - await axios - .get(`${uploadServer}/video`, { - params: { - fileName: currentVideo.substring(currentVideo.lastIndexOf("/") + 1), - }, - }) - .then((response) => { - title = response.data.title; - desc = response.data.description; - userid = response.data.creator_id; - }) - .catch((error) => { - alert(`There was an error fetching the video info!\n\n${error}`); + // Get video info + try { + const response = await axios.get(`${uploadServer}/video`, { + params: { + fileName: currentVideo.substring(currentVideo.lastIndexOf("/") + 1), + }, + }); + + // get user info + setCurrentVideoTitle(response.data.title); + setCurrentVideoDesc(response.data.description); + const username = await getUsername(response.data.creator_id); + setCurrentVideoCreatorName(username); + // translate the timestamp in created_at + const date = new Date(response.data.created_at).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + const time = new Date(response.data.created_at).toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", }); - creatorName = await getUsername(userid); - if (!desc) { - desc = "No description provided"; + setCurrentVideoDate(`${date} at ${time}`); + } catch (error) { + alert(`There was an error fetching the video info!\n\n${error}`); } - alert( - `Title: ${title}\n--------------------------\nDescription: ${desc}\n--------------------------\nCreator: ${creatorName}\n--------------------------\nViews: ${viewCount}` - ); + } async function getLoggedInUserId() { @@ -621,8 +641,9 @@ function Home() { console.log("Reply Liked State Before Rendering:", replyLiked); return ( - <div className="app-container"> - <h1>Engage</h1> + + <div className="app"> + <div className="app-container"> <div className="video-player"> <ReactPlayer id="video" @@ -632,57 +653,73 @@ function Home() { controls={true} loop={true} playsinline={true} - width="80vw" + width="90vw" height="60vh" onStart={handleVideoStart} /> - </div> - <div className="video-stats"> - <button onClick={handleLike} style={{ color: liked ? "red" : "black" }}> - <i className="fa-solid fa-heart"></i> {likeCount} Likes - </button> - <span className="view-count"> - <i className="fa-solid fa-eye"></i> {viewCount} Views - </span> - </div> <div className="controls"> - <a className="control-button" href={currentVideo} download> - <i className="fa-solid fa-download"></i> DOWNLOAD + <div className="video-stats"> + <LikeButton + fileName={currentVideo ? currentVideo.split("/").pop() || "" : ""} + loggedIn={loggedIn} + userId={userID} + initialLikeCount={likeCount} + initialLiked={liked} + loginServer={loginServer} + /> + <span className="views"> + <i className="fa-solid fa-eye"></i> {viewCount}<span className="desktop__text"> Views</span> + </span> + + </div> + <div className="download-next"> + + {filteredArray.length > 0 && ( + <a className="button" href={currentVideo} download> + <i className="fa-solid fa-download"></i><span className="desktop__text"> DOWNLOAD</span> + </a> + )} + {filteredArray.length == 0 && ( + <a className="button greyed"> + <i className="fa-solid fa-download"></i><span className="desktop__text"> DOWNLOAD</span> + </a> + )} + <a + className={filteredArray.length < 2 ? "button greyed" : "button"} + onClick={() => { + const videoElement = document.getElementById("video"); + if (videoElement && filteredArray.length >= 2) { + videoElement.classList.remove("fade-in"); + videoElement.classList.add("fade-out"); + setTimeout(() => { + handleNext(); + videoElement.classList.remove("fade-out"); + videoElement.classList.add("fade-in"); + }, 200); // Match the duration of the fade-out animation + }; + }} + > + <span className="desktop__text">NEXT </span><i className="fa-solid fa-arrow-right"></i> </a> - <div className="control-button" onClick={getVideoInfo}> - <i className="fas fa-info-circle"></i> VIDEO INFO </div> - {/* The COMMENT button toggles the entire comment section */} - <button className="control-button" onClick={toggleComments}> - COMMENT <i className="fa-solid fa-comment"></i> - </button> - <button className="control-button" onClick={handleNext}> - NEXT <i className="fa-solid fa-arrow-right"></i> - </button> </div> - <div className="upload-section"> - <button className="upload-button" onClick={() => navigate("/upload")}> - ENGAGE <i className="fa-solid fa-upload"></i> - </button> </div> - <div className="back-button-section"> - {/* Removed VIDEO INFO button */} - </div> - <div className="login-button-section"> - <button - className="control-button" - onClick={loggedIn ? () => navigate("/user") : handleBackToLogin} - > - {loggedIn ? ( + <div className="video-details"> + <div className="details-metadata"> + {filteredArray.length > 0 && ( <> - <i className="fa-solid fa-user"></i> {username} + <h1>{currentVideoTitle}</h1> + <h2>Engager: {currentVideoCreatorName}</h2> + <h3>Uploaded: {currentVideoDate}</h3> + <p>{currentVideoDesc !== "" ? currentVideoDesc : "No Description Provided"}</p> </> - ) : ( + )} + {filteredArray.length == 0 && ( <> - <i className="fa-solid fa-right-to-bracket"></i> Log In + <h2>There are no videos available</h2> + <h3>Upload one to kick things off.</h3> </> )} - </button> {/* Comment Section toggled by the COMMENT button */} {showComments && ( <div @@ -844,25 +881,32 @@ function Home() { )} </div> </div> + </div> + </div> ); } function App() { return ( <BrowserRouter> - <Routes> - <Route element={<App />} /> - <Route path="/" element={<Home />} /> - <Route path="/login" element={<Login />} /> - <Route path="/signup" element={<Signup />} /> - <Route path="/terms" element={<Terms />} /> - <Route path="/reset-password" element={<ResetPassword />} /> - {/* Protected Routes */} - <Route element={<PrivateRoute />}> - <Route path="/user" element={<User />} /> - <Route path="/upload" element={<Upload />} /> - </Route> - </Routes> + <TopBar /> + <Routes> + <Route path="/" element={<Home />} /> + <Route path="/login" element={<Login />} /> + <Route path="/signup" element={<Signup />} /> + <Route path="/terms" element={<Terms />} /> + <Route path="/reset-password" element={<ResetPassword />} /> + <Route path="/verify-email/:token" element={<VerifyEmail />} /> + <Route path="/recover-account/:token" element={<RecoverAccount />} /> + {/* User Page Route */} + + {/* Protected Route for Dashboard and Video Player */} + <Route element={<PrivateRoute />}> + <Route path="/user" element={<User />} /> + <Route path="/upload" element={<Upload />} /> + {/* <Route path="/dashboard" element={<Dashboard />} /> */} + </Route> + </Routes> </BrowserRouter> ); } diff --git a/src/User.tsx b/src/User.tsx index 0fbf9e024bbbd5a62c47ad9c0215cbd49c69c610..1569348644fbe8e79d5c50a615e690e0d7a40819 100644 --- a/src/User.tsx +++ b/src/User.tsx @@ -12,7 +12,7 @@ import axios from "axios"; // } // Set the number of videos displayed per page -const VIDEOS_PER_PAGE = 9; +const VIDEOS_PER_PAGE = 6; let uploadServer = "http://localhost:3001"; if (import.meta.env.VITE_UPLOAD_SERVER !== undefined) { @@ -143,8 +143,8 @@ function User() { const handleLogout = () => { // Clear the authentication token from localStorage localStorage.removeItem("authToken"); - // Navigate to login page - navigate("/"); + // Navigate to login page (force refresh the page) + window.location.href = "/"; }; /** @@ -195,12 +195,29 @@ function User() { }`} {...handlers} > - <p style={{ color: "white", padding: "0px", top: "0" }}> - Swipe left and right to navigate - </p> + {/* Logout button */} + <div className="logout__section"> + <a className="button warning" onClick={handleLogout}> + <i className="fas fa-door-open"></i><span className="desktop__text"> Logout</span> + </a> + </div> + <div className="content-container"> {/* Section title */} - <div className="my-videos-container">My Engagements</div> + + <div className="my-videos-container"> + <div className="text"> + <h2>Your engagements</h2> + <p style={{ fontSize: "1rem" }} className="mobile__text"> + Swipe left and right to navigate.<br></br> Touch video to play. <br></br>Tap background to return. + </p> + <p className="desktop__text"> + Click and drag left and right to navigate. + <br></br> Click video to play. + <br></br>Click background to return. + </p> + </div> + </div> {/* AnimatePresence ensures smooth transition between pages */} <AnimatePresence mode="popLayout"> @@ -209,7 +226,7 @@ function User() { className="video-grid" initial={{ x: direction * 100, opacity: 0 }} // Start position animate={{ x: 0, opacity: 1 }} // Target position (smooth slide-in effect) - exit={{ x: -direction * 100, opacity: 0 }} // Exit animation (smooth slide-out effect) + exit={{ x: direction * 100, opacity: 0 }} // Exit animation (smooth slide-out effect) transition={{ type: "spring", stiffness: 120, damping: 20 }} // Animation style > {currentVideos.length > 0 ? ( @@ -237,19 +254,12 @@ function User() { </AnimatePresence> {/* Home button for navigation */} - <div className="user-buttons"> + {/* <div className="user-buttons"> <button className="home-button" onClick={() => navigate("/")}> Home </button> - <button className="home-button btn-danger" onClick={handleLogout}> - Logout - </button> - </div> - </div> - - {/* Display username at the bottom */} - <div className="username-display"> - Engaged as: <span className="username">{username}</span> + + </div> */} </div> </div> diff --git a/src/VerifyEmail.tsx b/src/VerifyEmail.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0d9f3104a00d263bbd2114c7bff7b3415f2db301 --- /dev/null +++ b/src/VerifyEmail.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from "react"; +import { useNavigate, useLocation, useParams } from "react-router-dom"; +import axios from "axios"; +import "./styles/auth.scss"; + +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; +} + + + +const VerifyEmail: React.FC = () => { + const [message, setMessage] = useState<string>("Verifying..."); + const navigate = useNavigate(); + const location = useLocation(); + const token = useParams().token; + + useEffect(() => { + + // const token = localStorage.setItem('token', token); + + if (token) { + axios + .get(`${loginServer}/verify-email?token=${token}`) + .then((res) => { + setMessage(res.data.message); + setTimeout(() => navigate("/login"), 3000); // Redirect after 3 seconds + }) + .catch((err) => { + setMessage( + err.response?.data?.message || "Invalid or expired token." + ); + }); + } else { + setMessage("Invalid request."); + } + }, [location, navigate]); + + return ( + <div className="verify-email__container"> + <h2>Email Verification</h2> + <p>{message}</p> + </div> + ); +}; + +export default VerifyEmail; diff --git a/src/components/FileUploader.tsx b/src/components/FileUploader.tsx index 0abffde7d8388e026b8f10a1e8798f27db8f13a3..0b72653564f836caf2642acfd62d61b819d1386e 100644 --- a/src/components/FileUploader.tsx +++ b/src/components/FileUploader.tsx @@ -1,6 +1,12 @@ import { ChangeEvent, useState } from "react"; import axios from "axios"; import "dotenv"; +import { io, Socket } from "socket.io-client"; +import { v4 as uuidv4 } from "uuid"; + + +import "../styles/auth.scss"; + let uploadServer = "http://localhost:3001"; if (import.meta.env.VITE_UPLOAD_SERVER !== undefined) { @@ -19,7 +25,9 @@ interface FormValues { fileName: string; } -type UploadStatus = "idle" | "uploading" | "success" | "error"; +import "../styles/upload.scss"; + +type UploadStatus = "idle" | "uploading" | "transcoding" | "success" | "error"; const MAX_FILE_SIZE = 80 * 1024 * 1024; // 80MB @@ -64,8 +72,12 @@ export default function FileUploader() { async function handleFileUpload() { if (!file) return; - console.log("File size: " + file.size); - console.log("Max file size: " + MAX_FILE_SIZE); + if (!title){ + alert("Title is required"); + return; + } + // console.log("File size: " + file.size); + // console.log("Max file size: " + MAX_FILE_SIZE); if (!isMP4(file)) { alert("File is not an mp4."); return; @@ -103,30 +115,97 @@ export default function FileUploader() { } } + // Get the overall progress based on current status + const getOverallProgress = () => { + if (status === "uploading") { + return uploadProgress; + } else if (status === "transcoding") { + return transcodingProgress; + } else if (status === "success") { + return 100; + } + return 0; + }; + + // Watch the status and redirect to home if successful + useEffect(() => { + if (status === "success") { + const timer = setTimeout(() => { + window.location.href = "/"; // Redirect to home + }, 1500); // Wait for 3 seconds before redirecting + + return () => clearTimeout(timer); // Cleanup the timer on unmount + } + }, [status]); + // Get the progress message based on status + const getProgressMessage = () => { + if (status === "uploading") { + return `Uploading: ${uploadProgress}%`; + } else if (status === "transcoding") { + return `Transcoding: ${transcodingProgress}%`; + } else if (status === "success") { + return "Success! Video uploaded and processed."; + } else if (status === "error") { + return "Upload error, please try again."; + } + return ""; + }; + return ( - <div> - <br></br> - <label htmlFor="title">Title: </label> - <input name="title" value={title} onChange={handleTitleChange} /> - <br></br> - <label htmlFor="desc">Description: </label> - <input name="desc" value={desc} onChange={handleDescChange} /> - <br></br> - <input type="file" accept="video/mp4" onChange={handleFileChange} /> - {file && status !== "uploading" && ( - <button onClick={handleFileUpload}>Upload</button> - )} - {status === "uploading" && ( - <div> - <p>Progress: {uploadProgress}%</p> - <div - className="upload-bar" - style={{ width: `${uploadProgress}%` }} - ></div> + <div className=""> + <div className="auth__container"> + <div className="form-group"> + <label htmlFor="title">Title: </label> + <input name="title" className="auth__form-control" value={title} onChange={handleTitleChange} /> + + </div> + </div> + + <div className="auth__container"> + <div className="form-group"> + <label htmlFor="desc">Description: </label> + <input name="desc" className="auth__form-control" value={desc} onChange={handleDescChange} /> + </div> + + </div> + + + <div className="form-group "> + <input type="file" accept="video/mp4" onChange={handleFileChange} /> + <br /> + {file && status === "idle" && ( + <button style={{margin: "15px auto"}} className="button primary" onClick={handleFileUpload}>Upload</button> + )} + </div> + + {status !== "idle" && ( + <div className="progress-container"> + <p className="progress-message">{getProgressMessage()}</p> + <div className="progress-bar-container"> + <div + className="progress-bar" + style={{ width: `${getOverallProgress()}%` }} + /> + </div> + + {status === "transcoding" && ( + <p className="info-text"> + Transcoding may take a while depending on video size... + </p> + )} + </div> )} - {status === "success" && <p>Success!</p>} - {status === "error" && <p>Upload error, please try again. (Title is required)</p>} + + {status === "success" && ( + <p className="info-text"> + Redirecting back to home... + </p> + )} + + {/* <style>{` + + `}</style> */} </div> ); } diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fa3a529bbccf9232fdae690b61035094229e76b5 --- /dev/null +++ b/src/components/TopBar.tsx @@ -0,0 +1,117 @@ +import '../styles/topbar.scss'; +import { useNavigate } from "react-router-dom"; +import { useState, useEffect } from "react"; +import axios from "axios"; + +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; +} + +export default function TopBar(){ + const [loggedIn, setLoggedIn] = useState(false); + const [username, setUsername] = useState(""); + const [userID, setUserID] = useState(0); + const navigate = useNavigate(); + + async function getUsername(userid: number) { + let creatorName = ""; + await axios + .get(`${uploadServer}/user`, { + params: { + userID: userid, + }, + }) + .then((response) => { + creatorName = response.data.username; + }); + return creatorName as string; + } + + async function getLoggedInUserId() { + const token = localStorage.getItem("authToken"); + if (token) { + try { + const response = await axios.get(`${loginServer}/current-user-id`, { + params: { + auth: token ? token : "", + }, + }); + setUserID(response.data.userId); + setLoggedIn(true); + // userChanged = true; + return response.data.userId; + } catch (error) { + console.error("Error fetching user ID:", error); + return null; + } + } else { + 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); + } + } + assignUsername(); + return( + <nav className="topbar"> + <div className="topbar__container"> + <div className="topbar__logo"> + + <a onClick={() => navigate('/')}><h1>Engage</h1></a> + </div> + <div className="topbar__menu"> + <ul className="link__items"> + <li> + + <a className="button" onClick={() => navigate('/upload')}><i className="fa-solid fa-upload persist"></i> <span className="desktop__text">Upload</span></a> + </li> + <li> + <a className="button" onClick={loggedIn ? () => navigate("/user") : () => navigate('/login')}>{loggedIn ? (<> + <i className="fa-solid fa-user persist"></i> <span className="desktop__text">{username}</span> + </> + ) : ( + <> + <i className="fa solid fa-right-to-bracket persist"></i> <span className="desktop__text">Log In</span> + </>)} + </a> + </li> + </ul> + </div> + </div> + </nav> + ) +} \ No newline at end of file diff --git a/src/components/VideoPlayerUser.tsx b/src/components/VideoPlayerUser.tsx index 644041c77bfda7ecef260d384b38138c286c09a4..c14b8263071bf7922dce859d7e834413c3dc145f 100644 --- a/src/components/VideoPlayerUser.tsx +++ b/src/components/VideoPlayerUser.tsx @@ -1,6 +1,8 @@ import { useState } from "react"; import { motion } from "framer-motion"; + + // VideoPlayer Component - Toggles between a small and expanded video export default function VideoPlayer() { // State to track if the video is expanded or not diff --git a/src/components/likeButton.tsx b/src/components/likeButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7bfc79d3012857724b437cca26200a363d46a9a6 --- /dev/null +++ b/src/components/likeButton.tsx @@ -0,0 +1,133 @@ +import React, { useState, useEffect } from "react"; +import axios from "axios"; +import "../styles/App.scss"; +interface LikeButtonProps { + fileName: string; + loggedIn: boolean; + userId: number; + initialLikeCount?: number; + initialLiked?: boolean; + loginServer: string; +} + +const LikeButton: React.FC<LikeButtonProps> = ({ + fileName, + loggedIn, + userId, + initialLikeCount = 0, + initialLiked = false, + loginServer, +}) => { + const [likeCount, setLikeCount] = useState(initialLikeCount); + const [liked, setLiked] = useState(initialLiked); + + useEffect(() => { + getLikeCount(); + if (loggedIn && userId) { + checkIfLiked(); + } + }, [fileName, loggedIn, userId]); + + async function getLikeCount() { + try { + 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(initialLikeCount); + } + } + + async function checkIfLiked() { + if (!loggedIn) { + setLiked(false); + return; + } + + const token = localStorage.getItem("authToken"); + if (!token) { + setLiked(false); + return; + } + + if (!fileName) { + setLiked(false); + return; + } + + try { + const response = await axios.get(`${loginServer}/check-like-status`, { + params: { + auth: token, + fileName: fileName, + }, + }); + + 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; + } + + if (!fileName) { + console.error("Error: fileName is missing."); + return; + } + + const token = localStorage.getItem("authToken"); + if (!token) { + alert("Authentication error. Please log in again."); + 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."); + } + } + + return ( + <a onClick={handleLike} className={ liked ? "button liked" : "button not-liked" }> + <i className="fa-solid fa-heart"></i> {likeCount}<span className="desktop__text"> Likes</span> + </a> + // <button + // onClick={handleLike} + // style={{ color: liked ? "red" : "black" }} + // data-testid="like-button" + // > + // <i className="fa-solid fa-heart"></i> {likeCount} Likes + // </button> + ); +}; + +export default LikeButton; diff --git a/src/signupValidation.tsx b/src/components/signupValidation.tsx similarity index 100% rename from src/signupValidation.tsx rename to src/components/signupValidation.tsx diff --git a/src/login.tsx b/src/login.tsx index b55fe222384223820c70c69024ec52306721e3a2..f9c95ee0971a0a320eff30bfc6d025e8c889456a 100644 --- a/src/login.tsx +++ b/src/login.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from "react"; import { Link, useNavigate } from "react-router-dom"; -import "./styles/login.scss"; +import "./styles/auth.scss"; import validation from "./loginValidation"; import axios from "axios"; @@ -92,6 +92,9 @@ const Login: React.FC = () => { } else if (error.response && error.response.status === 401) { // Invalid password setErrors({ password: "Incorrect password! Please try again!" }); + } else if (error.response && error.response.status === 403) { + // Forbidden error + setErrors({ password: "Account is not verified! Please check your email." }); } else { // General error setErrors({ password: "An error occurred during login" }); @@ -101,16 +104,15 @@ const Login: React.FC = () => { }; return ( - <div className="login__body"> - <div className="login__form"> - <button className="login__btn login__btn--home" onClick={() => navigate('/')}>Home</button> - <h2>Login</h2> + <div className="auth__body"> + <div className="auth__form"> + <h2 className="auth__title">Login</h2> {successMessage && ( - <div className="login__success-message">{successMessage}</div> // Show success message + <div className="auth__success-message">{successMessage}</div> // Show success message )} <form onSubmit={handleSubmit}> - <div className="login__container"> - <label htmlFor="usernameOrEmail" className="login__label"> + <div className="auth__container"> + <label htmlFor="usernameOrEmail"> <strong>User Id</strong> </label> <input @@ -119,14 +121,14 @@ const Login: React.FC = () => { value={usernameOrEmail} // Can be username OR email onChange={handleUsernameOrEmailChange} placeholder="Enter Username OR Email" - className="login__form-control" + className="auth__form-control" /> {errors.usernameOrEmail && ( - <span className="login__text-danger">{errors.usernameOrEmail}</span> + <span className="auth__text-danger">{errors.usernameOrEmail}</span> )} </div> - <div className="login__container"> - <label htmlFor="password" className="login__label"> + <div className="auth__container"> + <label htmlFor="password"> <strong>Password</strong> </label> <input @@ -135,20 +137,20 @@ const Login: React.FC = () => { value={password} onChange={handlePasswordChange} placeholder="Enter Password" - className="login__form-control" + className="auth__form-control" /> {errors.password && ( - <span className="login__text-danger">{errors.password}</span> + <span className="auth__text-danger">{errors.password}</span> )} </div> - <div className="login__buttons-container"> - <button type="submit" className="login__btn login__btn--success"> + <div className="auth__buttons-container"> + <button type="submit" className="button success"> Login </button> - <Link to="/reset-password" className="login__button"> + <Link to="/reset-password" className="button danger"> Reset Password </Link> - <Link to="/signup" className="login__button"> + <Link to="/signup" className="button primary"> Create Account </Link> </div> diff --git a/src/recoverAccount.tsx b/src/recoverAccount.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1644de36e7a3cbfd9e5d7636049807d2d58b1a01 --- /dev/null +++ b/src/recoverAccount.tsx @@ -0,0 +1,143 @@ +import React, { useState, useEffect } from "react"; +import axios from "axios"; +import "./styles/auth.scss"; +import { Link, useNavigate, useParams } from "react-router-dom"; + +// 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; +} + + + +const RecoverAccount: React.FC = () => { + const [email, setEmail] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [message, setMessage] = useState<string | null>(null); + + + const [validToken, setValidToken] = useState(false); + const [error, setError] = useState<string | null>(null); + const navigate = useNavigate(); + const token = useParams().token; + + useEffect(() => { + + // const token = localStorage.setItem('token', token); + + if (token) { + axios + .get(`${loginServer}/recover-account?token=${token}`) + .then((res) => { + setEmail(res.data.message); + setValidToken(true); + }) + .catch((err) => { + setMessage( + err.response?.data?.message || "Invalid or expired token." + ); + }); + } else { + setMessage("Invalid request."); + } + }, [location, navigate]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setMessage(null); + setError(null); + + // Frontend validation for matching passwords + if (newPassword !== confirmPassword) { + setError("Passwords do not match"); + return; + } + + try { + const response = await axios.post( + `${loginServer}/reset-password`, + { + email, + newPassword, + } + ); + setMessage(response.data.message); + setTimeout(() => { + navigate("/login"); // Redirect to Login after success message + }, 1500); // Redirect after 1.5 seconds + } catch (err: any) { + setError(err.response?.data?.message || "An error occurred"); + } + }; + + return ( + <div className="auth__body"> + <div className="auth__form"> + <h2>Reset Password</h2> + {/* {!validToken ? ( + <div className="auth__error"></div> + ) : null} */} + {message && <div className="auth__success">{message}</div>} + {error && <div className="auth__error">{error}</div>} + {validToken && ( + + <form onSubmit={handleSubmit}> + + +<div className="auth__container"> + + <label> + <strong>New Password:</strong> + </label> + <input + type="password" + value={newPassword} + onChange={(e) => setNewPassword(e.target.value)} + placeholder="Enter new password" + required + className="auth__form-control" + /> +</div> + +<div className="auth__container"> + + <label> + <strong>Confirm Password:</strong> + </label> + <input + type="password" + value={confirmPassword} + onChange={(e) => setConfirmPassword(e.target.value)} + placeholder="Confirm new password" + required + className="auth__form-control" + /> +</div> + + + <button type="submit" className="button danger">Reset Password</button> + <br /> <br /> + <Link to="/login"> + <button className="button primary">Go to Login</button> + </Link> + + </form> + )} + + <div> + + </div> + </div> + </div> + ); +}; + +export default RecoverAccount; diff --git a/src/resetPassword.tsx b/src/resetPassword.tsx index 99a901459872a577a1af35668cd2f87e9732052a..ac11f96762c80b43b521aba01262b3a1fc75e96b 100644 --- a/src/resetPassword.tsx +++ b/src/resetPassword.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import axios from "axios"; -import "./styles/resetPassword.scss"; +import "./styles/auth.scss"; import { Link, useNavigate } from "react-router-dom"; // let uploadServer = "http://localhost:3001"; @@ -19,47 +19,37 @@ if (import.meta.env.VITE_LOGIN_SERVER !== undefined) { const ResetPassword: React.FC = () => { const [email, setEmail] = useState(""); - const [newPassword, setNewPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); const [message, setMessage] = useState<string | null>(null); const [error, setError] = useState<string | null>(null); - const navigate = useNavigate(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setMessage(null); setError(null); - // Frontend validation for matching passwords - if (newPassword !== confirmPassword) { - setError("Passwords do not match"); - return; - } - try { const response = await axios.post( - `${loginServer}/reset-password`, - { - email, - newPassword, - } + `${loginServer}/send-recovery-link`, + { + email, + } ); - setMessage(response.data.message); - setTimeout(() => { - navigate("/login"); // Redirect to Login after success message - }, 1500); // Redirect after 1.5 seconds + setMessage(response.data.message || "Recovery link sent successfully"); } catch (err: any) { setError(err.response?.data?.message || "An error occurred"); } }; return ( - <div className="reset-password__body"> - <div className="reset-password__form"> + <div className="auth__body"> + <div className="auth__form"> <h2>Reset Password</h2> - {message && <div className="reset-password__success">{message}</div>} - {error && <div className="reset-password__error">{error}</div>} + {message && <div className="auth__success">{message}</div>} + {error && <div className="auth__error">{error}</div>} <form onSubmit={handleSubmit}> + + <div className="auth__container"> + <label> <strong>Email:</strong> </label> @@ -69,37 +59,20 @@ const ResetPassword: React.FC = () => { onChange={(e) => setEmail(e.target.value)} placeholder="Enter your email" required + className="auth__form-control" /> - - <label> - <strong>New Password:</strong> - </label> - <input - type="password" - value={newPassword} - onChange={(e) => setNewPassword(e.target.value)} - placeholder="Enter new password" - required - /> - - <label> - <strong>Confirm Password:</strong> - </label> - <input - type="password" - value={confirmPassword} - onChange={(e) => setConfirmPassword(e.target.value)} - placeholder="Confirm new password" - required - /> - - <button type="submit">Reset Password</button> - </form> - - <div className="reset__buttons-container"> + </div> + + <button type="submit" className="button warning">Send Recovery Email</button> + <br /> <br /> <Link to="/login"> - <button className="reset__button">Go to Login</button> + <button className="button primary">Go to Login</button> </Link> + + </form> + + <div> + </div> </div> </div> diff --git a/src/signup.tsx b/src/signup.tsx index e59ac9bc99d87a331778d61f83cb265723b28126..497b78c9e83b9ee8980b71040ee865f92b4b480c 100644 --- a/src/signup.tsx +++ b/src/signup.tsx @@ -1,7 +1,7 @@ import React, { useState } from "react"; import { Link, useNavigate } from "react-router-dom"; -import "./styles/signup.scss"; -import validation from "./signupValidation"; +import "./styles/auth.scss"; +import validation from "./components/signupValidation"; import axios from "axios"; // let uploadServer = "http://localhost:3001"; @@ -51,10 +51,12 @@ const Signup: React.FC = () => { axios .post(`${loginServer}/signup`, formValues) .then(() => { - setSuccessMessage("You have successfully signed up! Redirecting..."); + setSuccessMessage( + "You have successfully signed up! Please verify your email." + ); setTimeout(() => { - navigate("/login"); // Redirect after 1.5 seconds - }, 1500); + navigate("/login"); // Redirect after 3 seconds + }, 3000); setName(""); setEmail(""); setPassword(""); @@ -79,19 +81,19 @@ const Signup: React.FC = () => { }; return ( - <div className="signup__body"> - <div className="signup__form"> + <div className="auth__body"> + <div className="auth__form"> <h2>Sign up</h2> - <div className="signup__container"> + <div className="auth__container"> {successMessage && ( - <div className="signup__success-message">{successMessage}</div> + <div className="auth__success-message">{successMessage}</div> )} {errorMessage && ( - <div className="signup__error-message">{errorMessage}</div> + <div className="auth__error-message">{errorMessage}</div> )} <form onSubmit={handleSubmit}> - <div className="signup__form-group"> - <label htmlFor="name" className="signup__label"> + <div className="auth__form-group"> + <label htmlFor="name" className="auth__label"> <strong>Username</strong> </label> <input @@ -100,15 +102,15 @@ const Signup: React.FC = () => { value={username} onChange={(e) => setName(e.target.value)} placeholder="Enter Username" - className="signup__form-control" + className="auth__form-control" /> {errors.username && ( - <span className="signup__text-danger">{errors.username}</span> + <span className="auth__text-danger">{errors.username}</span> )} </div> - <div className="signup__form-group"> - <label htmlFor="email" className="signup__label"> + <div className="auth__form-group"> + <label htmlFor="email" className="auth__label"> <strong>Email</strong> </label> <input @@ -117,15 +119,15 @@ const Signup: React.FC = () => { value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Enter Email" - className="signup__form-control" + className="auth__form-control" /> {errors.email && ( - <span className="signup__text-danger">{errors.email}</span> + <span className="auth__text-danger">{errors.email}</span> )} </div> - <div className="signup__form-group"> - <label htmlFor="password" className="signup__label"> + <div className="auth__form-group"> + <label htmlFor="password" className="auth__label"> <strong>Password</strong> </label> <input @@ -134,15 +136,15 @@ const Signup: React.FC = () => { value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Enter Password" - className="signup__form-control" + className="auth__form-control" /> {errors.password && ( - <span className="signup__text-danger">{errors.password}</span> + <span className="auth__text-danger">{errors.password}</span> )} </div> - <div className="signup__form-group"> - <label htmlFor="confirmPassword" className="signup__label"> + <div className="auth__form-group"> + <label htmlFor="confirmPassword" className="auth__label"> <strong>Confirm Password</strong> </label> <input @@ -151,37 +153,37 @@ const Signup: React.FC = () => { value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} placeholder="Confirm Password" - className="signup__form-control" + className="auth__form-control" /> {errors.confirmPassword && ( - <span className="signup__text-danger"> + <span className="auth__text-danger"> {errors.confirmPassword} </span> )} </div> {/* Terms and Conditions Checkbox */} - <div className="signup__terms"> + <div className="auth__terms"> <input type="checkbox" id="agreeToTerms" checked={agreeToTerms} onChange={() => setAgreeToTerms(!agreeToTerms)} /> - <label htmlFor="agreeToTerms" className="signup__terms-label"> - I agree to the <Link to="/terms">Terms and Conditions</Link> + <label htmlFor="agreeToTerms" className="auth__terms-label"> + I agree to the <Link className="terms-text" to="/terms">Terms and Conditions</Link> </label> </div> - <div className="signup__buttons-container"> - <button + <div className="auth__buttons-container"> + <button type="submit" - className="signup__btn signup__btn--success" + className={`button ${!agreeToTerms ? "greyed" : "success"}`} disabled={!agreeToTerms} // Disable the button if the checkbox is unchecked - > + > Sign up - </button> - <Link to="/login" className="signup__button"> + </button> + <Link to="/login" className="button primary"> Log in </Link> </div> diff --git a/src/styles/App.scss b/src/styles/App.scss index cd83b268b4482247c914bd2614a6781de3eb3cdd..5bedfe5c71aba2dfc64c6a411d16acc38e418049 100644 --- a/src/styles/App.scss +++ b/src/styles/App.scss @@ -1,65 +1,137 @@ @use 'global.scss' as *; body { - display: flex; + // display: flex; } +.app{ + animation: fade-in 0.3s ease-in forwards; +} .app-container { + margin-top: 40px; position: relative; // Ensure container acts as a positioning context width: 90vw; - height: 80vh; // Ensure enough height for content - background: white; - border: 1px solid #ddd; + height: 75vh; + // height: 100%; + margin-left: auto; + margin-right: auto; + // background-color: blue; + // background: $app-background; + border: 2px solid #ddd; border-radius: 10px; padding: 20px; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); - display: flex; // Flexbox to center the video player - flex-direction: column; // Stack items vertically + // box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + + display: grid; + flex-direction: row; // Stack items vertically justify-content: center; // Vertically center the video player align-items: center; // Horizontally center the video player + grid-template-columns: 2fr 1fr; + // grid-gap: 20px; } .video-player { - background: #e0ffe0; - width: 100%; // Adjust width to fit container if necessary - height: 80vh; + // // background: green; + // // border: 2px solid #ddd; + // width: 100%; // Adjust width to fit container if necessary + // height: 70vh; + // display: flex; + // flex-direction:column; + // justify-content: center; + // align-items: center; + // border-radius: 10px; + // // margin-bottom: 20px; // Add spacing for other content + width: 100%; + height: 70vh; display: flex; - justify-content: center; + flex-direction: column; align-items: center; border-radius: 10px; - margin-bottom: 20px; // Add spacing for other content + flex-wrap: nowrap; + justify-content: flex-start; +} + +.video-details{ + opacity: 0; + animation: fade-in 0.3s 0.2s ease-in forwards; + align-items: flex-end; + justify-content: flex-end; + width: 100%; + margin: auto; + text-align: right; + vertical-align: top; + display: flex; + flex-direction: column; + // flex-direction: column; + margin-right: 15px; + .details-metadata { + display: flex; + flex-direction: column; + color: white; + } } .controls { + margin-top: 30px; width: 100%; // Ensure controls take up full width display: flex; justify-content: space-between; align-items: center; gap: 10px; + opacity: 0; + animation: fade-in 0.3s 0.2s ease-in forwards; } +.video-stats{ + *{ + margin: 0px 5px; + } +} +.download-next{ + *{ + margin: 0px 5px; + } + +} /* Control Button Styling */ -.control-button { - background-color: #065527; - border-radius: 19px; - border: 2px solid #4e6096; - display: inline-block; - cursor: pointer; - color: #ffffff; - font-family: Courier New; +// .control-button { +// // background-color: #065527; +// border-radius: 19px; +// border: 2px solid #4e6096; +// // display: inline-block; +// cursor: pointer; +// color: #ffffff; +// font-size: 16px; +// font-weight: bold; +// padding: 16px 31px; +// text-decoration: none; +// text-shadow: 0px 0px 13px #283966; +// width: auto; // Let buttons size to their content +// min-width: 120px; // Optional: Ensure a minimum width +// display: inline-block; +// transition: all 0.3s ease; + +// &:hover { +// background-color: $button-hover-color; +// } +// } + + + +.views{ + border-radius: 25px; + // border: 3px solid #ddd; + // display: inline-block; + color: #ddd; font-size: 16px; font-weight: bold; - padding: 16px 31px; + padding: 16px 25px; text-decoration: none; text-shadow: 0px 0px 13px #283966; width: auto; // Let buttons size to their content min-width: 120px; // Optional: Ensure a minimum width - display: inline-block; + // display: inline-block; transition: all 0.3s ease; - - &:hover { - background-color: $button-hover-color; - } } /* Our new user button */ @@ -83,6 +155,57 @@ body { } } + +.button.not-liked{ + &:hover{ + color: #f10372; + padding: 18px 35px; + } +} +.button.liked{ + background-color: #f10372; + border-radius: 25px; + border: 3px solid #ddd; + // display: inline-block; + // cursor: pointer; + // color: #ffffff; + // font-size: 16px; + // font-weight: bold; + // padding: 16px 30px; + // text-decoration: none; + // text-shadow: 0px 0px 13px #283966; + // width: auto; // Let buttons size to their content + // min-width: 120px; // Optional: Ensure a minimum width + // // display: inline-block; + transition: all 0.3s ease; + + &:hover { + background-color: #ff75b6; + + } +} +// .like-button.not-liked{ +// background-color: #065527; +// border-radius: 19px; +// border: 2px solid #4e6096; +// // display: inline-block; +// cursor: pointer; +// color: #ffffff; +// font-size: 16px; +// font-weight: bold; +// padding: 16px 31px; +// text-decoration: none; +// text-shadow: 0px 0px 13px #283966; +// width: auto; // Let buttons size to their content +// min-width: 120px; // Optional: Ensure a minimum width +// display: inline-block; +// transition: all 0.3s ease; + +// &:hover { +// background-color: $button-hover-color; +// } +// } + .upload-section { position: absolute; // Allows precise positioning within app-container top: 20px; // Align to the top of app-container @@ -122,6 +245,33 @@ body { } } + +@keyframes fade-in { + 0% { + opacity: 0; // Fully transparent + } + 100% { + opacity: 1; // Fully visible + } +} + +@keyframes fade-out { + 0% { + opacity: 1; // Fully visible + } + 100% { + opacity: 0; // Fully transparent + } +} + +.fade-out { + animation: fade-out 0.1s ease-out forwards; +} + +.fade-in{ + animation: fade-in 0.1s ease-in forwards; +} + .upload-button { background-color: #065527; border-radius: 19px; @@ -129,7 +279,6 @@ body { display: inline-block; cursor: pointer; color: #ffffff; - font-family: Courier New; font-size: 16px; font-weight: bold; padding: 16px 31px; @@ -148,6 +297,65 @@ body { } } + +#video{ + max-height: 65vh; + // width: 90vw; + max-width: 60vw; +} + +@media screen and (max-width: 870px) { + .button.not-liked{ + &:hover{ + color: #f10372; + padding: 15px 20px; + } + } + .app-container { + width: 90vw; + height: 100%; + margin: 0 auto; + margin-top: 25px; + padding: 10px; + grid-template-columns: 1fr; // Stack items vertically + // justify-content: center; + } + + .video-player { + height: 70vh; // Adjust height for smaller screens + #video{ + max-height: 60vh; + max-width: 90vw; + } + } + + .video-details { + display: block; + text-align: center; // Center-align text for better readability + margin-top: 10px; + + .details-metadata { + grid-template-rows: auto; // Adjust layout for smaller screens + } + } + + // .controls { + // flex-direction: column; // Stack controls vertically + // gap: 15px; + // } + + .button, .user-button, .upload-button { + font-size: 14px; // Reduce font size for smaller screens + padding: 10px 20px; + } + + .upload-section, .login-button-section, .back-button-section { + position: static; // Remove absolute positioning for better layout + margin: 10px 0; + text-align: center; + } +} + .comment-section{ position:fixed; max-width: 250px; diff --git a/src/styles/User.scss b/src/styles/User.scss index 34f5a09cb19a1597edf39bfc2e18b22341bd4cf1..bcfce6ef2c96c69f5f8f1198270267cfd2588449 100644 --- a/src/styles/User.scss +++ b/src/styles/User.scss @@ -3,10 +3,10 @@ // Outer wrapper for the entire user page (full viewport) .user-page-wrapper { position: relative; - width: 100vw; - min-width: 100vw; - height: 100vh; - display: flex; + // width: 100vw; + // min-width: 100vw; + height: 70vh; + display: block; justify-content: center; // Centers content horizontally align-items: center; // Centers content vertically } @@ -17,6 +17,8 @@ flex-direction: column; align-items: center; // Centers children horizontally transition: filter 0.3s ease, transform 0.2s ease-in-out; + margin: 0 auto; + max-width: 80vw; // Blur effect when a video is opened in fullscreen &.blur { @@ -48,25 +50,51 @@ // Content box with elevation and rounded corners .content-container { - display: flex; - min-width: 1000px; + // display: flex; + // min-width: 1000px; flex-direction: column; align-items: center; - background-color: white; - padding: 20px; + // background-color: white; + border: 2px solid white; + // margin: auto; + // padding: 20px; + height: 70vh; border-radius: 10px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); } +.logout__section{ + width: 90vw; + height: 60px; + margin: 10px 10px; + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: flex-end; +} // "My Videos" section title .my-videos-container { + display: flex; + + padding: 0px 10px; // background-color: darkgreen; - color: rgb(37, 37, 37); - padding: 10px 20px; + // display: flex; + justify-content: space-between; + color: white; + // padding: 10px 20px; border-radius: 5px; - margin-bottom: 20px; + // gap: 50vw; // Adds space between elements + // margin-bottom: 10px; font-size: 1.4rem; font-weight: bold; + .button{ + font-size: 1.5rem; + height: 40px; + width: 80px; + // padding: 2px 15px; + text-align: center; + padding: auto; + } } // Video grid container with smooth page transition effect @@ -74,11 +102,13 @@ display: grid; grid-template-columns: repeat(3, 1fr); // 3-column layout gap: 2%; - background-color: #c9d6c9; - width: 100%; - min-width: 100%; - height: 73vh; - min-height: 73vh; + // background-color: #3b7543af; + backdrop-filter: blur(5px); // Adds a subtle background blur effect + width: 90vw; + // height: 73vh; + // min-height: 73vh; + max-height: 40vh; + padding: 5px; border-radius: 10px; padding: 10px; box-sizing: border-box; @@ -217,22 +247,21 @@ // Mobile Responsiveness Adjustments @media (max-width: 768px) { + .my-videos-container { + font-size: 1.2rem; // Adjust font size for smaller screens + display: block; + } .video-grid { grid-template-columns: repeat(2, 1fr); // Adjust to 2 columns for tablets + grid-auto-rows: auto; // Automatically create new rows for video thumbnails } - .home-button { - width: 100%; // Full-width button for easier tap access - } + // .home-button { + // width: 100%; // Full-width button for easier tap access + // } } -@media (max-width: 480px) { - .video-grid { - grid-template-columns: repeat( - 1, - 1fr - ); // Show only 1 video per row for mobile - } +@media (max-width: 670px) { .user-container { padding: 10px; // Reduce padding to better fit small screens diff --git a/src/styles/auth.scss b/src/styles/auth.scss new file mode 100644 index 0000000000000000000000000000000000000000..9117e1c17d179913f94b2b1f28f3f47fef5e4f49 --- /dev/null +++ b/src/styles/auth.scss @@ -0,0 +1,171 @@ +@use 'global.scss' as *; + +.auth__body{ + margin-top: 75px; + width: 90vw; + max-height: 90vh; + margin: 0px auto; + display: flex; + flex-direction: rows; + color: white; +} + +.auth__container { + margin-bottom: 1rem; + width: 100%; + } +.auth__form{ + // display: flex; +// flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + // background-image: $backgroundImageUrl; + background-position: top; + background-repeat: no-repeat; +// background-size: 300px; + // background-color: #e0f7e0; + + padding: 20px; + max-height: 40vh; +} + + +.auth__form-control { + width: 90%; + padding: 12px; + margin-top: 8px; + margin-bottom: 15px; + border: 1px solid #ccc; + border-radius: 5px; + font-size: 1rem; + color: #ddd; + background-color: #000; + } + +.auth__title{ + font-size:3rem; + // color: $uploadColor; + margin-bottom: 20px; + // text-align: center; + } + + + .auth__text-danger { + color: #e74c3c; + font-size: 0.875rem; + } + + .auth__btn { + // width: 100%; + padding: 12px; + border-radius: 5px; + cursor: pointer; + font-size: 1rem; + color: white; + border: none; + transition: background-color 0.3s ease; + // margin-bottom: 1rem; + } + + .auth__btn--success { + background-color: #28a745; + } + + .auth__btn--success:hover { + background-color: #218838; + } + + .auth__btn--home{ + background-color: #235523; + } + + .auth__btn--home:hover{ + background-color: #4b954b; + } + + .auth__buttons-container { + display: flex; + flex-direction: column; + // align-items: center; + // justify-content: center; + // width: 100%; + gap: 10px; + } + + .auth__button { + // width: 95%; + display: inline-block; + margin-top: 1rem; + padding: 10px; + border: none; + background-color: #007bff; + color: white; + font-size: 1rem; + cursor: pointer; + border-radius: 5px; + text-align: center; + text-decoration: none; + } + + .auth__button:hover { + background-color: #0056b3; + } + + .auth__success-message { + font-size: 1rem; + color: #ddd; + // background-color: #eaf7ea; + border: 3px solid #28a745; + padding: 10px; + border-radius: 15px; + margin-bottom: 20px; + text-align: center; + } + + .terms-text{ + color: #ddd; + &:visited{ + color: #ddd; + } + + } + + + .auth__terms { + display: inline-flex; + align-items: center; + margin-bottom: 1rem; + width: 75%; + } + + .auth__terms-label { + font-size: 14px; + margin: 0; + white-space: nowrap; + padding-left: 8px; + } + + .auth__label { + font-size: 1rem; + color: #ddd; + } + button{ + text-align: left; + } + +.verify-email__container{ + color: #ddd; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + background-position: top; + background-repeat: no-repeat; + padding: 20px; + max-height: 40vh; + +} + +// http://localhost:5173/verify-email/eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6Impvc2gzQGpvc2hyYW5kYWxsLm5ldCIsImlhdCI6MTc0MjM0NDc2MiwiZXhwIjoxNzQyNDMxMTYyfQ.HAVUd4k3iFVUfqh4Ek4PnqHnKZV_0iiIgVCZ90qskD8 diff --git a/src/styles/global.scss b/src/styles/global.scss index bb53220aa3a6ece2ddbc590afac23e90a9c65426..2e38addf93e10c529f34f9cbc7517f8bf85ab889 100644 --- a/src/styles/global.scss +++ b/src/styles/global.scss @@ -1,9 +1,15 @@ // Color Variables -$background-color: #245526; -$button-color: #1d4d2f; -$button-hover-color: #87b8da; -$text-color: #333; -$uploadColor: red; +@import url('https://fonts.googleapis.com/css2?family=Prompt:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap'); + +*{ + font-family: "Prompt", sans-serif; +} +$background-color: #000; +$app-background: #fff; +$button-color: #0db7fa; +$button-hover-color: #b9b9b9; +$text-color: #000000; +$uploadColor: rgb(0, 191, 255); body { margin: 0; @@ -14,4 +20,111 @@ body { justify-content: center; align-items: center; height: 100vh; - } \ No newline at end of file + + } + +.button{ + cursor: pointer; + border-radius: 25px; + border: 3px solid #ddd; + // display: inline-block; + background: none; + color: #ddd; + font-size: 16px; + font-weight: bold; + padding: 16px 30px; + text-decoration: none; + text-shadow: 0px 0px 13px #283966; + width: auto; // Let buttons size to their content + height: auto; + // min-width: 120px; // Optional: Ensure a minimum width + // display: inline-block; + transition: all 0.3s ease; + + &:hover { + color: $button-hover-color; + background: none; + border: 3px solid $button-hover-color; + } +} + +.button.success{ + // background: #28a745; + color: #fff; + border: 3px solid #28a745; + &:hover{ + background: #28a745; + color: #fff; + border: 3px solid #28a745; + } +} +.button.danger{ + // background: #e74c3c; + color: #fff; + border: 3px solid #e74c3c; + &:hover{ + background: #e74c3c; + color: #fff; + border: 3px solid #e74c3c; + } +} + +.button.primary{ + // background: #007bff; + color: #fff; + border: 3px solid #007bff; + &:hover{ + background: #007bff; + color: #fff; + border: 3px solid #007bff; + } +} + +.button.warning{ + // background: #ffc107; + color: #fff; + border: 3px solid #ffc107; + &:hover{ + background: #ffc107; + color: #fff; + border: 3px solid #ffc107; + } +} + +.button.greyed{ + cursor:default; + color: #4b4b4b; + border: 3px solid #615e5e; +} + +.center-container{ + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + margin: auto; + margin-top: 200px; +} + +.mobile__text{ + display: none; +} + +@media screen and (max-width: 800px){ + .center-container{ + margin-top: 300px; + } +} + +@media screen and (max-width: 1250px){ + .desktop__text{ + display:none; + } + .mobile__text{ + display: block; + } + .button{ + padding: 10px; + border-radius: 25px; + } +} diff --git a/src/styles/login.scss b/src/styles/login.scss deleted file mode 100644 index 530d1bee36eaa5fcc6de77b671450d9ca179f578..0000000000000000000000000000000000000000 --- a/src/styles/login.scss +++ /dev/null @@ -1,147 +0,0 @@ -@use 'global.scss' as *; - -.login__body { - display: flex; - align-items: center; - justify-content: center; - height: 100vh; - background: $background-color; - margin: 0; -} - -.login__form { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - border-radius: 10px; - padding: 40px; - background: white; - box-shadow: 0px 10px 20px rgba(0, 0, 0, 0.1); - width: 100%; - max-width: 400px; -} - -.login__form h2 { - font-size: 1.8rem; - color: #333; - margin-bottom: 20px; -} - -.login__container { - margin-bottom: 1rem; - width: 100%; -} - -.login__label { - font-size: 1rem; - color: #333; -} - -.login__form-control { - width: 90%; - padding: 12px; - margin-top: 8px; - margin-bottom: 15px; - border: 1px solid #ccc; - border-radius: 5px; - font-size: 1rem; -} - -.login__form-control:focus { - outline: none; - border-color: #28a745; - box-shadow: 0 0 5px rgba(40, 167, 69, 0.5); -} - -.login__text-danger { - color: #e74c3c; - font-size: 0.875rem; -} - -.login__btn { - width: 100%; - padding: 12px; - border-radius: 5px; - cursor: pointer; - font-size: 1rem; - color: white; - border: none; - transition: background-color 0.3s ease; - margin-bottom: 1rem; -} - -.login__btn--success { - background-color: #28a745; -} - -.login__btn--success:hover { - background-color: #218838; -} - -.login__btn--home{ - background-color: #235523; -} - -.login__btn--home:hover{ - background-color: #4b954b; -} - -.login__buttons-container { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - width: 100%; - gap: 10px; -} - -.login__button { - width: 95%; - display: inline-block; - margin-top: 1rem; - padding: 10px; - border: none; - background-color: #007bff; - color: white; - font-size: 1rem; - cursor: pointer; - border-radius: 5px; - text-align: center; - text-decoration: none; -} - -.login__button:hover { - background-color: #0056b3; -} - -.login__success-message { - font-size: 1rem; - color: #28a745; - background-color: #eaf7ea; - padding: 10px; - border-radius: 5px; - margin-bottom: 20px; - text-align: center; -} - -@media (max-width: 768px) { - .login__form { - padding: 20px; - width: 90%; - } - - .login__form h2 { - font-size: 1.6rem; - } - - .login__form-control { - padding: 10px; - font-size: 0.9rem; - } - - .login__btn { - padding: 10px; - font-size: 0.9rem; - } -} diff --git a/src/styles/resetPassword.scss b/src/styles/resetPassword.scss deleted file mode 100644 index 229b34e3403fddebdf5f8e1e1077301651719d46..0000000000000000000000000000000000000000 --- a/src/styles/resetPassword.scss +++ /dev/null @@ -1,87 +0,0 @@ -@use 'global.scss' as *; - -.reset-password__body { - width: 100%; - display: flex; - justify-content: center; - align-items: center; - min-height: 100vh; - background-color: $background-color; -} - -.reset-password__form { - background: white; - padding: 20px; - border-radius: 10px; - box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); - width: 320px; - text-align: left; -} - -input { - width: 90%; - padding: 10px; - margin: 8px 0; - border: 1px solid #ccc; - border-radius: 5px; -} - -.login-buttons { - background-color: #4caf50; - color: white; - border: none; - font-size: 1rem; - border-radius: 5px; - cursor: pointer; - transition: background-color 0.3s ease; -} - -button:hover { - background-color: #45a049; -} - -.reset-password__success { - font-size: 1rem; - color: #28a745; - background-color: #eaf7ea; - padding: 10px; - border-radius: 5px; - margin-bottom: 20px; - text-align: center; -} - -.reset-password__error { - color: #ff4d4f; - background-color: #ffeaea; - padding: 10px; - margin-bottom: 15px; - border: 1px solid #ff4d4f; - border-radius: 4px; - text-align: center; - font-weight: bold; -} - -.reset__buttons-container { - display: flex; - flex-direction: column; - justify-content: center; - width: 100%; -} - -.reset__button { - width: 97%; - display: inline-block; - margin-top: 1rem; - padding: 10px; - border: none; - background-color: #007bff; - color: white; - font-size: 1rem; - cursor: pointer; - border-radius: 5px; - text-align: center; -} - -.reset__button:hover { - background-color: #0056b3; -} diff --git a/src/styles/signup.scss b/src/styles/signup.scss deleted file mode 100644 index aa53a93c662140cae7eeb261ed0f97bd4f94bfc9..0000000000000000000000000000000000000000 --- a/src/styles/signup.scss +++ /dev/null @@ -1,167 +0,0 @@ -@use 'global.scss' as *; - -// signup.scss - -.signup__body { - display: flex; - align-items: center; - justify-content: center; - height: 100vh; - background: $background-color; - margin: 0; -} - -.signup__form { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - border-radius: 10px; - padding: 40px; - background: white; - box-shadow: 0px 10px 20px rgba(0, 0, 0, 0.1); - width: 100%; - max-width: 400px; -} - -.signup__form h2 { - font-size: 1.8rem; - color: #333; - margin-bottom: 20px; -} - -.signup__container { - margin-bottom: 1rem; - width: 100%; -} - -.signup__label { - font-size: 1rem; - color: #333; -} - -.signup__form-control { - width: 90%; - padding: 12px; - margin-top: 8px; - margin-bottom: 15px; - border: 1px solid #ccc; - border-radius: 5px; - font-size: 1rem; -} - -.signup__form-control:focus { - outline: none; - border-color: #28a745; - box-shadow: 0 0 5px rgba(40, 167, 69, 0.5); -} - -.signup__text-danger { - color: #e74c3c; - font-size: 0.875rem; -} - -.signup__btn { - width: 100%; - padding: 12px; - border-radius: 5px; - cursor: pointer; - font-size: 1rem; - color: white; - border: none; - transition: background-color 0.3s ease; - margin-bottom: 1rem; -} - -.signup__btn--success { - background-color: #28a745; -} - -.signup__btn--success:hover { - background-color: #218838; -} - -.signup__buttons-container { - display: flex; - flex-direction: column; - justify-content: center; - width: 100%; -} - -.signup__button { - display: inline-block; - margin-top: 1rem; - padding: 10px; - border: none; - background-color: #007bff; - color: white; - font-size: 1rem; - cursor: pointer; - border-radius: 5px; - text-align: center; - text-decoration: none; -} - -.signup__button:hover { - background-color: #0056b3; -} - -.signup__success-message { - font-size: 1rem; - color: #28a745; - background-color: #eaf7ea; - padding: 10px; - border-radius: 5px; - margin-bottom: 20px; - text-align: center; -} - -@media (max-width: 768px) { - .signup__form { - padding: 20px; - width: 90%; - } - - .signup__form h2 { - font-size: 1.6rem; - } - - .signup__form-control { - padding: 10px; - font-size: 0.9rem; - } - - .signup__btn { - padding: 10px; - font-size: 0.9rem; - } -} -.signup__error-message { - color: #ff4d4f; - background-color: #ffeaea; - padding: 10px; - margin-bottom: 15px; - border: 1px solid #ff4d4f; - border-radius: 4px; - text-align: center; - font-weight: bold; -} -.signup__btn:disabled { - background-color: #ccc; - cursor: not-allowed; - opacity: 0.7; -} - -.signup__terms { - display: inline-flex; - align-items: center; - margin-bottom: 1rem; - width: 75%; -} - -.signup__terms-label { - font-size: 14px; - margin: 0; - white-space: nowrap; - padding-left: 8px; -} diff --git a/src/styles/topbar.scss b/src/styles/topbar.scss new file mode 100644 index 0000000000000000000000000000000000000000..5afd55ef576995ffc6e231db9e69cccd94490319 --- /dev/null +++ b/src/styles/topbar.scss @@ -0,0 +1,69 @@ +@use 'global.scss' as *; + +@keyframes dropdownFadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.topbar{ + // display: flex; + flex-direction: row; + z-index: 3; + background-color: rgba(52, 53, 52, 0.9); + backdrop-filter: blur(10px); + width: 100%; + height: 100px; + // margin-top: 100px; + position: sticky; + top: 0; + color: white; + animation: dropdownFadeIn 0.5s ease-out forwards; +} + +.topbar__container{ + height: 100px; + display: flex; + // justify-content: space-between; + + // padding: 20px; + margin: 0px 40px; +} + +.topbar__logo { + cursor:pointer; + margin-right: auto; + margin-top: 10px; + transition: all 0.3s ease; + &:hover{ + color: rgb(168, 224, 161); + } +} + +// .topbar__menu { +// justify-content: center; +// } +.link__items{ + display:flex; + flex-direction: row; + margin-top: 35px; + // margin:auto; + li{ + + list-style: none; + margin: auto; + padding: 5px; + } +} + +@media screen and (max-width: 500px) { + .persist{ + display: flex; + } +} + diff --git a/src/styles/upload.scss b/src/styles/upload.scss index 5e6211dc9a3fd6b543598d1728861c30f79e5754..6ceb5b4cffa192d1469ba9e87cb15747467276ad 100644 --- a/src/styles/upload.scss +++ b/src/styles/upload.scss @@ -3,25 +3,42 @@ $uploadColor: #4CAF50; $backgroundImageUrl: url('src/assets/blob.png'); -.upload-container { + +.upload-app{ + margin-top: 75px; + width: 90vw; + max-height: 90vh; + margin: 0px auto; display: flex; - flex-direction: column; + flex-direction: rows; + color: white; + +} +.upload-container { + // display: flex; + // flex-direction: column; align-items: center; justify-content: center; height: 100vh; - // background-image: $backgroundImageUrl; background-position: top; background-repeat: no-repeat; - background-size: 300px; - background-color: #e0f7e0; padding: 20px; + max-height: 40vh; +} + +.upload-banner{ + display: flex; + flex-direction: column; + // justify-content: space-between; + // gap: 50px; + text-align: left; } .upload-title { - font-size: 2.5rem; - color: $uploadColor; + font-size:3rem; + // color: $uploadColor; margin-bottom: 20px; - text-align: center; + // text-align: center; } .back-button { @@ -49,10 +66,56 @@ $backgroundImageUrl: url('src/assets/blob.png'); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); } +// FileUploader.tsx +.upload-container { + padding: 20px; + max-width: 600px; +} +.form-group { + margin-bottom: 15px; +} +.form-group label { + display: inline-block; + width: 100px; +} +.progress-container { + margin-top: 20px; +} +.progress-bar-container { + width: 100%; + height: 20px; + background-color: #f0f0f0; + border-radius: 10px; + overflow: hidden; + margin-bottom: 10px; +} +.progress-bar { + height: 100%; + background-color: #4caf50; + transition: width 0.3s ease; +} +.progress-message { + margin-bottom: 5px; + font-weight: bold; +} +.info-text { + font-size: 0.8rem; + color: #666; +} + + @media (max-width: 768px) { .upload-title { font-size: 2rem; } + .upload-banner{ + width: 80vw; + } + .upload-container { + font-size: 1rem; + width: 80vw; + // overflow-x: hidden; + } .back-button { padding: 8px 16px; diff --git a/src/terms.tsx b/src/terms.tsx index 12c28efde6c2115faaa6d11e122c1fe41f9535c8..24ce7c9e5e53132b1065a7406e24109ba386957a 100644 --- a/src/terms.tsx +++ b/src/terms.tsx @@ -1,14 +1,17 @@ import { useNavigate } from 'react-router-dom'; -import './styles/signup.scss'; +import './styles/auth.scss'; function Terms(){ const navigate = useNavigate(); return( - <div> + <main> + <div className="center-container"> <h3 style={{ color: 'white' }}>No illegal content, otherwise go nuts</h3> - <button className="signup__button" onClick={() => navigate('/signup')}>Back to Signup</button> + <button className="button primary" onClick={() => navigate('/signup')}>Back to Signup</button> </div> + </main> + ) } diff --git a/src/upload.tsx b/src/upload.tsx index f5e6432dfc31bc740a91a91599e64980881f6814..022552d8e82fe036a7251e8d2c71b6c8c921eb52 100644 --- a/src/upload.tsx +++ b/src/upload.tsx @@ -4,6 +4,7 @@ import "./styles/upload.scss"; // Import the updated styles import FileUploader from "./components/FileUploader"; import { useState, useEffect } from 'react'; // React hook for managing state import axios from "axios"; +import App from "./App"; let uploadServer = "http://localhost:3001"; @@ -65,19 +66,19 @@ useEffect(() => { getUsername(userID); },) return ( - <div className="upload-container"> - <h3 style={{ color: "green" }}>Disclaimer: The host is not responsible for any content on this site.</h3> - <button - className="back-button" - onClick={() => (window.location.href = "/")} - > - Home - </button> - <h4>Engager: <span>{username}</span></h4> - <h1 className="upload-title">Upload Your Video</h1> - <FileUploader /> - <p>Transcoding is now done server side. Max file size is 80MB.</p> + <div className="upload-app"> + <div className="upload-container"> + <div className="upload-banner"> + <h1 className="upload-title">Upload Your Video</h1> + <h3>Disclaimer: The host is not responsible for any content on this site.</h3> + <p>Max file size is 80MB.</p> + </div> + <div className="uploader"> + <FileUploader /> + </div> </div> + </div> + ); } diff --git a/tests/like.test.tsx b/tests/like.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1cb118206f50d053adf51cbec7a76bbd4d8d97dd --- /dev/null +++ b/tests/like.test.tsx @@ -0,0 +1,152 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import "@testing-library/jest-dom"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import axios from "axios"; +import LikeButton from "../src/likeButton"; + +// Mock axios +vi.mock("axios"); + +describe("Like Functionality", () => { + const originalEnv = process.env; + const mockLoginServer = "http://test-login-server"; + + beforeEach(() => { + // Mock environment variables + vi.stubEnv("VITE_UPLOAD_SERVER", "http://test-upload-server"); + vi.stubEnv("VITE_LOGIN_SERVER", mockLoginServer); + + // Mock localStorage + Object.defineProperty(window, "localStorage", { + value: { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + }, + writable: true, + }); + + // Mock localStorage for logged-in user + vi.spyOn(window.localStorage, "getItem").mockImplementation((key) => { + if (key === "authToken") return "mock-token"; + return null; + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + process.env = originalEnv; + }); + // Test 1: Test case for like functionality for logged-in user + it("should call like-video API when like button is clicked by logged-in user", async () => { + // Mock responses for API calls + vi.mocked(axios.get).mockImplementation((url) => { + if (url.includes("/video-likes-by-filename/")) { + return Promise.resolve({ data: { likeCount: 5 } }); + } else if (url.includes("/check-like-status")) { + return Promise.resolve({ data: { liked: false } }); + } + return Promise.resolve({ data: {} }); + }); + + vi.mocked(axios.post).mockImplementation((url) => { + if (url.includes("/like-video")) { + return Promise.resolve({ + data: { message: "Video liked successfully" }, + }); + } + return Promise.resolve({ data: {} }); + }); + + // Render just the LikeButton component (avoiding the whole App with router) + render( + <LikeButton + fileName="video1.mp4" + loggedIn={true} + userId={123} + initialLikeCount={5} + loginServer={mockLoginServer} + /> + ); + + // Find and click the like button + const likeButton = screen.getByTestId("like-button"); + fireEvent.click(likeButton); + + // Verify that the like API was called + await waitFor(() => { + expect(axios.post).toHaveBeenCalledWith( + `${mockLoginServer}/like-video`, + { fileName: "video1.mp4" }, + { params: { auth: "mock-token" } } + ); + }); + }); + // Test 2: Test case for like functionality for logged-out user + it("should show alert if user is not logged in", async () => { + // Mock alert + const alertMock = vi.spyOn(window, "alert").mockImplementation(() => {}); + + render( + <LikeButton + fileName="video1.mp4" + loggedIn={false} + userId={0} + initialLikeCount={5} + loginServer={mockLoginServer} + /> + ); + + // Find and click the like button + const likeButton = screen.getByTestId("like-button"); + fireEvent.click(likeButton); + + // Verify alert was shown + expect(alertMock).toHaveBeenCalledWith( + "You must be logged in to like videos." + ); + }); + + // Test 3: Test case for updating UI after liking a video + it("should update UI after liking a video", async () => { + // Mock responses + vi.mocked(axios.get).mockImplementation((url) => { + if (url.includes("/video-likes-by-filename/")) { + return Promise.resolve({ data: { likeCount: 5 } }); + } else if (url.includes("/check-like-status")) { + return Promise.resolve({ data: { liked: false } }); + } + return Promise.resolve({ data: {} }); + }); + + vi.mocked(axios.post).mockImplementation((url) => { + if (url.includes("/like-video")) { + return Promise.resolve({ + data: { message: "Video liked successfully" }, + }); + } + return Promise.resolve({ data: {} }); + }); + + render( + <LikeButton + fileName="video1.mp4" + loggedIn={true} + userId={123} + initialLikeCount={5} + loginServer={mockLoginServer} + /> + ); + + // Find and click the like button + const likeButton = screen.getByTestId("like-button"); + fireEvent.click(likeButton); + + // Verify that the UI updates + await waitFor(() => { + expect(likeButton).toHaveStyle("color: rgb(255, 0, 0)"); + expect(likeButton).toHaveTextContent("6 Likes"); + }); + }); +});