Browse Source

Merge pull request 'features/playlist' (#4) from features/playlist into developpement

Reviewed-on: #4
features/visibility
astria 4 months ago
parent
commit
c837382d56
  1. 58
      backend/app/controllers/playlist.controller.js
  2. 115
      backend/app/controllers/user.controller.js
  3. 6
      backend/app/routes/user.route.js
  4. 42
      backend/app/utils/database.js
  5. 30
      backend/app/utils/mail.js
  6. 1937
      backend/logs/access.log
  7. 10
      backend/package-lock.json
  8. 1
      backend/package.json
  9. 16
      docker-compose.yaml
  10. BIN
      frontend/src/assets/img/background.png
  11. 4
      frontend/src/assets/svg/eye-slash.svg
  12. 4
      frontend/src/assets/svg/eye.svg
  13. 1
      frontend/src/assets/svg/trash.svg
  14. 1
      frontend/src/components/Navbar.jsx
  15. 2
      frontend/src/components/PlaylistCard.jsx
  16. 17
      frontend/src/components/PlaylistVideoCard.jsx
  17. 4
      frontend/src/components/TrendingVideos.jsx
  18. 42
      frontend/src/components/VideoCard.jsx
  19. 4
      frontend/src/contexts/AuthContext.jsx
  20. 46
      frontend/src/modals/CreatePlaylistModal.jsx
  21. 26
      frontend/src/modals/EmailVerificationModal.jsx
  22. 27
      frontend/src/modals/VerificationModal.jsx
  23. 286
      frontend/src/pages/Account.jsx
  24. 48
      frontend/src/pages/Login.jsx
  25. 98
      frontend/src/pages/Playlist.jsx
  26. 118
      frontend/src/pages/Register.jsx
  27. 162
      frontend/src/pages/Video.jsx
  28. 9
      frontend/src/routes/routes.jsx
  29. 104
      frontend/src/services/playlist.service.js
  30. 22
      frontend/src/services/user.service.js

58
backend/app/controllers/playlist.controller.js

@ -83,7 +83,63 @@ export async function getById(req, res) {
const logger = req.body.logger;
const client = await getClient();
const query = `SELECT * FROM playlists WHERE id = $1`;
const query = `
SELECT
playlists.id,
playlists.name,
COALESCE(
JSON_AGG(
JSON_BUILD_OBJECT(
'id', video_data.id,
'title', video_data.title,
'thumbnail', video_data.thumbnail,
'video_description', video_data.description,
'channel', video_data.channel,
'visibility', video_data.visibility,
'file', video_data.file,
'slug', video_data.slug,
'format', video_data.format,
'release_date', video_data.release_date,
'channel_id', video_data.channel,
'owner', channels.owner,
'views', CAST(video_data.views AS TEXT),
'creator', JSON_BUILD_OBJECT(
'name', channels.name,
'profilePicture', users.picture,
'description', channels.description
),
'type', 'video'
)
) FILTER (WHERE video_data.id IS NOT NULL),
'[]'::json
) AS videos
FROM
playlists
LEFT JOIN playlist_elements ON playlists.id = playlist_elements.playlist
LEFT JOIN (
SELECT
videos.id,
videos.title,
videos.description,
videos.thumbnail,
videos.release_date,
videos.visibility,
videos.file,
videos.slug,
videos.format,
videos.channel,
COUNT(history.id) AS views
FROM videos
LEFT JOIN history ON history.video = videos.id
GROUP BY videos.id, videos.title, videos.description, videos.thumbnail, videos.release_date, videos.visibility, videos.file, videos.slug, videos.format, videos.channel
) video_data ON playlist_elements.video = video_data.id
LEFT JOIN channels ON video_data.channel = channels.id
LEFT JOIN users ON channels.owner = users.id
WHERE
playlists.id = $1
GROUP BY
playlists.id;
`;
try {
const result = await client.query(query, [id]);

115
backend/app/controllers/user.controller.js

@ -4,6 +4,8 @@ import jwt from "jsonwebtoken";
import path, {dirname} from "path";
import fs from "fs";
import {fileURLToPath} from "url";
import crypto from "crypto";
import {sendEmail} from "../utils/mail.js";
export async function register(req, res) {
try {
@ -43,6 +45,84 @@ export async function register(req, res) {
const playlistQuery = `INSERT INTO playlists (name, owner) VALUES ('A regarder plus tard', $1)`;
await client.query(playlistQuery, [id]);
// GENERATE EMAIL HEXA VERIFICATION TOKEN
const token = crypto.randomBytes(32).toString("hex").slice(0, 5);
const textMessage = "Merci de vous être inscrit. Veuillez vérifier votre e-mail. Code: " + token;
const htmlMessage = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bienvenue sur Freetube</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
<div style="max-width: 600px; margin: 0 auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
<!-- Header -->
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 20px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 28px; font-weight: bold;">
🎬 Bienvenue sur Freetube!
</h1>
</div>
<!-- Content -->
<div style="padding: 40px 30px;">
<h2 style="color: #333333; margin-top: 0; font-size: 24px;">
Bonjour ${user.username}! 👋
</h2>
<p style="color: #666666; font-size: 16px; line-height: 1.6; margin: 20px 0;">
Merci de vous être inscrit sur <strong>Freetube</strong>! Nous sommes ravis de vous accueillir dans notre communauté.
</p>
<p style="color: #666666; font-size: 16px; line-height: 1.6; margin: 20px 0;">
Pour finaliser votre inscription, veuillez utiliser le code de vérification ci-dessous :
</p>
<!-- Verification Code Box -->
<div style="background-color: #f8f9fa; border: 2px dashed #667eea; border-radius: 8px; padding: 25px; text-align: center; margin: 30px 0;">
<p style="color: #333333; font-size: 14px; margin: 0 0 10px 0; text-transform: uppercase; letter-spacing: 1px;">
Code de vérification
</p>
<div style="background-color: #667eea; color: #ffffff; font-size: 32px; font-weight: bold; padding: 15px 25px; border-radius: 6px; letter-spacing: 3px; display: inline-block;">
${token}
</div>
</div>
<p style="color: #999999; font-size: 14px; line-height: 1.5; margin: 30px 0 10px 0;">
Ce code expirera dans <strong>1 heure</strong>.
</p>
<p style="color: #999999; font-size: 14px; line-height: 1.5; margin: 10px 0;">
Si vous n'avez pas créé de compte, vous pouvez ignorer cet e-mail.
</p>
</div>
<!-- Footer -->
<div style="background-color: #f8f9fa; padding: 20px 30px; border-top: 1px solid #eee;">
<p style="color: #999999; font-size: 12px; margin: 0; text-align: center;">
© 2025 Freetube. Tous droits réservés.
</p>
</div>
</div>
</body>
</html>
`;
console.log("Sending email to:", user.email);
await sendEmail(user.email, "🎬 Bienvenue sur Freetube - Vérifiez votre email", textMessage, htmlMessage);
// Store the token in the database
const expirationDate = new Date();
expirationDate.setHours(expirationDate.getHours() + 1); // Token expires in 1 hour
const insertQuery = `INSERT INTO email_verification (email, token, expires_at) VALUES ($1, $2, $3)`;
await client.query(insertQuery, [user.email, token, expirationDate]);
client.end();
console.log("Successfully registered");
client.end();
logger.write("successfully registered", 200);
@ -51,7 +131,42 @@ export async function register(req, res) {
console.log(err);
}
}
export async function verifyEmail(req, res) {
const { email, token } = req.body;
const logger = req.body.logger;
logger.action("try to verify email for " + email + " with token " + token);
const client = await getClient();
try {
const query = `SELECT * FROM email_verification WHERE email = $1 AND token = $2`;
const result = await client.query(query, [email, token]);
if (result.rows.length === 0) {
logger.write("failed to verify email for " + email, 404);
return res.status(404).json({ error: "Invalid token or email" });
}
// If we reach this point, the email is verified
const queryDelete = `DELETE FROM email_verification WHERE email = $1`;
await client.query(queryDelete, [email]);
const updateQuery = `UPDATE users SET is_verified = TRUE WHERE email = $1`;
await client.query(updateQuery, [email]);
logger.write("successfully verified email for " + email, 200);
res.status(200).json({ message: "Email verified successfully" });
} catch (error) {
console.error("Error verifying email:", error);
logger.write("failed to verify email for " + email, 500);
res.status(500).json({ error: "Internal server error" });
} finally {
client.end();
}
}
export async function login(req, res) {

6
backend/app/routes/user.route.js

@ -7,7 +7,8 @@ import {
update,
deleteUser,
getChannel, getHistory,
isSubscribed
isSubscribed,
verifyEmail
} from "../controllers/user.controller.js";
import {
UserRegister,
@ -54,4 +55,7 @@ router.get("/:id/history", [addLogger, isTokenValid, User.id, validator], getHis
// CHECK IF SUBSCRIBED TO CHANNEL
router.get("/:id/channel/subscribed", [addLogger, isTokenValid, User.id, Channel.id, validator], isSubscribed)
// VERIFY EMAIL
router.post("/verify-email", [addLogger, validator], verifyEmail);
export default router;

42
backend/app/utils/database.js

@ -23,7 +23,8 @@ export async function initDb() {
email VARCHAR(255) NOT NULL,
username VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL,
picture VARCHAR(255)
picture VARCHAR(255),
is_verified BOOLEAN NOT NULL DEFAULT FALSE
);`;
await client.query(query);
@ -31,7 +32,7 @@ export async function initDb() {
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
owner INTEGER NOT NULL REFERENCES users(id)
owner INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
)`
await client.query(query);
@ -40,7 +41,7 @@ export async function initDb() {
title VARCHAR(255) NOT NULL,
thumbnail VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
channel INTEGER NOT NULL REFERENCES channels(id),
channel INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
visibility VARCHAR(50) NOT NULL DEFAULT 'public',
file VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL,
@ -53,16 +54,16 @@ export async function initDb() {
(
id SERIAL PRIMARY KEY,
content TEXT NOT NULL,
author INTEGER NOT NULL REFERENCES users(id),
video INTEGER NOT NULL REFERENCES videos(id),
author INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
video INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
)`;
await client.query(query);
query = `CREATE TABLE IF NOT EXISTS likes (
id SERIAL PRIMARY KEY,
owner INTEGER NOT NULL REFERENCES users(id),
video INTEGER NOT NULL REFERENCES videos(id),
owner INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
video INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);`;
await client.query(query);
@ -70,21 +71,21 @@ export async function initDb() {
query = `CREATE TABLE IF NOT EXISTS playlists (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
owner INTEGER NOT NULL REFERENCES users(id)
owner INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
)`;
await client.query(query);
query = `CREATE TABLE IF NOT EXISTS playlist_elements (
id SERIAL PRIMARY KEY,
video INTEGER NOT NULL REFERENCES videos(id),
playlist INTEGER NOT NULL REFERENCES playlists(id)
video INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
playlist INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE
)`;
await client.query(query);
query = `CREATE TABLE IF NOT EXISTS subscriptions (
id SERIAL PRIMARY KEY,
channel INTEGER NOT NULL REFERENCES channels(id),
owner INTEGER NOT NULL REFERENCES users(id)
channel INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
owner INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
)`;
await client.query(query);
@ -96,19 +97,28 @@ export async function initDb() {
await client.query(query);
query = `CREATE TABLE IF NOT EXISTS video_tags (
video INTEGER NOT NULL REFERENCES videos(id),
tag INTEGER NOT NULL REFERENCES tags(id)
video INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
tag INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE
)`
await client.query(query);
query = `CREATE TABLE IF NOT EXISTS history (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
video INTEGER NOT NULL REFERENCES videos(id),
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
video INTEGER NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
viewed_at TIMESTAMP NOT NULL DEFAULT NOW()
)`;
await client.query(query);
query = `CREATE TABLE IF NOT EXISTS email_verification (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
token VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
expires_at TIMESTAMP NOT NULL
)`;
await client.query(query);
} catch (e) {
console.error("Error initializing database:", e);
}

30
backend/app/utils/mail.js

@ -0,0 +1,30 @@
import nodemailer from "nodemailer";
function getTransporter() {
return nodemailer.createTransport({
host: "smtp.gmail.com",
port: 587,
secure: false,
auth: {
user: process.env.GMAIL_USER,
pass: "yuuu kvoi ytrf blla",
},
});
};
export function sendEmail(to, subject, text, html = null) {
const transporter = getTransporter();
const mailOptions = {
from: process.env.GMAIL_USER,
to,
subject,
text,
};
// Add HTML if provided
if (html) {
mailOptions.html = html;
}
return transporter.sendMail(mailOptions);
}

1937
backend/logs/access.log

File diff suppressed because it is too large

10
backend/package-lock.json

@ -18,6 +18,7 @@
"jsonwebtoken": "^9.0.2",
"moment": "^2.30.1",
"multer": "^2.0.1",
"nodemailer": "^7.0.5",
"pg": "^8.16.3"
},
"devDependencies": {
@ -3104,6 +3105,15 @@
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/nodemailer": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.5.tgz",
"integrity": "sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",

1
backend/package.json

@ -22,6 +22,7 @@
"jsonwebtoken": "^9.0.2",
"moment": "^2.30.1",
"multer": "^2.0.1",
"nodemailer": "^7.0.5",
"pg": "^8.16.3"
},
"devDependencies": {

16
docker-compose.yaml

@ -15,6 +15,8 @@ services:
JWT_SECRET: ${JWT_SECRET}
LOG_FILE: ${LOG_FILE}
PORT: ${BACKEND_PORT}
GMAIL_USER: ${GMAIL_USER}
GMAIL_PASSWORD: ${GMAIL_PASSWORD}
volumes:
- ./backend/logs:/var/log/freetube
- ./backend:/app
@ -45,6 +47,20 @@ services:
depends_on:
- resit_backend
mailpit:
image: axllent/mailpit:latest
ports:
- "8025:8025" # Web UI
- "1025:1025" # SMTP
volumes:
- mailpit-data:/data
environment:
# set where to store the database
MP_DATABASE: /data/mailpit.db
restart: unless-stopped
volumes:
db_data:
driver: local
mailpit-data:
driver: local

BIN
frontend/src/assets/img/background.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

4
frontend/src/assets/svg/eye-slash.svg

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" >
<!--Boxicons v3.0 https://boxicons.com | License https://docs.boxicons.com/free-->
<path d="M12 17c-5.35 0-7.42-3.84-7.93-5 .2-.46.65-1.34 1.45-2.23l-1.4-1.4c-1.49 1.65-2.06 3.28-2.08 3.31-.07.21-.07.43 0 .63.02.07 2.32 6.68 9.95 6.68.91 0 1.73-.1 2.49-.26l-1.77-1.77c-.24.02-.47.03-.72.03ZM21.95 12.32c.07-.21.07-.43 0-.63-.02-.07-2.32-6.68-9.95-6.68-1.84 0-3.36.39-4.61.97L2.71 1.29 1.3 2.7l4.32 4.32 1.42 1.42 2.27 2.27 3.98 3.98 1.8 1.8 1.53 1.53 4.68 4.68 1.41-1.41-4.32-4.32c2.61-1.95 3.55-4.61 3.56-4.65m-7.25.97c.19-.39.3-.83.3-1.29 0-1.64-1.36-3-3-3-.46 0-.89.11-1.29.3l-1.8-1.8c.88-.31 1.9-.5 3.08-.5 5.35 0 7.42 3.85 7.93 5-.3.69-1.18 2.33-2.96 3.55z"></path>
</svg>

After

Width:  |  Height:  |  Size: 764 B

4
frontend/src/assets/svg/eye.svg

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" >
<!--Boxicons v3.0 https://boxicons.com | License https://docs.boxicons.com/free-->
<path d="M12 9a3 3 0 1 0 0 6 3 3 0 1 0 0-6"></path><path d="M12 19c7.63 0 9.93-6.62 9.95-6.68.07-.21.07-.43 0-.63-.02-.07-2.32-6.68-9.95-6.68s-9.93 6.61-9.95 6.67c-.07.21-.07.43 0 .63.02.07 2.32 6.68 9.95 6.68Zm0-12c5.35 0 7.42 3.85 7.93 5-.5 1.16-2.58 5-7.93 5s-7.42-3.84-7.93-5c.5-1.16 2.58-5 7.93-5"></path>
</svg>

After

Width:  |  Height:  |  Size: 487 B

1
frontend/src/assets/svg/trash.svg

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M5 20a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8h2V6h-4V4a2 2 0 0 0-2-2H9a2 2 0 0 0-2 2v2H3v2h2zM9 4h6v2H9zM8 8h9v12H7V8z"></path><path d="M9 10h2v8H9zm4 0h2v8h-2z"></path></svg>

After

Width:  |  Height:  |  Size: 260 B

1
frontend/src/components/Navbar.jsx

@ -48,6 +48,7 @@ export default function Navbar({ isSearchPage = false, alerts = [], onCloseAlert
</div>
<div>
<ul className="flex items-center space-x-[44px] font-montserrat text-2xl font-black">
<li><a href="/">Accueil</a></li>
{isAuthenticated ? (
<>
<li><a href="/">Abonnements</a></li>

2
frontend/src/components/PlaylistCard.jsx

@ -4,7 +4,7 @@ export default function PlaylistCard(props){
const {playlist, onClick} = props;
return (
<div className="glassmorphism w-1/3 p-4 cursor-pointer" onClick={() => {onClick(playlist.id)}}>
<div className="glassmorphism p-4 cursor-pointer" onClick={() => {onClick(playlist.id)}}>
<img src={playlist.thumbnail ? playlist.thumbnail : Default} alt={playlist.name} className="rounded-sm" />
<div className="playlist-info">
<h3 className="font-montserrat font-semibold text-xl text-white mt-3">{playlist.name}</h3>

17
frontend/src/components/PlaylistVideoCard.jsx

@ -0,0 +1,17 @@
export default function PlaylistVideoCard({ video, playlistId, navigation, currentVideo }) {
return (
<div className="glassmorphism flex items-center gap-2 p-2" onClick={() => navigation(`/video/${video.id}?playlistId=${playlistId}`)} >
<div className="relative" >
<img src={video.thumbnail} alt={video.title} className="h-16 object-cover rounded-sm" />
{currentVideo == video.id && (
<div className="absolute inset-0 bg-black opacity-80 rounded-sm w-full h-full flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" className="fill-white"><path d="M7 6v12l10-6z"></path></svg>
</div>
)}
</div>
<h3 className="font-montserrat font-medium text-white">{video.title}</h3>
</div>
);
}

4
frontend/src/components/TrendingVideos.jsx

@ -6,11 +6,9 @@ export default function TrendingVideos({ videos }) {
return (
<div className="mt-10">
<h2 className="text-3xl font-bold mb-4 text-white">Tendances</h2>
<div className="flex flex-wrap gap-11">
<div className="grid grid-cols-5 gap-8 mt-8">
{videos && videos.map((video, index) => (
<div className="w-445/1920" key={index}>
<VideoCard video={video} key={index} />
</div>
))}
</div>
</div>

42
frontend/src/components/VideoCard.jsx

@ -25,28 +25,40 @@ import { useNavigate } from 'react-router-dom';
// }
// ]
export default function VideoCard({ video }) {
export default function VideoCard({ video, showControls = false, onDelete = () => {}, link = `/video/${video.id}` }) {
const navigation = useNavigate();
const handleClick = () => {
navigation(`/video/${video.id}`, {
navigation(link, {
state: { video }
})
}
return (
<div className="flex flex-col glassmorphism w-full p-6 cursor-pointer" onClick={handleClick} >
<div className="aspect-video rounded-sm overflow-hidden">
<img
src={video.thumbnail}
alt={video.title}
className="w-full h-full object-cover"
/>
</div>
<h2 className="text-2xl font-medium font-inter mt-3 text-white">{video.title}</h2>
<div className="text-sm text-gray-400 mt-1 flex items-center">
<img src={video.creator.profilePicture} alt={video.title} className="w-12 aspect-square rounded-full object-cover" />
<span className="ml-2">{video.creator.name}</span>
<span className="ml-3.5">{video.views} vues</span>
<div className="flex flex-col glassmorphism w-full p-6 cursor-pointer relative">
<div onClick={handleClick} >
<div className="aspect-video rounded-sm overflow-hidden">
<img
src={video.thumbnail}
alt={video.title}
className="w-full h-full object-cover"
/>
</div>
<h2 className="text-2xl font-medium font-inter mt-3 text-white">{video.title}</h2>
<div className="text-sm text-gray-400 mt-1 flex items-center">
<img src={video.creator.profilePicture} alt={video.title} className="w-12 aspect-square rounded-full object-cover" />
<span className="ml-2">{video.creator.name}</span>
<span className="ml-3.5">{video.views} vues</span>
</div>
</div>
{showControls && (
<div className="mt-4">
<button
className="absolute -bottom-5 -right-5 bg-red-500 ml-4 px-3 py-2 rounded-full aspect-square text-white font-montserrat text-lg font-semibold cursor-pointer"
onClick={() => onDelete(video.id)}
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" className='fill-white' ><path d="M5 20a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8h2V6h-4V4a2 2 0 0 0-2-2H9a2 2 0 0 0-2 2v2H3v2h2zM9 4h6v2H9zM8 8h9v12H7V8z"></path><path d="M9 10h2v8H9zm4 0h2v8h-2z"></path></svg>
</button>
</div>
)}
</div>
);
}

4
frontend/src/contexts/AuthContext.jsx

@ -74,8 +74,8 @@ export const AuthProvider = ({ children }) => {
throw new Error(data.message || 'Erreur lors de la création du compte');
}
// After successful registration, log the user in
await login(username, password);
// // After successful registration, log the user in
// await login(username, password);
return data;
} catch (error) {

46
frontend/src/modals/CreatePlaylistModal.jsx

@ -0,0 +1,46 @@
import { useState } from "react";
import { createPlaylist } from "../services/playlist.service.js";
export default function CreatePlaylistModal({ isOpen, onClose, addAlert }) {
const [name, setName] = useState('');
const onSubmit = async (e) => {
e.preventDefault();
const token = localStorage.getItem("token");
const body = { name };
await createPlaylist(body, token, addAlert);
onClose();
};
return isOpen && (
<div className="bg-[#00000080] fixed top-0 left-0 w-screen h-screen flex flex-col items-center justify-center" >
<div className="glassmorphism p-4 w-1/4" >
<h2 className="text-2xl text-white font-montserrat font-bold" >Créer une playlist</h2>
<label htmlFor="name" className="block text-xl text-white font-montserrat font-semibold mt-2" >Nom de la playlist</label>
<input
type="text"
id="name"
name="name"
placeholder="Nom de la playlist"
className="block glassmorphism text-lg text-white font-inter w-full py-3 px-2 focus:outline-none"
onChange={(e) => setName(e.target.value)}
value={name}
/>
<button
className="bg-primary mt-2 py-2 px-3 rounded-sm text-white font-montserrat text-2xl font-semibold cursor-pointer"
onClick={(e) => onSubmit(e) }
>
Valider
</button>
<button
className="bg-red-500 ml-2 mt-2 py-2 px-3 rounded-sm text-white font-montserrat text-2xl font-semibold cursor-pointer"
onClick={onClose}
>
Annuler
</button>
</div>
</div>
)
}

26
frontend/src/modals/EmailVerificationModal.jsx

@ -0,0 +1,26 @@
import React, { useState } from 'react';
export default function EmailVerificationModal({ isOpen, onSubmit, onClose }) {
const [verificationCode, setVerificationCode] = useState('');
return isOpen && (
<div className="bg-[#00000080] fixed top-0 left-0 w-screen h-screen flex flex-col items-center justify-center" >
<div className="glassmorphism p-4 w-1/4" >
<h2 className="text-lg font-bold mb-2 font-montserrat text-white">Vérification de l'email</h2>
<p className="font-montserrat text-white">Un email de vérification a été envoyé à votre adresse email. Veuillez vérifier votre boîte de réception.</p>
<input
type="text"
placeholder="Entrez le code de vérification"
className="glassmorphism w-full px-4 py-2 mt-4 text-white"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
/>
<button className="bg-primary px-3 py-2 rounded-sm text-white font-montserrat text-lg font-semibold cursor-pointer mt-2" onClick={() => {
console.log("Verification code submitted:", verificationCode);
onSubmit(verificationCode)
}}>Vérifier</button>
</div>
</div>
);
}

27
frontend/src/modals/VerificationModal.jsx

@ -0,0 +1,27 @@
export default function VerificationModal({title, onConfirm, onCancel, isOpen}) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 flex items-center justify-center">
<div className="glassmorphism p-6">
<h2 className="text-lg text-white font-semibold mb-4">{title}</h2>
<div className="flex justify-end">
<button
className="bg-primary px-3 py-2 rounded-sm text-white font-montserrat text-lg font-semibold cursor-pointer"
onClick={() => onConfirm()}
>
Confirmer
</button>
<button
className="bg-red-500 ml-4 px-3 py-2 rounded-sm text-white font-montserrat text-lg font-semibold cursor-pointer"
onClick={() => onCancel()}
>
Annuler
</button>
</div>
</div>
</div>
);
}

286
frontend/src/pages/Account.jsx

@ -1,9 +1,10 @@
import Navbar from "../components/Navbar.jsx";
import {useEffect, useState} from "react";
import { useEffect, useState } from "react";
import PlaylistCard from "../components/PlaylistCard.jsx";
import VideoCard from "../components/VideoCard.jsx";
import {useNavigate} from "react-router-dom";
import { useNavigate } from "react-router-dom";
import CreateChannelModal from "../modals/CreateChannelModal.jsx";
import CreatePlaylistModal from "../modals/CreatePlaylistModal.jsx";
import { getChannel, getUserHistory, getPlaylists, updateUser } from "../services/user.service.js";
@ -21,6 +22,7 @@ export default function Account() {
const [userPlaylists, setUserPlaylists] = useState([]);
const [userChannel, setUserChannel] = useState(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isCreatePlaylistModalOpen, setIsCreatePlaylistModalOpen] = useState(false);
const [alerts, setAlerts] = useState([]);
const navigation = useNavigate();
@ -84,6 +86,10 @@ export default function Account() {
setIsModalOpen(false);
fetchUserChannel();
}
const closePlaylistModal = () => {
setIsCreatePlaylistModalOpen(false);
fetchUserPlaylists();
}
return (
<div className="min-w-screen min-h-screen bg-linear-to-br from-left-gradient to-right-gradient">
@ -92,160 +98,164 @@ export default function Account() {
<main className="px-36 pt-[118px] flex justify-between items-start">
{/* Left side */}
{/* Profile / Edit profile */}
<form className="glassmorphism w-1/3 p-10">
<div className="relative w-1/3 aspect-square overflow-hidden mb-3 mx-auto" onMouseEnter={() => setIsPictureEditActive(true)} onMouseLeave={() => setIsPictureEditActive(false)} >
<label htmlFor="image">
<img
src={user.picture}
className="w-full aspect-square rounded-full object-cover"
/>
<div className={`absolute w-full h-full bg-[#000000EF] flex items-center justify-center top-0 left-0 rounded-full ${(isPictureEditActive && editMode) ? "opacity-100 cursor-pointer" : "opacity-0 cursor-default"} ` } >
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" className="fill-white" viewBox="0 0 24 24">
<path d="M19.045 7.401c.378-.378.586-.88.586-1.414s-.208-1.036-.586-1.414l-1.586-1.586c-.378-.378-.88-.586-1.414-.586s-1.036.208-1.413.585L4 13.585V18h4.413L19.045 7.401zm-3-3 1.587 1.585-1.59 1.584-1.586-1.585 1.589-1.584zM6 16v-1.585l7.04-7.018 1.586 1.586L7.587 16H6zm-2 4h16v2H4z"></path>
</svg>
</div>
</label>
<input type="file" accept="image/*" id="image" className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" disabled={!editMode}/>
</div>
<label htmlFor="name" className="text-2xl text-white mb-1 block font-montserrat">
Nom d'utilisateur
</label>
<input
type="text"
id="name"
value={username}
className={(editMode ? editModeClasses : nonEditModeClasses)}
onChange={(e) => setUsername(e.target.value)}
placeholder="Nom d'utilisateur"
disabled={!editMode}
/>
<label htmlFor="email" className="text-2xl text-white mb-1 mt-4 block font-montserrat">
Adresse e-mail
{/* Profile / Edit profile */}
<form className="glassmorphism w-1/3 p-10">
<div className="relative w-1/3 aspect-square overflow-hidden mb-3 mx-auto" onMouseEnter={() => setIsPictureEditActive(true)} onMouseLeave={() => setIsPictureEditActive(false)} >
<label htmlFor="image">
<img
src={user.picture}
className="w-full aspect-square rounded-full object-cover"
/>
<div className={`absolute w-full h-full bg-[#000000EF] flex items-center justify-center top-0 left-0 rounded-full ${(isPictureEditActive && editMode) ? "opacity-100 cursor-pointer" : "opacity-0 cursor-default"} `} >
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" className="fill-white" viewBox="0 0 24 24">
<path d="M19.045 7.401c.378-.378.586-.88.586-1.414s-.208-1.036-.586-1.414l-1.586-1.586c-.378-.378-.88-.586-1.414-.586s-1.036.208-1.413.585L4 13.585V18h4.413L19.045 7.401zm-3-3 1.587 1.585-1.59 1.584-1.586-1.585 1.589-1.584zM6 16v-1.585l7.04-7.018 1.586 1.586L7.587 16H6zm-2 4h16v2H4z"></path>
</svg>
</div>
</label>
<input
type="email"
id="email"
value={email}
className={(editMode ? editModeClasses : nonEditModeClasses)}
onChange={(e) => setEmail(e.target.value)}
placeholder="Adresse mail"
disabled={!editMode}
/>
{ editMode && (
<>
<label htmlFor="password" className="text-2xl text-white mb-1 mt-4 block font-montserrat">
Mot de passe
</label>
<input
type="password"
id="password"
value={password}
className={(editMode ? editModeClasses : nonEditModeClasses)}
onChange={(e) => setPassword(e.target.value)}
placeholder="**************"
disabled={!editMode}
/>
<label htmlFor="confirm-password" className="text-2xl text-white mb-1 mt-4 block font-montserrat">
Confirmer le mot de passe
</label>
<input
type="password"
id="confirm-password"
value={confirmPassword}
className={(editMode ? editModeClasses : nonEditModeClasses)}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder=""
disabled={!editMode}
/>
</>
)
<input type="file" accept="image/*" id="image" className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" disabled={!editMode} />
</div>
<label htmlFor="name" className="text-2xl text-white mb-1 block font-montserrat">
Nom d'utilisateur
</label>
<input
type="text"
id="name"
value={username}
className={(editMode ? editModeClasses : nonEditModeClasses)}
onChange={(e) => setUsername(e.target.value)}
placeholder="Nom d'utilisateur"
disabled={!editMode}
/>
}
<label htmlFor="email" className="text-2xl text-white mb-1 mt-4 block font-montserrat">
Adresse e-mail
</label>
<input
type="email"
id="email"
value={email}
className={(editMode ? editModeClasses : nonEditModeClasses)}
onChange={(e) => setEmail(e.target.value)}
placeholder="Adresse mail"
disabled={!editMode}
/>
{editMode && (
<>
<label htmlFor="password" className="text-2xl text-white mb-1 mt-4 block font-montserrat">
Mot de passe
</label>
<input
type="password"
id="password"
value={password}
className={(editMode ? editModeClasses : nonEditModeClasses)}
onChange={(e) => setPassword(e.target.value)}
placeholder="**************"
disabled={!editMode}
/>
<div className="flex justify-center mt-5">
{
editMode ? (
<div>
<button
type="button"
className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-black cursor-pointer"
onClick={handleUpdateUser}
>
Enregistrer
</button>
<button
type="button"
className="bg-red-500 p-3 rounded-sm text-white font-montserrat text-2xl font-black cursor-pointer ml-3"
onClick={() => setEditMode(!editMode)}
>
Annuler
</button>
</div>
) : (
<label htmlFor="confirm-password" className="text-2xl text-white mb-1 mt-4 block font-montserrat">
Confirmer le mot de passe
</label>
<input
type="password"
id="confirm-password"
value={confirmPassword}
className={(editMode ? editModeClasses : nonEditModeClasses)}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder=""
disabled={!editMode}
/>
</>
)
}
<div className="flex justify-center mt-5">
{
editMode ? (
<div>
<button
type="button"
className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-black cursor-pointer"
onClick={handleUpdateUser}
>
Enregistrer
</button>
<button
type="button"
className="bg-red-500 p-3 rounded-sm text-white font-montserrat text-2xl font-black cursor-pointer ml-3"
onClick={() => setEditMode(!editMode)}
>
Modifier le profil
Annuler
</button>
)
}
</div>
</form>
</div>
) : (
<button
type="button"
className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-black cursor-pointer"
onClick={() => setEditMode(!editMode)}
>
Modifier le profil
</button>
)
}
</div>
</form>
{ /* Right side */}
<div className="w-2/3 flex flex-col items-start pl-10">
{/* Channel */}
{userChannel ? (
<div className="glassmorphism p-10 w-full flex justify-between">
<p className="text-3xl text-white mb-2 font-montserrat font-bold">{userChannel.channel.name}</p>
<button>
<span onClick={() => navigation(`/manage-channel/${userChannel.channel.id}`)} className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-semibold cursor-pointer">
Gérer la chaîne
</span>
</button>
</div>
) : (
<div className="glassmorphism p-10 w-full">
<h2 className="text-3xl font-bold text-white mb-4">Chaîne</h2>
<p className="text-xl text-white mb-2">Aucune chaîne associée à ce compte.</p>
<button className=" mt-4">
<a onClick={() => setIsModalOpen(true)} className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-semibold cursor-pointer">
Créer une chaîne
</a>
</button>
</div>
)}
{/* Playlists */}
<h2 className="font-montserrat font-bold text-3xl text-white mt-10" >Playlists</h2>
<div className="w-full mt-5 flex flex-wrap" >
{
userPlaylists && userPlaylists.map((playlist, index) => (
<PlaylistCard playlist={playlist} key={index} onClick={handlePlaylistClick} />
))
}
<div className="w-2/3 flex flex-col items-start pl-10">
{/* Channel */}
{userChannel ? (
<div className="glassmorphism p-10 w-full flex justify-between">
<p className="text-3xl text-white mb-2 font-montserrat font-bold">{userChannel.channel.name}</p>
<button>
<span onClick={() => navigation(`/manage-channel/${userChannel.channel.id}`)} className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-semibold cursor-pointer">
Gérer la chaîne
</span>
</button>
</div>
{/* History */}
<h2 className="font-montserrat font-bold text-3xl text-white mt-10" >Historique</h2>
<div className="w-full mt-5 flex flex-wrap gap-2" >
{
userHistory && userHistory.map((video, index) => (
<div className="w-1/3" key={index}>
<VideoCard video={video}/>
</div>
))
}
) : (
<div className="glassmorphism p-10 w-full">
<h2 className="text-3xl font-bold text-white mb-4">Chaîne</h2>
<p className="text-xl text-white mb-2">Aucune chaîne associée à ce compte.</p>
<button className=" mt-4">
<a onClick={() => setIsModalOpen(true)} className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-semibold cursor-pointer">
Créer une chaîne
</a>
</button>
</div>
)}
{/* Playlists */}
<div className="flex justify-between items-center w-full mt-10">
<h2 className="font-montserrat font-bold text-3xl text-white" >Playlists</h2>
<button onClick={() => setIsCreatePlaylistModalOpen(true)} className="bg-primary p-3 rounded-sm text-white font-montserrat text-lg font-semibold cursor-pointer">
Créer une playlist
</button>
</div>
<div className="grid grid-cols-3 gap-8 mt-8" >
{
userPlaylists && userPlaylists.map((playlist, index) => (
<PlaylistCard playlist={playlist} key={index} onClick={handlePlaylistClick} />
))
}
</div>
{/* History */}
<h2 className="font-montserrat font-bold text-3xl text-white mt-10" >Historique</h2>
<div className="grid grid-cols-3 gap-8 mt-8" >
{
userHistory && userHistory.map((video, index) => (
<VideoCard video={video} />
))
}
</div>
</div>
</main>
<CreateChannelModal isOpen={isModalOpen} onClose={() => closeModal()} addAlert={addAlert} />
<CreatePlaylistModal isOpen={isCreatePlaylistModalOpen} onClose={() => closePlaylistModal()} addAlert={addAlert} />
</div>
)

48
frontend/src/pages/Login.jsx

@ -10,6 +10,7 @@ export default function Login() {
});
const [alerts, setAlerts] = useState([]);
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const navigate = useNavigate();
const { login } = useAuth();
@ -50,12 +51,12 @@ export default function Login() {
<Navbar isSearchPage={false} alerts={alerts} onCloseAlert={onCloseAlert} />
<div className="flex justify-center items-center min-h-screen pt-20">
<div className="bg-white p-8 rounded-lg shadow-lg w-full max-w-md">
<h2 className="text-3xl font-bold text-center mb-6 font-montserrat">Connexion</h2>
<div className="glassmorphism p-8 rounded-lg shadow-lg w-full max-w-md">
<h2 className="text-3xl font-bold text-center mb-6 font-montserrat text-white">Connexion</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="username" className="block text-sm font-medium text-white mb-1">
Nom d'utilisateur
</label>
<input
@ -65,25 +66,42 @@ export default function Login() {
value={formData.username}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 glassmorphism text-white rounded-md focus:outline-none"
placeholder="Entrez votre nom d'utilisateur"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="password" className="block text-sm font-medium text-white mb-1">
Mot de passe
</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Entrez votre mot de passe"
/>
<div className='w-full glassmorphism flex items-center'>
<input
type={showPassword ? "text" : "password"}
id="password"
name="password"
value={formData.password}
onChange={handleChange}
required
className="flex-1 px-3 py-2 focus:outline-none text-white"
placeholder="Entrez votre mot de passe"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2"
>
{showPassword ? (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" className='fill-white' viewBox="0 0 24 24" >
<path d="M12 9a3 3 0 1 0 0 6 3 3 0 1 0 0-6"></path><path d="M12 19c7.63 0 9.93-6.62 9.95-6.68.07-.21.07-.43 0-.63-.02-.07-2.32-6.68-9.95-6.68s-9.93 6.61-9.95 6.67c-.07.21-.07.43 0 .63.02.07 2.32 6.68 9.95 6.68Zm0-12c5.35 0 7.42 3.85 7.93 5-.5 1.16-2.58 5-7.93 5s-7.42-3.84-7.93-5c.5-1.16 2.58-5 7.93-5"></path>
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" className='fill-white' >
<path d="M12 17c-5.35 0-7.42-3.84-7.93-5 .2-.46.65-1.34 1.45-2.23l-1.4-1.4c-1.49 1.65-2.06 3.28-2.08 3.31-.07.21-.07.43 0 .63.02.07 2.32 6.68 9.95 6.68.91 0 1.73-.1 2.49-.26l-1.77-1.77c-.24.02-.47.03-.72.03ZM21.95 12.32c.07-.21.07-.43 0-.63-.02-.07-2.32-6.68-9.95-6.68-1.84 0-3.36.39-4.61.97L2.71 1.29 1.3 2.7l4.32 4.32 1.42 1.42 2.27 2.27 3.98 3.98 1.8 1.8 1.53 1.53 4.68 4.68 1.41-1.41-4.32-4.32c2.61-1.95 3.55-4.61 3.56-4.65m-7.25.97c.19-.39.3-.83.3-1.29 0-1.64-1.36-3-3-3-.46 0-.89.11-1.29.3l-1.8-1.8c.88-.31 1.9-.5 3.08-.5 5.35 0 7.42 3.85 7.93 5-.3.69-1.18 2.33-2.96 3.55z"></path>
</svg>
)}
</button>
</div>
</div>
<button

98
frontend/src/pages/Playlist.jsx

@ -0,0 +1,98 @@
import { useParams } from "react-router-dom";
import Navbar from "../components/Navbar";
import { useEffect, useState } from "react";
import { getPlaylistById, deletePlaylist, deleteVideo } from "../services/playlist.service.js";
import VideoCard from "../components/VideoCard.jsx";
import VerificationModal from "../modals/VerificationModal.jsx";
import { useNavigate } from "react-router-dom";
export default function Playlist() {
const { id } = useParams();
const navigate = useNavigate();
const [alerts, setAlerts] = useState([]);
const [playlist, setPlaylist] = useState(null);
const [isDeletePlaylistModalOpen, setIsDeletePlaylistModalOpen] = useState(false);
const fetchPlaylistDetails = async () => {
const token = localStorage.getItem("token");
const data = await getPlaylistById(id, token, addAlert);
setPlaylist(data);
}
useEffect(() => {
fetchPlaylistDetails();
}, [id]);
const addAlert = (type, message) => {
const newAlert = { type, message, id: Date.now() }; // Add unique ID
setAlerts([...alerts, newAlert]);
};
const onCloseAlert = (alertToRemove) => {
setAlerts(alerts.filter(alert => alert !== alertToRemove));
};
const onDeletePlaylist = async () => {
const token = localStorage.getItem("token");
await deletePlaylist(id, token, addAlert);
setIsDeletePlaylistModalOpen(false);
navigate("/profile");
}
const onDeleteVideo = async (videoId) => {
const token = localStorage.getItem("token");
await deleteVideo(id, videoId, token, addAlert);
fetchPlaylistDetails();
}
return (
<div className="min-w-screen min-h-screen bg-linear-to-br from-left-gradient to-right-gradient">
<Navbar isSearchPage={false} alerts={alerts} onCloseAlert={onCloseAlert} />
<main className="px-36 w-full pt-[118px]">
<h1 className="font-bold font-montserrat text-3xl text-white" >{playlist && playlist.name}</h1>
{/* CONTROLS */}
<div className="mt-4">
<button
className="bg-primary px-3 py-2 rounded-sm text-white font-montserrat text-lg font-semibold cursor-pointer"
onClick={() => console.log("Modifier playlist")}
>
modifier
</button>
<button
className="bg-red-500 ml-4 px-3 py-2 rounded-sm text-white font-montserrat text-lg font-semibold cursor-pointer"
onClick={() => setIsDeletePlaylistModalOpen(true)}
>
supprimer
</button>
</div>
<div className="grid grid-cols-4 gap-8 mt-12">
{
playlist && playlist.videos && playlist.videos.length > 0 ? playlist.videos.map(video => (
<VideoCard
key={video.id}
video={video}
showControls={true}
onDelete={onDeleteVideo}
link={`/video/${video.id}?playlistId=${playlist.id}`}
/>
)) : (
<p className="text-white">Aucun vidéo trouvée dans cette playlist.</p>
)
}
</div>
</main>
<VerificationModal
title="Confirmer la suppression"
onConfirm={() => onDeletePlaylist()}
onCancel={() => setIsDeletePlaylistModalOpen(false)}
isOpen={isDeletePlaylistModalOpen}
/>
</div>
);
}

118
frontend/src/pages/Register.jsx

@ -2,6 +2,8 @@ import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import Navbar from '../components/Navbar';
import EmailVerificationModal from '../modals/EmailVerificationModal';
import { verifyEmail } from '../services/user.service';
export default function Register() {
const [formData, setFormData] = useState({
@ -15,6 +17,9 @@ export default function Register() {
const [loading, setLoading] = useState(false);
const [previewImage, setPreviewImage] = useState(null);
const [alerts, setAlerts] = useState([]);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [isEmailVerificationModalOpen, setEmailVerificationModalOpen] = useState(false);
const navigate = useNavigate();
const { register } = useAuth();
@ -61,7 +66,7 @@ export default function Register() {
try {
await register(formData.email, formData.username, formData.password, formData.profile);
navigate('/');
setEmailVerificationModalOpen(true);
} catch (err) {
addAlert('error', 'Erreur lors de la création du compte');
} finally {
@ -69,6 +74,20 @@ export default function Register() {
}
};
const onVerificationSubmit = async (token) => {
console.log("Submitting email verification with token:", token);
const response = await verifyEmail(formData.email, token, addAlert);
if (response) {
// Email verified successfully
setEmailVerificationModalOpen(false);
navigate('/login');
}
}
const addAlert = (type, message) => {
const newAlert = { type, message, id: Date.now() }; // Add unique ID
setAlerts([...alerts, newAlert]);
@ -83,12 +102,12 @@ export default function Register() {
<Navbar isSearchPage={false} alerts={alerts} onCloseAlert={onCloseAlert} />
<div className="flex justify-center items-center min-h-screen pt-20 pb-10">
<div className="bg-white p-8 rounded-lg shadow-lg w-full max-w-md">
<h2 className="text-3xl font-bold text-center mb-6 font-montserrat">Créer un compte</h2>
<div className="glassmorphism p-8 rounded-lg shadow-lg w-full max-w-md">
<h2 className="text-3xl font-bold text-center mb-6 font-montserrat text-white">Créer un compte</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="email" className="block text-sm font-medium font-montserrat text-white mb-1">
Email
</label>
<input
@ -98,13 +117,13 @@ export default function Register() {
value={formData.email}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 glassmorphism focus:outline-none text-white"
placeholder="Entrez votre email"
/>
</div>
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="username" className="block text-sm font-medium font-montserrat text-white mb-1">
Nom d'utilisateur
</label>
<input
@ -114,45 +133,79 @@ export default function Register() {
value={formData.username}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 glassmorphism focus:outline-none text-white"
placeholder="Entrez votre nom d'utilisateur"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="password" className="block text-sm font-medium font-montserrat text-white mb-1">
Mot de passe
</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Entrez votre mot de passe"
/>
<div className='w-full glassmorphism flex items-center'>
<input
type={showPassword ? "text" : "password"}
id="password"
name="password"
value={formData.password}
onChange={handleChange}
required
className="flex-1 px-3 py-2 focus:outline-none text-white"
placeholder="Entrez votre mot de passe"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2"
>
{showPassword ? (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" className='fill-white' viewBox="0 0 24 24" >
<path d="M12 9a3 3 0 1 0 0 6 3 3 0 1 0 0-6"></path><path d="M12 19c7.63 0 9.93-6.62 9.95-6.68.07-.21.07-.43 0-.63-.02-.07-2.32-6.68-9.95-6.68s-9.93 6.61-9.95 6.67c-.07.21-.07.43 0 .63.02.07 2.32 6.68 9.95 6.68Zm0-12c5.35 0 7.42 3.85 7.93 5-.5 1.16-2.58 5-7.93 5s-7.42-3.84-7.93-5c.5-1.16 2.58-5 7.93-5"></path>
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" className='fill-white' >
<path d="M12 17c-5.35 0-7.42-3.84-7.93-5 .2-.46.65-1.34 1.45-2.23l-1.4-1.4c-1.49 1.65-2.06 3.28-2.08 3.31-.07.21-.07.43 0 .63.02.07 2.32 6.68 9.95 6.68.91 0 1.73-.1 2.49-.26l-1.77-1.77c-.24.02-.47.03-.72.03ZM21.95 12.32c.07-.21.07-.43 0-.63-.02-.07-2.32-6.68-9.95-6.68-1.84 0-3.36.39-4.61.97L2.71 1.29 1.3 2.7l4.32 4.32 1.42 1.42 2.27 2.27 3.98 3.98 1.8 1.8 1.53 1.53 4.68 4.68 1.41-1.41-4.32-4.32c2.61-1.95 3.55-4.61 3.56-4.65m-7.25.97c.19-.39.3-.83.3-1.29 0-1.64-1.36-3-3-3-.46 0-.89.11-1.29.3l-1.8-1.8c.88-.31 1.9-.5 3.08-.5 5.35 0 7.42 3.85 7.93 5-.3.69-1.18 2.33-2.96 3.55z"></path>
</svg>
)}
</button>
</div>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="confirmPassword" className="block text-sm font-medium font-montserrat text-white mb-1">
Confirmer le mot de passe
</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Confirmez votre mot de passe"
/>
<div className='w-full glassmorphism flex items-center'>
<input
type={showConfirmPassword ? "text" : "password"}
id="confirmPassword"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleChange}
required
className="flex-1 px-3 py-2 focus:outline-none text-white"
placeholder="Confirmez votre mot de passe"
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 transform -translate-y-1/2"
>
{showPassword ? (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" className='fill-white' viewBox="0 0 24 24" >
<path d="M12 9a3 3 0 1 0 0 6 3 3 0 1 0 0-6"></path><path d="M12 19c7.63 0 9.93-6.62 9.95-6.68.07-.21.07-.43 0-.63-.02-.07-2.32-6.68-9.95-6.68s-9.93 6.61-9.95 6.67c-.07.21-.07.43 0 .63.02.07 2.32 6.68 9.95 6.68Zm0-12c5.35 0 7.42 3.85 7.93 5-.5 1.16-2.58 5-7.93 5s-7.42-3.84-7.93-5c.5-1.16 2.58-5 7.93-5"></path>
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" className='fill-white' >
<path d="M12 17c-5.35 0-7.42-3.84-7.93-5 .2-.46.65-1.34 1.45-2.23l-1.4-1.4c-1.49 1.65-2.06 3.28-2.08 3.31-.07.21-.07.43 0 .63.02.07 2.32 6.68 9.95 6.68.91 0 1.73-.1 2.49-.26l-1.77-1.77c-.24.02-.47.03-.72.03ZM21.95 12.32c.07-.21.07-.43 0-.63-.02-.07-2.32-6.68-9.95-6.68-1.84 0-3.36.39-4.61.97L2.71 1.29 1.3 2.7l4.32 4.32 1.42 1.42 2.27 2.27 3.98 3.98 1.8 1.8 1.53 1.53 4.68 4.68 1.41-1.41-4.32-4.32c2.61-1.95 3.55-4.61 3.56-4.65m-7.25.97c.19-.39.3-.83.3-1.29 0-1.64-1.36-3-3-3-.46 0-.89.11-1.29.3l-1.8-1.8c.88-.31 1.9-.5 3.08-.5 5.35 0 7.42 3.85 7.93 5-.3.69-1.18 2.33-2.96 3.55z"></path>
</svg>
)}
</button>
</div>
</div>
<div>
<label htmlFor="profile" className="block text-sm font-medium text-gray-700 mb-1">
<label htmlFor="profile" className="block text-sm font-medium font-montserrat text-white mb-1">
Photo de profil (optionnel)
</label>
<input
@ -161,7 +214,7 @@ export default function Register() {
name="profile"
accept="image/*"
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 glassmorphism focus:outline-none text-white"
/>
{previewImage && (
<div className="mt-2">
@ -177,7 +230,7 @@ export default function Register() {
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 font-montserrat"
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-blue-500 disabled:opacity-50 font-montserrat"
>
{loading ? 'Création du compte...' : 'Créer un compte'}
</button>
@ -193,6 +246,7 @@ export default function Register() {
</div>
</div>
</div>
<EmailVerificationModal isOpen={isEmailVerificationModalOpen} onSubmit={onVerificationSubmit} onClose={() => setEmailVerificationModalOpen(false)} />
</div>
);
}

162
frontend/src/pages/Video.jsx

@ -1,4 +1,4 @@
import {useNavigate, useParams} from "react-router-dom";
import {useNavigate, useParams, useSearchParams} from "react-router-dom";
import {useEffect, useState, useRef, useCallback} from "react";
import Navbar from "../components/Navbar.jsx";
import { useAuth } from "../contexts/AuthContext.jsx";
@ -8,6 +8,9 @@ import Tag from "../components/Tag.jsx";
import {addView, getSimilarVideos, getVideoById, toggleLike} from "../services/video.service.js";
import {subscribe} from "../services/channel.service.js";
import {addComment} from "../services/comment.service.js";
import { getPlaylists } from "../services/user.service.js";
import { addToPlaylist, getPlaylistById } from "../services/playlist.service.js";
import PlaylistVideoCard from "../components/PlaylistVideoCard.jsx";
export default function Video() {
@ -16,8 +19,12 @@ export default function Video() {
const videoRef = useRef(null);
const controllerRef = useRef(null);
const navigation = useNavigate();
const [searchParams] = useSearchParams();
const playlistId = searchParams.get("playlistId");
const isPlaylist = playlistId !== null;
const [video, setVideo] = useState(null);
const [nextVideo, setNextVideo] = useState(null);
const [similarVideos, setSimilarVideos] = useState([]);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
@ -25,6 +32,9 @@ export default function Video() {
const [showControls, setShowControls] = useState(false);
const [comment, setComment] = useState("");
const [alerts, setAlerts] = useState([]);
const [playlists, setPlaylists] = useState([]);
const [isAddToPlaylistOpen, setIsAddToPlaylistOpen] = useState(false);
const [currentPlaylist, setCurrentPlaylist] = useState(null);
const fetchVideo = useCallback(async () => {
// Fetch video data and similar videos based on the video ID from the URL
@ -47,10 +57,75 @@ export default function Video() {
await addView(id, addAlert);
}, [id, navigation]);
const fetchPlaylists = async () => {
const token = localStorage.getItem('token');
if (!token) return;
const user = JSON.parse(localStorage.getItem('user'));
const data = await getPlaylists(user.id, token, addAlert);
if (data) {
setPlaylists(data);
}
}
const fetchCurrentPlaylist = async () => {
const token = localStorage.getItem('token');
if (!token) return;
if (!playlistId) return;
setCurrentPlaylist(await getPlaylistById(playlistId, token, addAlert));
}
const fetchNextVideo = async () => {
const token = localStorage.getItem('token');
if (!token) return;
console.log("Fetching next video");
console.log("currentPlaylist", currentPlaylist);
console.log("current video id from params:", id, "type:", typeof id);
console.log("playlist videos:", currentPlaylist?.videos?.map(v => ({ id: v.id, type: typeof v.id })));
//Find position of current video id in currentPlaylist.videos
const currentIndex = currentPlaylist?.videos.findIndex(video => {
console.log(`Comparing video.id: ${video.id} (${typeof video.id}) with id: ${id} (${typeof id})`);
return video.id.toString() === id.toString();
});
console.log("currentIndex", currentIndex);
if (currentIndex !== -1) {
if (currentPlaylist?.videos[currentIndex + 1]) {
setNextVideo(currentPlaylist.videos[currentIndex + 1]);
console.log("nextVideo", currentPlaylist.videos[currentIndex + 1]);
}
}
}
const passToNextVideo = () => {
if (!nextVideo) {
console.log("No next video available");
return;
}
console.log("Passing to next video:", nextVideo);
// Navigate to the next video with playlist context
if (playlistId) {
navigation(`/video/${nextVideo.id}?playlistId=${playlistId}`);
} else {
navigation(`/video/${nextVideo.id}`);
}
}
useEffect(() => {
fetchVideo();
fetchPlaylists();
fetchCurrentPlaylist();
}, [fetchVideo]);
useEffect(() => {
fetchNextVideo();
}, [currentPlaylist]);
const handlePlayPause = () => {
if (videoRef.current) {
if (videoRef.current.paused) {
@ -212,6 +287,22 @@ export default function Video() {
setAlerts(alerts.filter(alert => alert !== alertToRemove));
};
const handleAddToPlaylist = async (id) => {
if (!isAuthenticated) {
navigation('/login');
return;
}
const body = {
video: video.id
}
const token = localStorage.getItem('token');
await addToPlaylist(id, body, token, addAlert);
setIsAddToPlaylistOpen(!isAddToPlaylistOpen);
}
return (
<div className="min-w-screen min-h-screen bg-linear-to-br from-left-gradient to-right-gradient">
<Navbar isSearchPage={false} alerts={alerts} onCloseAlert={onCloseAlert} />
@ -228,10 +319,13 @@ export default function Video() {
onMouseLeave={handleMouseLeave}
>
<video
key={video.id}
id={`video-${video.id}`}
ref={videoRef}
onPlay={handlePlaying}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onEnded={passToNextVideo}
>
<source src={`${video.file}`} type="video/mp4" />
Your browser does not support the video tag.
@ -292,6 +386,35 @@ export default function Video() {
</svg>
</button>
<p className="font-montserrat text-white ml-2" >{video.likes}</p>
<button className="relative ml-14">
<div className="bg-primary cursor-pointer px-4 py-2 rounded-md flex items-center gap-4" onClick={() => setIsAddToPlaylistOpen(!isAddToPlaylistOpen)} >
<p className="text-white font-montserrat font-bold" >playlist</p>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" className="fill-white"><path d="M19 11h-6V5h-2v6H5v2h6v6h2v-6h6z"></path></svg>
</div>
{
playlists.length > 0 && isAddToPlaylistOpen && (
<div className="absolute inset-0 w-max h-max z-40 glassmorphism top-1/1 mt-2 left-0 rounded-2xl px-4 py-2 cursor-default">
<ul className="flex flex-col gap-2">
{playlists.map((playlist) => (
<li
key={playlist.id}
className="text-white font-montserrat font-medium text-sm cursor-pointer hover:underline flex items-center justify-between gap-4"
onClick={() => handleAddToPlaylist(playlist.id)}
>
<p className="text-start">{playlist.name}</p>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" className="fill-white"><path d="M19 11h-6V5h-2v6H5v2h6v6h2v-6h6z"></path></svg>
</li>
))}
</ul>
</div>
)
}
</button>
</div>
{/* Video details */}
@ -343,13 +466,36 @@ export default function Video() {
</div>
{/* Similar videos section */}
<div className="flex-1">
<div className="flex flex-col items-center gap-2">
{similarVideos.map((video, index) => (
<div className="w-9/10" key={index}>
<VideoCard video={video} />
</div>
))}
</div>
{
!isPlaylist ? (
<div className="flex flex-col items-center gap-2">
{similarVideos.map((video, index) => (
<div className="w-9/10" key={index}>
<VideoCard video={video} />
</div>
))}
</div>
) : (
<div className="flex flex-col items-center gap-2">
<div className="glassmorphism w-9/10 py-4 px-2" >
<h2 className="font-montserrat text-white text-2xl">{currentPlaylist?.name}</h2>
{
currentPlaylist?.videos && currentPlaylist.videos.length > 0 ? (
<div className="flex flex-col items-center gap-2">
{currentPlaylist.videos.map((video, index) => (
<div className="w-full" key={index}>
<PlaylistVideoCard video={video} playlistId={currentPlaylist.id} navigation={navigation} currentVideo={id} />
</div>
))}
</div>
) : (
<p className="font-montserrat text-white mt-2">Aucune vidéo trouvée dans cette playlist.</p>
)
}
</div>
</div>
)
}
</div>
</>
): (

9
frontend/src/routes/routes.jsx

@ -9,6 +9,7 @@ import ManageVideo from "../pages/ManageVideo.jsx";
import AddVideo from "../pages/AddVideo.jsx";
import Search from "../pages/Search.jsx";
import Channel from "../pages/Channel.jsx";
import Playlist from "../pages/Playlist.jsx";
const routes = [
{ path: "/", element: <Home/> },
@ -79,6 +80,14 @@ const routes = [
element: (
<Channel/>
)
},
{
path: "playlist/:id",
element: (
<ProtectedRoute requireAuth={true}>
<Playlist/>
</ProtectedRoute>
)
}
]

104
frontend/src/services/playlist.service.js

@ -0,0 +1,104 @@
export async function createPlaylist(body, token, addAlert) {
try {
const response = await fetch(`https://localhost/api/playlists`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(body)
});
if (!response.ok) {
throw new Error('Failed to create playlist');
}
const data = await response.json();
addAlert('success', 'Playlist created successfully');
return data;
} catch (error) {
addAlert('error', error.message);
}
}
export async function addToPlaylist(id, body, token, addAlert) {
try {
const response = await fetch(`https://localhost/api/playlists/${id}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(body)
});
if (!response.ok) {
throw new Error('Failed to add video to playlist');
}
const data = await response.json();
addAlert('success', 'Vidéo ajoutée à la playlist avec succès');
return data;
} catch (error) {
addAlert('error', "Erreur lors de l'ajout à la playlist");
}
}
export async function getPlaylistById(id, token, addAlert) {
try {
const response = await fetch(`https://localhost/api/playlists/${id}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error('Failed to fetch playlist');
}
const data = await response.json();
return data;
} catch (error) {
addAlert('error', error.message);
}
}
export async function deletePlaylist(id, token, addAlert) {
try {
const response = await fetch(`https://localhost/api/playlists/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error('Failed to delete playlist');
}
addAlert('success', 'Playlist deleted successfully');
} catch (error) {
addAlert('error', error.message);
}
}
export async function deleteVideo(playlistId, videoId, token, addAlert) {
try {
const response = await fetch(`https://localhost/api/playlists/${playlistId}/video/${videoId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error('Failed to delete video');
}
addAlert('success', 'Video deleted successfully');
} catch (error) {
addAlert('error', error.message);
}
}

22
frontend/src/services/user.service.js

@ -101,3 +101,25 @@ export async function updateUser(userId, token, userData, addAlert) {
addAlert('error', "Erreur lors de la mise à jour des données de l'utilisateur.");
}
}
export async function verifyEmail(email, token, addAlert) {
try {
const response = await fetch('/api/users/verify-email', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email, token })
});
if (!response.ok) {
throw new Error("Failed to verify email");
}
const data = await response.json();
return true;
} catch (error) {
console.error("Error verifying email:", error);
addAlert('error', "Erreur lors de la vérification de l'email.");
}
}
Loading…
Cancel
Save