Browse Source

Merge branch 'developpement' of github.com:Astri4-4/3RESIT_DOCKER into developpement

features/search
Astri4-4 4 months ago
parent
commit
a55182ec1a
  1. 9
      .gitignore
  2. 62
      backend/app/controllers/channel.controller.js
  3. 6
      backend/app/controllers/comment.controller.js
  4. 18
      backend/app/controllers/playlist.controller.js
  5. 2
      backend/app/controllers/recommendation.controller.js
  6. 2
      backend/app/controllers/search.controller.js
  7. 42
      backend/app/controllers/user.controller.js
  8. 83
      backend/app/controllers/video.controller.js
  9. 6
      backend/app/routes/channel.route.js
  10. 2
      backend/app/routes/user.route.js
  11. 5
      backend/app/routes/video.route.js
  12. BIN
      backend/app/uploads/profiles/astri6.jpg
  13. BIN
      backend/app/uploads/profiles/astri7.jpg
  14. BIN
      backend/app/uploads/profiles/astria.png
  15. BIN
      backend/app/uploads/profiles/astria2.jpg
  16. BIN
      backend/app/uploads/profiles/astria3.jpg
  17. BIN
      backend/app/uploads/videos/946FFC1D2D8C189D.mp4
  18. 3
      backend/app/utils/database.js
  19. 2605
      backend/logs/access.log
  20. 6
      backend/requests/video.http
  21. 6
      backend/server.js
  22. 9
      docker-compose.yaml
  23. 37
      frontend/package-lock.json
  24. 2
      frontend/package.json
  25. 69
      frontend/src/components/Comment.jsx
  26. 52
      frontend/src/components/LinearGraph.jsx
  27. 22
      frontend/src/components/Tag.jsx
  28. 19
      frontend/src/components/VideoStatListElement.jsx
  29. 4
      frontend/src/index.css
  30. 80
      frontend/src/modals/CreateChannelModal.jsx
  31. 17
      frontend/src/pages/Account.jsx
  32. 275
      frontend/src/pages/AddVideo.jsx
  33. 208
      frontend/src/pages/ManageChannel.jsx
  34. 417
      frontend/src/pages/ManageVideo.jsx
  35. 8
      frontend/src/pages/Video.jsx
  36. 27
      frontend/src/routes/routes.jsx
  37. 21
      nginx/default.conf
  38. 23
      nginx/nginx-selfsigned.crt
  39. 28
      nginx/nginx-selfsigned.key

9
.gitignore

@ -0,0 +1,9 @@
/backend/app/uploads/
# Ignore all files in the uploads directory
/frontend/node_modules
/backend/node_modules
# Ignore node_modules directories in both frontend and backend
/frontend/dist
# Ignore the build output directory for the frontend

62
backend/app/controllers/channel.controller.js

@ -18,6 +18,7 @@ export async function create(req, res) {
logger.action("try to create new channel with owner " + channel.owner + " and name " + channel.name); logger.action("try to create new channel with owner " + channel.owner + " and name " + channel.name);
logger.write("Successfully created new channel with name " + channel.name, 200); logger.write("Successfully created new channel with name " + channel.name, 200);
client.end();
res.status(200).json(channel); res.status(200).json(channel);
} }
@ -27,10 +28,31 @@ export async function getById(req, res) {
const logger = req.body.logger; const logger = req.body.logger;
logger.action("try to get channel with id " + id); logger.action("try to get channel with id " + id);
const client = await getClient(); const client = await getClient();
const query = `SELECT * FROM channels WHERE id = $1`;
const query = `
SELECT *
FROM channels
WHERE channels.id = $1
`;
const result = await client.query(query, [id]); const result = await client.query(query, [id]);
const videoQuery = `
SELECT v.*, COUNT(h.video) as views, COUNT(l.id) as likes, COUNT(c.id) as comments
FROM videos v
LEFT JOIN history h ON v.id = h.video
LEFT JOIN likes l ON v.id = l.video
LEFT JOIN comments c ON v.id = c.video
WHERE v.channel = $1
GROUP BY v.id, title, thumbnail, description, channel, visibility, file, slug, format, release_date
`;
const videoResult = await client.query(videoQuery, [id]);
result.rows[0].videos = videoResult.rows;
logger.write("Successfully get channel with id " + id, 200); logger.write("Successfully get channel with id " + id, 200);
res.status(200).json(result); client.end();
res.status(200).json(result.rows[0]);
} }
@ -65,6 +87,7 @@ export async function getAll(req, res) {
}) })
logger.write("Successfully get all channels", 200); logger.write("Successfully get all channels", 200);
client.end();
res.status(200).json(result); res.status(200).json(result);
} }
@ -88,6 +111,7 @@ export async function update(req, res) {
const nameResult = await client.query(nameQuery, [channel.name]); const nameResult = await client.query(nameQuery, [channel.name]);
if (nameResult.rows.length > 0) { if (nameResult.rows.length > 0) {
logger.write("failed to update channel because name already taken", 400); logger.write("failed to update channel because name already taken", 400);
client.end();
res.status(400).json({error: 'Name already used'}); res.status(400).json({error: 'Name already used'});
return return
} }
@ -96,6 +120,7 @@ export async function update(req, res) {
const updateQuery = `UPDATE channels SET name = $1, description = $2 WHERE id = $3`; const updateQuery = `UPDATE channels SET name = $1, description = $2 WHERE id = $3`;
await client.query(updateQuery, [channel.name, channel.description, id]); await client.query(updateQuery, [channel.name, channel.description, id]);
logger.write("Successfully updated channel", 200); logger.write("Successfully updated channel", 200);
client.end();
res.status(200).json(channel); res.status(200).json(channel);
} }
@ -108,6 +133,7 @@ export async function del(req, res) {
const query = `DELETE FROM channels WHERE id = $1`; const query = `DELETE FROM channels WHERE id = $1`;
await client.query(query, [id]); await client.query(query, [id]);
logger.write("Successfully deleted channel", 200); logger.write("Successfully deleted channel", 200);
client.end();
res.status(200).json({message: 'Successfully deleted'}); res.status(200).json({message: 'Successfully deleted'});
} }
@ -132,6 +158,7 @@ export async function toggleSubscription(req, res) {
const remainingSubscriptions = countResult.rows[0].count; const remainingSubscriptions = countResult.rows[0].count;
logger.write("Successfully unsubscribed from channel", 200); logger.write("Successfully unsubscribed from channel", 200);
client.end();
res.status(200).json({message: 'Unsubscribed successfully', subscriptions: remainingSubscriptions}); res.status(200).json({message: 'Unsubscribed successfully', subscriptions: remainingSubscriptions});
} else { } else {
// Subscribe // Subscribe
@ -144,6 +171,37 @@ export async function toggleSubscription(req, res) {
const totalSubscriptions = countResult.rows[0].count; const totalSubscriptions = countResult.rows[0].count;
logger.write("Successfully subscribed to channel", 200); logger.write("Successfully subscribed to channel", 200);
client.end();
res.status(200).json({message: 'Subscribed successfully', subscriptions: totalSubscriptions}); res.status(200).json({message: 'Subscribed successfully', subscriptions: totalSubscriptions});
} }
}
export async function getStats(req, res) {
try {
const id = req.params.id;
const logger = req.body.logger;
logger.action("try to get stats");
const request = `
SELECT
(SELECT COUNT(*) FROM subscriptions WHERE channel = $1) as subscribers,
(SELECT COUNT(*) FROM history h JOIN videos v ON h.video = v.id WHERE v.channel = $1) as views
FROM channels
LEFT JOIN public.subscriptions s on channels.id = s.channel
LEFT JOIN public.videos v on channels.id = v.channel
LEFT JOIN public.history h on v.id = h.video
WHERE channels.id = $1
`;
const client = await getClient();
const result = await client.query(request, [id]);
logger.write("Successfully get stats", 200);
client.end();
res.status(200).json(result.rows[0]);
} catch (error) {
console.log(error);
res.status(500).json({error: error.message});
}
} }

6
backend/app/controllers/comment.controller.js

@ -39,7 +39,7 @@ export async function upload(req, res) {
createdAt: createdAt createdAt: createdAt
} }
client.end();
res.status(200).json(responseComment); res.status(200).json(responseComment);
} }
@ -52,6 +52,7 @@ export async function getByVideo(req, res) {
const query = `SELECT * FROM comments WHERE video = $1`; const query = `SELECT * FROM comments WHERE video = $1`;
const result = await client.query(query, [videoId]); const result = await client.query(query, [videoId]);
logger.write("successfully get comment", 200); logger.write("successfully get comment", 200);
client.end()
res.status(200).json(result.rows); res.status(200).json(result.rows);
} }
@ -63,6 +64,7 @@ export async function getById(req, res) {
const query = `SELECT * FROM comments WHERE id = $1`; const query = `SELECT * FROM comments WHERE id = $1`;
const result = await client.query(query, [id]); const result = await client.query(query, [id]);
logger.write("successfully get comment", 200); logger.write("successfully get comment", 200);
client.end();
res.status(200).json(result.rows[0]); res.status(200).json(result.rows[0]);
} }
@ -74,6 +76,7 @@ export async function update(req, res) {
const query = `UPDATE comments SET content = $1 WHERE id = $2`; const query = `UPDATE comments SET content = $1 WHERE id = $2`;
const result = await client.query(query, [req.body.content, id]); const result = await client.query(query, [req.body.content, id]);
logger.write("successfully update comment", 200); logger.write("successfully update comment", 200);
client.end();
res.status(200).json(result.rows[0]); res.status(200).json(result.rows[0]);
} }
@ -85,5 +88,6 @@ export async function del(req, res) {
const query = `DELETE FROM comments WHERE id = $1`; const query = `DELETE FROM comments WHERE id = $1`;
const result = await client.query(query, [id]); const result = await client.query(query, [id]);
logger.write("successfully deleted comment", 200); logger.write("successfully deleted comment", 200);
client.end();
res.status(200).json(result.rows[0]); res.status(200).json(result.rows[0]);
} }

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

@ -14,9 +14,11 @@ export async function create(req, res) {
try { try {
const result = await client.query(query, [name, userId]); const result = await client.query(query, [name, userId]);
logger.write("Playlist created with id " + result.rows[0].id, 200); logger.write("Playlist created with id " + result.rows[0].id, 200);
client.end()
res.status(200).json({ id: result.rows[0].id }); res.status(200).json({ id: result.rows[0].id });
} catch (error) { } catch (error) {
logger.write("Error creating playlist: " + error.message, 500); logger.write("Error creating playlist: " + error.message, 500);
client.end();
res.status(500).json({ error: "Internal server error" }); res.status(500).json({ error: "Internal server error" });
} }
} }
@ -31,9 +33,11 @@ export async function addVideo(req, res) {
try { try {
const result = await client.query(query, [video, id]); const result = await client.query(query, [video, id]);
logger.write("Video added to playlist with id " + id, 200); logger.write("Video added to playlist with id " + id, 200);
client.end();
res.status(200).json({id: result.rows[0].id}); res.status(200).json({id: result.rows[0].id});
} catch (error) { } catch (error) {
logger.write("Error adding video to playlist: " + error.message, 500); logger.write("Error adding video to playlist: " + error.message, 500);
client.end();
res.status(500).json({error: "Internal server error"}); res.status(500).json({error: "Internal server error"});
} }
} }
@ -60,13 +64,16 @@ export async function getByUser(req, res) {
const result = await client.query(query, [id]); const result = await client.query(query, [id]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
logger.write("No playlists found for user with id " + id, 404); logger.write("No playlists found for user with id " + id, 404);
client.end();
res.status(404).json({ error: "No playlists found" }); res.status(404).json({ error: "No playlists found" });
return; return;
} }
logger.write("Playlists retrieved for user with id " + id, 200); logger.write("Playlists retrieved for user with id " + id, 200);
client.end();
res.status(200).json(result.rows); res.status(200).json(result.rows);
} catch (error) { } catch (error) {
logger.write("Error retrieving playlists: " + error.message, 500); logger.write("Error retrieving playlists: " + error.message, 500);
client.end();
res.status(500).json({ error: "Internal server error" }); res.status(500).json({ error: "Internal server error" });
} }
} }
@ -82,13 +89,16 @@ export async function getById(req, res) {
const result = await client.query(query, [id]); const result = await client.query(query, [id]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
logger.write("No playlist found with id " + id, 404); logger.write("No playlist found with id " + id, 404);
client.end();
res.status(404).json({ error: "Playlist not found" }); res.status(404).json({ error: "Playlist not found" });
return; return;
} }
logger.write("Playlist retrieved with id " + id, 200); logger.write("Playlist retrieved with id " + id, 200);
client.end();
res.status(200).json(result.rows[0]); res.status(200).json(result.rows[0]);
} catch (error) { } catch (error) {
logger.write("Error retrieving playlist: " + error.message, 500); logger.write("Error retrieving playlist: " + error.message, 500);
client.end();
res.status(500).json({ error: "Internal server error" }); res.status(500).json({ error: "Internal server error" });
} }
} }
@ -105,13 +115,16 @@ export async function update(req, res) {
const result = await client.query(query, [name, id]); const result = await client.query(query, [name, id]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
logger.write("No playlist found with id " + id, 404); logger.write("No playlist found with id " + id, 404);
client.end();
res.status(404).json({ error: "Playlist not found", result: result.rows, query: query }); res.status(404).json({ error: "Playlist not found", result: result.rows, query: query });
return; return;
} }
logger.write("Playlist updated with id " + result.rows[0].id, 200); logger.write("Playlist updated with id " + result.rows[0].id, 200);
client.end();
res.status(200).json({ id: result.rows[0].id }); res.status(200).json({ id: result.rows[0].id });
} catch (error) { } catch (error) {
logger.write("Error updating playlist: " + error.message, 500); logger.write("Error updating playlist: " + error.message, 500);
client.end();
res.status(500).json({ error: "Internal server error" }); res.status(500).json({ error: "Internal server error" });
} }
} }
@ -127,13 +140,16 @@ export async function deleteVideo(req, res) {
const result = await client.query(query, [videoId, id]); const result = await client.query(query, [videoId, id]);
if (result.rows.length === 0) { if (result.rows.length === 0) {
logger.write("No video found in playlist with id " + id, 404); logger.write("No video found in playlist with id " + id, 404);
client.end();
res.status(404).json({ error: "Video not found in playlist" }); res.status(404).json({ error: "Video not found in playlist" });
return; return;
} }
logger.write("Video deleted from playlist with id " + id, 200); logger.write("Video deleted from playlist with id " + id, 200);
client.end();
res.status(200).json({ id: result.rows[0].id }); res.status(200).json({ id: result.rows[0].id });
} catch (error) { } catch (error) {
logger.write("Error deleting video from playlist: " + error.message, 500); logger.write("Error deleting video from playlist: " + error.message, 500);
client.end();
res.status(500).json({ error: "Internal server error" }); res.status(500).json({ error: "Internal server error" });
} }
} }
@ -148,9 +164,11 @@ export async function del(req, res) {
try { try {
const result = await client.query(query, [id]); const result = await client.query(query, [id]);
logger.write("Playlist deleted", 200); logger.write("Playlist deleted", 200);
client.end()
res.status(200).json({ "message": "playlist deleted" }); res.status(200).json({ "message": "playlist deleted" });
} catch (error) { } catch (error) {
logger.write("Error deleting playlist: " + error.message, 500); logger.write("Error deleting playlist: " + error.message, 500);
client.end();
res.status(500).json({ error: "Internal server error" }); res.status(500).json({ error: "Internal server error" });
} }
} }

2
backend/app/controllers/recommendation.controller.js

@ -30,6 +30,7 @@ export async function getRecommendations(req, res) {
// Recuperer 10 videos avec les 3 tags ayant le plus d'interaction avec l'utilisateur // Recuperer 10 videos avec les 3 tags ayant le plus d'interaction avec l'utilisateur
client.end()
res.status(200).json({ res.status(200).json({
message: "Recommendations based on user history and interactions are not yet implemented." message: "Recommendations based on user history and interactions are not yet implemented."
}); });
@ -80,6 +81,7 @@ export async function getTrendingVideos(req, res) {
} }
client.end();
res.status(200).json(trendingVideos); res.status(200).json(trendingVideos);
} catch (error) { } catch (error) {
console.error("Error fetching trending videos:", error); console.error("Error fetching trending videos:", error);

2
backend/app/controllers/search.controller.js

@ -76,7 +76,7 @@ export async function search(req, res) {
} }
client.end()
return res.status(200).json(videos); return res.status(200).json(videos);

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

@ -99,6 +99,7 @@ export async function login(req, res) {
} }
logger.write("Successfully logged in", 200); logger.write("Successfully logged in", 200);
client.end();
res.status(200).json({token: token, user: userData}); res.status(200).json({token: token, user: userData});
} }
@ -108,15 +109,20 @@ export async function getById(req, res) {
const logger = req.body.logger; const logger = req.body.logger;
logger.action("try to retrieve user " + id); logger.action("try to retrieve user " + id);
const client = await getClient(); const client = await getClient();
const query = `SELECT id, email, username, picture FROM users WHERE id = $1`; const query = `SELECT id, email, username, picture
FROM users
WHERE id = $1`;
const result = await client.query(query, [id]); const result = await client.query(query, [id]);
if (!result.rows[0]) { if (!result.rows[0]) {
logger.write("failed to retrieve user " + id + " because it doesn't exist", 404); logger.write("failed to retrieve user " + id + " because it doesn't exist", 404);
client.end()
res.status(404).json({error: "Not Found"}); res.status(404).json({error: "Not Found"});
return return
} }
logger.write("successfully retrieved user " + id, 200); logger.write("successfully retrieved user " + id, 200);
return res.status(200).json({user: result.rows[0]}); if (result.rows[0].picture) {
return res.status(200).json({user: result.rows[0]});
}
} }
export async function getByUsername(req, res) { export async function getByUsername(req, res) {
@ -128,10 +134,12 @@ export async function getByUsername(req, res) {
const result = await client.query(query, [username]); const result = await client.query(query, [username]);
if (!result.rows[0]) { if (!result.rows[0]) {
logger.write("failed to retrieve user " + username + " because it doesn't exist", 404); logger.write("failed to retrieve user " + username + " because it doesn't exist", 404);
client.end()
res.status(404).json({error: "Not Found"}); res.status(404).json({error: "Not Found"});
return return
} }
logger.write("successfully retrieved user " + username, 200); logger.write("successfully retrieved user " + username, 200);
client.end();
return res.status(200).json({user: result.rows[0]}); return res.status(200).json({user: result.rows[0]});
} }
@ -159,6 +167,7 @@ export async function update(req, res) {
const emailResult = await client.query(emailQuery, [user.email]); const emailResult = await client.query(emailQuery, [user.email]);
if (emailResult.rows[0]) { if (emailResult.rows[0]) {
logger.write("failed to update because email is already used", 400) logger.write("failed to update because email is already used", 400)
client.end();
res.status(400).json({error: "Email already exists"}); res.status(400).json({error: "Email already exists"});
} }
} }
@ -168,6 +177,7 @@ export async function update(req, res) {
const usernameResult = await client.query(usernameQuery, [user.username]); const usernameResult = await client.query(usernameQuery, [user.username]);
if (usernameResult.rows[0]) { if (usernameResult.rows[0]) {
logger.write("failed to update because username is already used", 400) logger.write("failed to update because username is already used", 400)
client.end();
res.status(400).json({error: "Username already exists"}); res.status(400).json({error: "Username already exists"});
} }
} }
@ -184,12 +194,31 @@ export async function update(req, res) {
user.password = userInBase.password; user.password = userInBase.password;
} }
let __filename = fileURLToPath(import.meta.url);
let __dirname = dirname(__filename);
console.log(__dirname);
let profilePicture = userInBase.picture.split("/").pop();
fs.rename(
path.join(__dirname, "..", "uploads", "profiles", profilePicture),
path.join(__dirname, "..", "uploads", "profiles", user.username + "." + profilePicture.split(".").pop()),
(err) => {
if (err) {
logger.write("failed to update profile picture", 500);
console.error("Error renaming file:", err);
throw err;
}
});
profilePicture = "/api/media/profile/" + user.username + "." + profilePicture.split(".").pop();
const updateQuery = `UPDATE users SET email = $1, username = $2, password = $3, picture = $4 WHERE id = $5 RETURNING id, email, username, picture`; const updateQuery = `UPDATE users SET email = $1, username = $2, password = $3, picture = $4 WHERE id = $5 RETURNING id, email, username, picture`;
const result = await client.query(updateQuery, [user.email, user.username, user.password, user.picture, id]); const result = await client.query(updateQuery, [user.email, user.username, user.password, profilePicture, id]);
logger.write("successfully updated user " + id, 200); logger.write("successfully updated user " + id, 200);
res.status(200).send({user: result.rows[0]}); client.end();
res.status(200).json(result.rows[0]);
} catch (err) { } catch (err) {
console.log(err); console.log(err);
client.end()
res.status(500).json({error: err}); res.status(500).json({error: err});
} }
@ -203,6 +232,7 @@ export async function deleteUser(req, res) {
const query = `DELETE FROM users WHERE id = $1`; const query = `DELETE FROM users WHERE id = $1`;
await client.query(query, [id]); await client.query(query, [id]);
logger.write("successfully deleted user " + id); logger.write("successfully deleted user " + id);
client.end();
res.status(200).json({message: 'User deleted'}); res.status(200).json({message: 'User deleted'});
} }
@ -217,11 +247,13 @@ export async function getChannel(req, res) {
if (!result.rows[0]) { if (!result.rows[0]) {
logger.write("failed to retrieve channel of user " + id + " because it doesn't exist", 404); logger.write("failed to retrieve channel of user " + id + " because it doesn't exist", 404);
client.end();
res.status(404).json({error: "Channel Not Found"}); res.status(404).json({error: "Channel Not Found"});
return; return;
} }
logger.write("successfully retrieved channel of user " + id, 200); logger.write("successfully retrieved channel of user " + id, 200);
client.end();
res.status(200).json({channel: result.rows[0]}); res.status(200).json({channel: result.rows[0]});
} }
@ -268,9 +300,11 @@ export async function getHistory(req, res) {
if (!result.rows[0]) { if (!result.rows[0]) {
logger.write("failed to retrieve history of user " + id + " because it doesn't exist", 404); logger.write("failed to retrieve history of user " + id + " because it doesn't exist", 404);
res.status(404).json({error: "History Not Found"}); res.status(404).json({error: "History Not Found"});
client.end();
return; return;
} }
logger.write("successfully retrieved history of user " + id, 200); logger.write("successfully retrieved history of user " + id, 200);
client.end();
res.status(200).json(videos); res.status(200).json(videos);
} }

83
backend/app/controllers/video.controller.js

@ -49,10 +49,12 @@ export async function upload(req, res) {
logger.write("try to upload video"); logger.write("try to upload video");
const releaseDate = new Date(Date.now()).toISOString(); const releaseDate = new Date(Date.now()).toISOString();
const query = `INSERT INTO videos (title, thumbnail, description, channel, visibility, file, slug, format, release_date) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`; const query = `INSERT INTO videos (title, thumbnail, description, channel, visibility, file, slug, format, release_date) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`;
await client.query(query, [video.title, 'null', video.description, video.channel, video.visibility, video.file, video.slug, video.format, releaseDate]); const idResult = await client.query(query, [video.title, 'null', video.description, video.channel, video.visibility, video.file, video.slug, video.format, releaseDate]);
const id = idResult.rows[0].id;
logger.write("successfully uploaded video", 200); logger.write("successfully uploaded video", 200);
res.status(200).json({"message": "Successfully uploaded video"}); await client.end()
res.status(200).json({"message": "Successfully uploaded video", "id":id});
} }
@ -75,6 +77,7 @@ export async function uploadThumbnail(req, res) {
const updateQuery = `UPDATE videos SET thumbnail = $1 WHERE id = $2`; const updateQuery = `UPDATE videos SET thumbnail = $1 WHERE id = $2`;
await client.query(updateQuery, [file, req.body.video]); await client.query(updateQuery, [file, req.body.video]);
logger.write("successfully uploaded thumbnail", 200); logger.write("successfully uploaded thumbnail", 200);
await client.end();
res.status(200).json({"message": "Successfully uploaded thumbnail"}); res.status(200).json({"message": "Successfully uploaded thumbnail"});
} }
@ -97,7 +100,7 @@ export async function getById(req, res) {
video.likes = likesResult.rows[0].like_count; video.likes = likesResult.rows[0].like_count;
// GET COMMENTS // GET COMMENTS
const commentsQuery = `SELECT c.id, c.content, c.created_at, u.username, u.picture FROM comments c JOIN users u ON c.author = u.id WHERE c.video = $1`; const commentsQuery = `SELECT c.id, c.content, c.created_at, u.username, u.picture FROM comments c JOIN users u ON c.author = u.id WHERE c.video = $1 ORDER BY c.created_at DESC`;
const commentsResult = await client.query(commentsQuery, [id]); const commentsResult = await client.query(commentsQuery, [id]);
video.comments = commentsResult.rows; video.comments = commentsResult.rows;
@ -122,6 +125,7 @@ export async function getById(req, res) {
video.tags = tagsResult.rows.map(tag => tag.name); video.tags = tagsResult.rows.map(tag => tag.name);
logger.write("successfully get video " + id, 200); logger.write("successfully get video " + id, 200);
client.end()
res.status(200).json(video); res.status(200).json(video);
} }
@ -133,6 +137,7 @@ export async function getByChannel(req, res) {
const query = `SELECT * FROM videos WHERE channel = $1`; const query = `SELECT * FROM videos WHERE channel = $1`;
const result = await client.query(query, [id]); const result = await client.query(query, [id]);
logger.write("successfully get video from channel " + id, 200); logger.write("successfully get video from channel " + id, 200);
client.end()
res.status(200).json(result.rows); res.status(200).json(result.rows);
} }
@ -144,6 +149,7 @@ export async function update(req, res) {
const query = `UPDATE videos SET title = $1, description = $2, visibility = $3 WHERE id = $4`; const query = `UPDATE videos SET title = $1, description = $2, visibility = $3 WHERE id = $4`;
await client.query(query, [req.body.title, req.body.description, req.body.visibility, id]); await client.query(query, [req.body.title, req.body.description, req.body.visibility, id]);
logger.write("successfully updated video", 200); logger.write("successfully updated video", 200);
client.end()
res.status(200).json({"message": "Successfully updated video"}); res.status(200).json({"message": "Successfully updated video"});
} }
@ -161,6 +167,7 @@ export async function updateVideo(req, res) {
fs.unlink(pathToDelete, (error) => { fs.unlink(pathToDelete, (error) => {
if (error) { if (error) {
logger.write(error, 500); logger.write(error, 500);
client.end()
res.status(500).json({"message": "Failed to delete video"}); res.status(500).json({"message": "Failed to delete video"});
return return
} }
@ -172,6 +179,7 @@ export async function updateVideo(req, res) {
fs.writeFileSync(destinationPath, fileBuffer); fs.writeFileSync(destinationPath, fileBuffer);
logger.write("successfully updated video", 200); logger.write("successfully updated video", 200);
client.end()
res.status(200).json({"message": "Successfully updated video"}); res.status(200).json({"message": "Successfully updated video"});
}) })
@ -193,6 +201,7 @@ export async function del(req, res) {
fs.unlink(pathToDelete, (error) => { fs.unlink(pathToDelete, (error) => {
if (error) { if (error) {
logger.write(error, 500); logger.write(error, 500);
client.end()
res.status(500).json({"message": "Failed to delete video"}); res.status(500).json({"message": "Failed to delete video"});
return return
} }
@ -207,6 +216,7 @@ export async function del(req, res) {
const query = `DELETE FROM videos WHERE id = $1`; const query = `DELETE FROM videos WHERE id = $1`;
await client.query(query, [id]); await client.query(query, [id]);
logger.write("successfully deleted video", 200); logger.write("successfully deleted video", 200);
client.end()
res.status(200).json({"message": "Successfully deleted video"}); res.status(200).json({"message": "Successfully deleted video"});
}) })
}) })
@ -237,6 +247,7 @@ export async function toggleLike(req, res) {
const likesCount = likesCountResult.rows[0].like_count; const likesCount = likesCountResult.rows[0].like_count;
logger.write("no likes found adding likes for video " + id, 200); logger.write("no likes found adding likes for video " + id, 200);
client.end();
res.status(200).json({"message": "Successfully added like", "likes": likesCount}); res.status(200).json({"message": "Successfully added like", "likes": likesCount});
} else { } else {
const query = `DELETE FROM likes WHERE owner = $1 AND video = $2`; const query = `DELETE FROM likes WHERE owner = $1 AND video = $2`;
@ -248,6 +259,7 @@ export async function toggleLike(req, res) {
const likesCount = likesCountResult.rows[0].like_count; const likesCount = likesCountResult.rows[0].like_count;
logger.write("likes found, removing like for video " + id, 200); logger.write("likes found, removing like for video " + id, 200);
client.end();
res.status(200).json({"message": "Successfully removed like", "likes": likesCount}); res.status(200).json({"message": "Successfully removed like", "likes": likesCount});
} }
@ -295,9 +307,15 @@ export async function addTags(req, res) {
const insertVideoTagQuery = `INSERT INTO video_tags (tag, video) VALUES ($1, $2)`; const insertVideoTagQuery = `INSERT INTO video_tags (tag, video) VALUES ($1, $2)`;
await client.query(insertVideoTagQuery, [id, videoId]); await client.query(insertVideoTagQuery, [id, videoId]);
} }
// GET UPDATED TAGS FOR VIDEO
const updatedTagsQuery = `SELECT t.name FROM tags t JOIN video_tags vt ON t.id = vt.tag WHERE vt.video = $1`;
const updatedTagsResult = await client.query(updatedTagsQuery, [videoId]);
const updatedTags = updatedTagsResult.rows;
logger.write("successfully added tags to video " + videoId, 200); logger.write("successfully added tags to video " + videoId, 200);
await client.end(); await client.end();
res.status(200).json({"message": "Successfully added tags to video"}); res.status(200).json({"message": "Successfully added tags to video", "tags" : updatedTags.map(tag => tag.name)});
} }
@ -355,10 +373,62 @@ export async function getSimilarVideos(req, res) {
} }
logger.write("successfully retrieved similar videos for video " + id, 200); logger.write("successfully get similar videos for video " + id, 200);
await client.end();
res.status(200).json(result.rows); res.status(200).json(result.rows);
} }
export async function getLikesPerDay(req, res) {
const id = req.params.id;
const logger = req.body.logger;
logger.action("try to get likes per day");
const client = await getClient();
try {
const response = {}
const likeQuery = `
SELECT
DATE(created_at) as date,
COUNT(*) as count
FROM likes
WHERE video = $1
GROUP BY DATE(created_at)
ORDER BY date DESC
LIMIT 30
`;
const viewQuery = `
SELECT
DATE(viewed_at) as date,
COUNT(*) as count
FROM history
WHERE video = $1
GROUP BY DATE(viewed_at)
ORDER BY date DESC
LIMIT 30
`;
const resultViews = await client.query(viewQuery, [id]);
response.views = resultViews.rows;
const resultLikes = await client.query(likeQuery, [id]);
response.likes = resultLikes.rows;
console.log(response);
logger.write("successfully retrieved likes per day", 200);
res.status(200).json(response);
} catch (error) {
logger.write("Error retrieving likes per day: " + error.message, 500);
res.status(500).json({ error: "Internal server error" });
} finally {
await client.end();
}
}
export async function addViews(req, res) { export async function addViews(req, res) {
const id = req.params.id; const id = req.params.id;
const logger = req.body.logger; const logger = req.body.logger;
@ -378,5 +448,6 @@ export async function addViews(req, res) {
} }
logger.write("successfully added views for video " + id, 200); logger.write("successfully added views for video " + id, 200);
await client.end();
res.status(200).json({"message": "Successfully added views"}); res.status(200).json({"message": "Successfully added views"});
} }

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

@ -1,5 +1,5 @@
import {Router} from "express"; import {Router} from "express";
import {create, del, getAll, getById, toggleSubscription, update} from "../controllers/channel.controller.js"; import {create, del, getAll, getById, getStats, toggleSubscription, update} from "../controllers/channel.controller.js";
import {isTokenValid} from "../middlewares/jwt.middleware.js"; import {isTokenValid} from "../middlewares/jwt.middleware.js";
import { import {
Channel, Channel,
@ -32,4 +32,8 @@ router.delete("/:id", [addLogger, isTokenValid, Channel.id, validator, doChannel
// TOGGLE SUBSCRIPTION // TOGGLE SUBSCRIPTION
router.post("/:id/subscribe", [addLogger, isTokenValid, Channel.id, validator, doChannelExists], toggleSubscription); router.post("/:id/subscribe", [addLogger, isTokenValid, Channel.id, validator, doChannelExists], toggleSubscription);
// GET TOTAL VIEWS AND SUBSCRIBERS OF THE CHANNEL
router.get("/:id/stats", [addLogger, isTokenValid, Channel.id, validator, doChannelExists, isOwner], getStats);
export default router; export default router;

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

@ -38,7 +38,7 @@ router.get("/:id", [addLogger, isTokenValid, User.id, validator], getById)
router.get("/username/:username", [addLogger, isTokenValid, UserRequest.username, validator], getByUsername); router.get("/username/:username", [addLogger, isTokenValid, UserRequest.username, validator], getByUsername);
// UPDATE USER // UPDATE USER
router.put("/:id", [addLogger, isTokenValid, User.id, UserRegister.email, UserRegister.username, UserRegister.password, validator, doUserExists, isOwner], update); router.put("/:id", [addLogger, isTokenValid, User.id, UserRegister.email, UserRegister.username, validator, doUserExists, isOwner], update);
// DELETE USER // DELETE USER
router.delete("/:id", [addLogger, isTokenValid, User.id, validator, doUserExists, isOwner], deleteUser); router.delete("/:id", [addLogger, isTokenValid, User.id, validator, doUserExists, isOwner], deleteUser);

5
backend/app/routes/video.route.js

@ -10,7 +10,7 @@ import {
uploadThumbnail, uploadThumbnail,
updateVideo, updateVideo,
toggleLike, toggleLike,
addTags, getSimilarVideos, addViews addTags, getSimilarVideos, addViews, getLikesPerDay
} from "../controllers/video.controller.js"; } from "../controllers/video.controller.js";
import { import {
doVideoExists, doVideoExists,
@ -58,5 +58,8 @@ router.get("/:id/similar", [addLogger, Video.id, validator, doVideoExistsParam],
// ADD VIEWS // ADD VIEWS
router.get("/:id/views", [addLogger, isTokenValid, Video.id, validator, doVideoExistsParam], addViews); router.get("/:id/views", [addLogger, isTokenValid, Video.id, validator, doVideoExistsParam], addViews);
// GET LIKE PER DAY
router.get("/:id/likes/day", [addLogger, isTokenValid, Video.id, validator, doVideoExistsParam], getLikesPerDay);
export default router; export default router;

BIN
backend/app/uploads/profiles/astri6.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
backend/app/uploads/profiles/astri7.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
backend/app/uploads/profiles/astria.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

BIN
backend/app/uploads/profiles/astria2.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
backend/app/uploads/profiles/astria3.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
backend/app/uploads/videos/946FFC1D2D8C189D.mp4

Binary file not shown.

3
backend/app/utils/database.js

@ -62,7 +62,8 @@ export async function initDb() {
query = `CREATE TABLE IF NOT EXISTS likes ( query = `CREATE TABLE IF NOT EXISTS likes (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
owner INTEGER NOT NULL REFERENCES users(id), owner INTEGER NOT NULL REFERENCES users(id),
video INTEGER NOT NULL REFERENCES videos(id) video INTEGER NOT NULL REFERENCES videos(id),
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);`; );`;
await client.query(query); await client.query(query);

2605
backend/logs/access.log

File diff suppressed because it is too large

6
backend/requests/video.http

@ -1,4 +1,4 @@
@token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidXNlcm5hbWUiOiJhc3RyaWEiLCJpYXQiOjE3NTI5NDQxMDF9.dbGCL8qqqLR3e7Ngns-xPfZAvp0WQzAbjaEHjDVg1HI @token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhc3RyaWEiLCJpYXQiOjE3NTMzODAyNjB9._rUcieo3acJp6tjQao7V3UQz0_ngHuB2z36_fG_fIX8
### UPDATE VIDEO ### UPDATE VIDEO
PUT http://127.0.0.1:8000/api/videos/3 PUT http://127.0.0.1:8000/api/videos/3
@ -16,7 +16,7 @@ GET http://127.0.0.1:8000/api/videos/14/like
Authorization: Bearer {{token}} Authorization: Bearer {{token}}
### ADD TAGS ### ADD TAGS
PUT http://127.0.0.1:8000/api/videos/2/tags PUT http://127.0.0.1:8000/api/videos/3/tags
Content-Type: application/json Content-Type: application/json
Authorization: Bearer {{token}} Authorization: Bearer {{token}}
@ -26,7 +26,7 @@ Authorization: Bearer {{token}}
"Create Mod", "Create Mod",
"Redstone" "Redstone"
], ],
"channel": 2 "channel": 1
} }
### ###

6
backend/server.js

@ -20,9 +20,9 @@ const app = express();
// INITIALIZE DATABASE // INITIALIZE DATABASE
// Increase body size limits for file uploads
app.use(express.urlencoded({extended: true})); app.use(express.urlencoded({extended: true, limit: '500mb'}));
app.use(express.json()); app.use(express.json({limit: '500mb'}));
app.use(cors()) app.use(cors())
// ROUTES // ROUTES

9
docker-compose.yaml

@ -4,14 +4,13 @@ services:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
network: host
container_name: resit_backend container_name: resit_backend
ports: ports:
- "8000:8000" - "8000:8000"
environment: environment:
DB_USER: ${POSTGRES_USER} DB_USER: ${POSTGRES_USER}
DB_NAME: ${POSTGRES_DB} DB_NAME: ${POSTGRES_DB}
DB_HOST: ${POSTGRES_HOST} DB_HOST: db
DB_PASSWORD: ${POSTGRES_PASSWORD} DB_PASSWORD: ${POSTGRES_PASSWORD}
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
LOG_FILE: ${LOG_FILE} LOG_FILE: ${LOG_FILE}
@ -35,12 +34,16 @@ services:
frontend: frontend:
image: nginx:latest image: nginx:latest
network_mode: host
ports: ports:
- "80:80" - "80:80"
- "443:443"
volumes: volumes:
- ./frontend/dist:/usr/share/nginx/html - ./frontend/dist:/usr/share/nginx/html
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf - ./nginx/default.conf:/etc/nginx/conf.d/default.conf
- ./nginx/nginx-selfsigned.crt:/etc/nginx/ssl/nginx-selfsigned.crt
- ./nginx/nginx-selfsigned.key:/etc/nginx/ssl/nginx-selfsigned.key
depends_on:
- resit_backend
volumes: volumes:
db_data: db_data:

37
frontend/package-lock.json

@ -9,7 +9,10 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"chart.js": "^4.5.0",
"chartjs": "^0.3.24",
"react": "^19.1.0", "react": "^19.1.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-router-dom": "^7.6.3", "react-router-dom": "^7.6.3",
"tailwindcss": "^4.1.11" "tailwindcss": "^4.1.11"
@ -1004,6 +1007,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.19", "version": "1.0.0-beta.19",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz",
@ -1794,6 +1803,24 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/chart.js": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chartjs": {
"version": "0.3.24",
"resolved": "https://registry.npmjs.org/chartjs/-/chartjs-0.3.24.tgz",
"integrity": "sha512-h6G9qcDqmFYnSWqjWCzQMeOLiypS+pM6Fq2Rj7LPty8Kjx5yHonwwJ7oEHImZpQ2u9Pu36XGYfardvvBiQVrhg==",
"license": "MIT"
},
"node_modules/chownr": { "node_modules/chownr": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@ -2999,6 +3026,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-chartjs-2": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz",
"integrity": "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==",
"license": "MIT",
"peerDependencies": {
"chart.js": "^4.1.1",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "19.1.0", "version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",

2
frontend/package.json

@ -11,7 +11,9 @@
}, },
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"chart.js": "^4.5.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-router-dom": "^7.6.3", "react-router-dom": "^7.6.3",
"tailwindcss": "^4.1.11" "tailwindcss": "^4.1.11"

69
frontend/src/components/Comment.jsx

@ -2,7 +2,7 @@ import {useAuth} from "../contexts/AuthContext.jsx";
import {useRef, useState} from "react"; import {useRef, useState} from "react";
export default function Comment({ comment, index, videoId, refetchVideo }) { export default function Comment({ comment, index, videoId, refetchVideo, doShowCommands=true }) {
let {user, isAuthenticated} = useAuth(); let {user, isAuthenticated} = useAuth();
let commentRef = useRef(); let commentRef = useRef();
@ -82,39 +82,44 @@ export default function Comment({ comment, index, videoId, refetchVideo }) {
className="w-8 h-8 rounded-full object-cover mr-3" className="w-8 h-8 rounded-full object-cover mr-3"
/> />
<span className="font-montserrat font-bold text-white">{comment.username}</span> <span className="font-montserrat font-bold text-white">{comment.username}</span>
<span className="text-gray-400 ml-2 text-sm">{new Date(comment.created_at).toLocaleDateString()}</span>
</div> </div>
<p className={(editMode) ? editClass : "text-white focus:outline-none "} ref={commentRef}>{comment.content}</p> <p className={(editMode) ? editClass : "text-white focus:outline-none "} ref={commentRef}>{comment.content}</p>
<div className="flex gap-2 items-center mt-2"> {
{ isAuthenticated && user.username === comment.username && editMode === false ? ( doShowCommands && (
<button className="text-blue-500 mt-2 hover:underline" onClick={() => handleEdit(comment.id)}> <div className="flex gap-2 items-center mt-2">
Modifier { isAuthenticated && user.username === comment.username && editMode === false ? (
</button> <button className="text-blue-500 mt-2 hover:underline" onClick={() => handleEdit(comment.id)}>
) : null } Modifier
{ isAuthenticated && user.username === comment.username && editMode === false ? ( </button>
) : null }
<button className="text-red-500 mt-2 hover:underline" onClick={() => handleDelete(comment.id)}> { isAuthenticated && user.username === comment.username && editMode === false ? (
Supprimer
</button> <button className="text-red-500 mt-2 hover:underline" onClick={() => handleDelete(comment.id)}>
) : null Supprimer
} </button>
{ isAuthenticated && user.username === comment.username && editMode ? ( ) : null
<button className="text-green-500 mt-2 hover:underline" onClick={() => { }
setEditMode(false); { isAuthenticated && user.username === comment.username && editMode ? (
commentRef.current.contentEditable = false; <button className="text-green-500 mt-2 hover:underline" onClick={() => {
handleEditSubmit(comment.id, commentRef.current.textContent); setEditMode(false);
}}> commentRef.current.contentEditable = false;
Enregistrer handleEditSubmit(comment.id, commentRef.current.textContent);
</button> }}>
) : null } Enregistrer
{ isAuthenticated && user.username === comment.username && editMode ? ( </button>
<button className="text-gray-500 mt-2 hover:underline" onClick={() => { ) : null }
setEditMode(false); { isAuthenticated && user.username === comment.username && editMode ? (
commentRef.current.contentEditable = false; <button className="text-gray-500 mt-2 hover:underline" onClick={() => {
}}> setEditMode(false);
Annuler commentRef.current.contentEditable = false;
</button> }}>
) : null } Annuler
</div> </button>
) : null }
</div>
)
}
</div> </div>
) )

52
frontend/src/components/LinearGraph.jsx

@ -0,0 +1,52 @@
import {Line} from "react-chartjs-2";
import {Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend} from "chart.js";
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend
);
export default function LinearGraph({ dataToGraph, className, legend, borderColor="rgba(75, 192, 192, 1)" }) {
const prepareData = () => {
if (!dataToGraph || dataToGraph.length === 0) {
return {
labels: [],
datasets: []
};
}
const labels = dataToGraph.map(item => item.date.split("T")[0]);
const data = dataToGraph.map(item => item.count);
return {
labels,
datasets: [{
label: legend,
data,
fill: false,
pointRadius: 3,
borderColor: borderColor,
tension: 0,
stepped: false
}]
};
}
const data = prepareData();
const options = {
}
return (
<div className={className}>
<Line options={options} data={data} className="w-full border-red-500 " />
</div>
)
}

22
frontend/src/components/Tag.jsx

@ -0,0 +1,22 @@
export default function Tag({ tag, onSuppress, doShowControls=true }) {
return (
<div className="glassmorphism px-2 py-1 w-max flex flex-row items-center gap-2">
<span className="font-inter text-white">#{tag}</span>
{doShowControls && (
<span className="tag-controls cursor-pointer" onClick={onSuppress}>
<svg
className="w-6 h-6 fill-white"
stroke="#FFF"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</span>
)}
</div>
)
}

19
frontend/src/components/VideoStatListElement.jsx

@ -0,0 +1,19 @@
export default function VideoStatListElement ({ video, onClick }) {
return (
<div className="flex p-4 gap-4 glassmorphism cursor-pointer" onClick={onClick} >
<img
src={video.thumbnail}
alt=""
className="w-1/4 aspect-video rounded-sm"
/>
<div>
<h3 className="text-white text-2xl font-montserrat font-bold" >{video.title}</h3>
<p className="text-white text-lg font-montserrat font-normal">Vues: {video.views}</p>
<p className="text-white text-lg font-montserrat font-normal">Likes: {video.likes}</p>
<p className="text-white text-lg font-montserrat font-normal">Commentaires: {video.comments}</p>
</div>
</div>
);
}

4
frontend/src/index.css

@ -31,6 +31,10 @@
backdrop-filter: blur(27.5px); backdrop-filter: blur(27.5px);
} }
.resizable-none {
resize: none;
}
@theme { @theme {
/* Fonts */ /* Fonts */
--font-inter: 'Inter', sans-serif; --font-inter: 'Inter', sans-serif;

80
frontend/src/modals/CreateChannelModal.jsx

@ -0,0 +1,80 @@
import {useState} from "react";
export default function CreateChannelModal({isOpen, onClose}) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const token = localStorage.getItem('token');
const userStored = localStorage.getItem('user');
const user = userStored ? JSON.parse(userStored) : {};
const onSubmit = async (e) => {
e.preventDefault();
const request = await fetch(`/api/channels/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
"name": name,
"description": description,
"owner": user.id
})
})
if (!request.ok) {
console.error("Not able to create channel");
return; // Prevent further execution if the request failed
}
const data = await request.json();
console.log(data);
}
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 chaine</h2>
<label htmlFor="name" className="block text-xl text-white font-montserrat font-semibold mt-2" >Nom de la chaine</label>
<input
type="text"
id="name"
name="name"
placeholder="Nom de la chaine"
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}
/>
<label htmlFor="description" className="block text-xl text-white font-montserrat font-semibold mt-2" >Description</label>
<textarea
id="description"
name="description"
placeholder="Description de votre chaine"
className="block glassmorphism text-lg text-white font-inter w-full py-3 px-2 focus:outline-none"
onChange={(e) => setDescription(e.target.value)}
value={description}
>
</textarea>
<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>
)
}

17
frontend/src/pages/Account.jsx

@ -2,6 +2,8 @@ import Navbar from "../components/Navbar.jsx";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import PlaylistCard from "../components/PlaylistCard.jsx"; import PlaylistCard from "../components/PlaylistCard.jsx";
import VideoCard from "../components/VideoCard.jsx"; import VideoCard from "../components/VideoCard.jsx";
import {useNavigate} from "react-router-dom";
import CreateChannelModal from "../modals/CreateChannelModal.jsx";
export default function Account() { export default function Account() {
@ -16,8 +18,10 @@ export default function Account() {
const [isPictureEditActive, setIsPictureEditActive] = useState(false); const [isPictureEditActive, setIsPictureEditActive] = useState(false);
const [userHistory, setUserHistory] = useState([]); const [userHistory, setUserHistory] = useState([]);
const [userPlaylists, setUserPlaylists] = useState([]); const [userPlaylists, setUserPlaylists] = useState([]);
const [userChannel, setUserChannel] = useState(null); const [userChannel, setUserChannel] = useState(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const navigation = useNavigate();
const fetchUserChannel = async () => { const fetchUserChannel = async () => {
try { try {
@ -93,7 +97,7 @@ export default function Account() {
const editModeClasses = nonEditModeClasses + " glassmorphism"; const editModeClasses = nonEditModeClasses + " glassmorphism";
const handlePlaylistClick = (playlistId) => { const handlePlaylistClick = (playlistId) => {
navigation(`/playlist/${playlistId}`);
} }
const handleUpdateUser = async () => { const handleUpdateUser = async () => {
@ -132,6 +136,7 @@ export default function Account() {
} }
} }
return ( return (
<div className="min-w-screen min-h-screen bg-linear-to-br from-left-gradient to-right-gradient"> <div className="min-w-screen min-h-screen bg-linear-to-br from-left-gradient to-right-gradient">
<Navbar/> <Navbar/>
@ -253,9 +258,9 @@ export default function Account() {
<div className="glassmorphism p-10 w-full flex justify-between"> <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> <p className="text-3xl text-white mb-2 font-montserrat font-bold">{userChannel.channel.name}</p>
<button> <button>
<a href="" className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-semibold cursor-pointer"> <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 Gérer la chaîne
</a> </span>
</button> </button>
</div> </div>
) : ( ) : (
@ -263,7 +268,7 @@ export default function Account() {
<h2 className="text-3xl font-bold text-white mb-4">Chaîne</h2> <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> <p className="text-xl text-white mb-2">Aucune chaîne associée à ce compte.</p>
<button className=" mt-4"> <button className=" mt-4">
<a href="/create-channel" className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-semibold cursor-pointer"> <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 Créer une chaîne
</a> </a>
</button> </button>
@ -292,7 +297,7 @@ export default function Account() {
</div> </div>
</main> </main>
<CreateChannelModal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
</div> </div>
) )

275
frontend/src/pages/AddVideo.jsx

@ -0,0 +1,275 @@
import Navbar from "../components/Navbar.jsx";
import {useEffect, useState} from "react";
import Tag from "../components/Tag.jsx";
export default function AddVideo() {
const storedUser = localStorage.getItem("user");
const user = storedUser ? JSON.parse(storedUser) : null;
const token = localStorage.getItem("token");
const [videoTitle, setVideoTitle] = useState("");
const [videoDescription, setVideoDescription] = useState("");
const [videoTags, setVideoTags] = useState([]);
const [visibility, setVisibility] = useState("public"); // Default visibility
const [videoThumbnail, setVideoThumbnail] = useState(null);
const [videoFile, setVideoFile] = useState(null);
const [channel, setChannel] = useState(null); // Assuming user.channel is the channel ID
useEffect(() => {
fetchChannel();
}, [])
const fetchChannel = async () => {
try {
const response = await fetch(`/api/users/${user.id}/channel`, {
headers: {
"Authorization": `Bearer ${token}` // Assuming you have a token for authentication
},
});
if (!response.ok) {
throw new Error("Erreur lors de la récupération de la chaîne");
}
const data = await response.json();
setChannel(data.channel);
} catch (error) {
console.error("Erreur lors de la récupération de la chaîne :", error);
}
}
const handleTagKeyDown = (e) => {
if (e.key === 'Enter' && videoTags.length < 10) {
e.preventDefault();
const newTag = e.target.value.trim();
if (newTag && !videoTags.includes(newTag)) {
setVideoTags([...videoTags, newTag]);
e.target.value = '';
}
}
}
const handleTagRemove = (tagToRemove) => {
setVideoTags(videoTags.filter(tag => tag !== tagToRemove));
};
// This function handles the submission of the video form
const handleSubmit = async (e) => {
e.preventDefault();
if (!videoTitle || !videoDescription || !videoThumbnail || !videoFile) {
alert("Veuillez remplir tous les champs requis.");
return;
}
if (!channel || !channel.id) {
alert("Erreur: aucune chaîne trouvée. Veuillez actualiser la page.");
return;
}
if (videoTags.length > 10) {
alert("Vous ne pouvez pas ajouter plus de 10 tags.");
return;
}
const formData = new FormData();
formData.append("title", videoTitle);
formData.append("description", videoDescription);
formData.append("channel", channel.id.toString()); // Ensure it's a string
formData.append("visibility", visibility);
formData.append("file", videoFile);
const request = await fetch("/api/videos", {
method: "POST",
headers: {
"Authorization": `Bearer ${token}` // Assuming you have a token for authentication
},
body: formData
});
if (!request.ok) {
const errorData = await request.json();
console.error("Backend validation errors:", errorData);
// Display specific validation errors if available
if (errorData.errors && errorData.errors.length > 0) {
const errorMessages = errorData.errors.map(error =>
`${error.path}: ${error.msg}`
).join('\n');
alert(`Erreurs de validation:\n${errorMessages}`);
} else {
alert(`Erreur lors de l'ajout de la vidéo : ${errorData.message || 'Erreur inconnue'}`);
}
return;
}
// If the video was successfully created, we can now upload the thumbnail
const response = await request.json();
const videoId = response.id;
const thumbnailFormData = new FormData();
thumbnailFormData.append("video", videoId);
thumbnailFormData.append("file", videoThumbnail);
thumbnailFormData.append("channel", channel.id.toString());
const thumbnailRequest = await fetch("/api/videos/thumbnail", {
method: "POST",
headers: {
"Authorization": `Bearer ${token}` // Assuming you have a token for authentication
},
body: thumbnailFormData
});
if (!thumbnailRequest.ok) {
const errorData = await thumbnailRequest.json();
console.error("Backend validation errors:", errorData);
alert(`Erreur lors de l'ajout de la miniature : ${errorData.message || 'Erreur inconnue'}`);
return;
}
// if the thumbnail was successfully uploaded, we can send the tags
const tagsRequest = await fetch(`/api/videos/${videoId}/tags`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}` // Assuming you have a token for authentication
},
body: JSON.stringify({ tags: videoTags, channel: channel.id.toString() }) // Ensure channel ID is a string
});
if (!tagsRequest.ok) {
const errorData = await tagsRequest.json();
console.error("Backend validation errors:", errorData);
alert(`Erreur lors de l'ajout des tags : ${errorData.message || 'Erreur inconnue'}`);
return;
}
// If everything is successful, redirect to the video management page
alert("Vidéo ajoutée avec succès !");
};
return (
<div className="min-w-screen min-h-screen bg-linear-to-br from-left-gradient to-right-gradient">
<Navbar isSearchPage={false} />
<main className="px-36 pt-[118px]">
<h1 className="font-montserrat text-2xl font-black text-white">
Ajouter une vidéo
</h1>
<div className="flex gap-8 mt-8">
{/* Left side: Form for adding video details */}
<form className="flex-1">
<label className="block text-white text-xl font-montserrat font-semibold mb-2" htmlFor="videoTitle">Titre de la vidéo</label>
<input
type="text"
id="videoTitle"
name="videoTitle"
className="w-full p-2 mb-4 glassmorphism focus:outline-none font-inter text-xl text-white"
placeholder="Entrez le titre de la vidéo"
value={videoTitle}
onChange={(e) => setVideoTitle(e.target.value)}
required
/>
<label className="block text-white text-xl font-montserrat font-semibold mb-2" htmlFor="videoDescription">Description de la vidéo</label>
<textarea
id="videoDescription"
name="videoDescription"
className="w-full p-2 mb-4 glassmorphism focus:outline-none font-inter text-xl text-white"
placeholder="Entrez la description de la vidéo"
rows="4"
value={videoDescription}
onChange={(e) => setVideoDescription(e.target.value)}
required
></textarea>
<label className="block text-white text-xl font-montserrat font-semibold mb-1" htmlFor="videoDescription">Tags</label>
<input
type="text"
id="videoTags"
name="videoTags"
className="w-full p-2 mb-4 glassmorphism focus:outline-none font-inter text-xl text-white"
placeholder="Entrez les tags de la vidéo (entrée pour valider) 10 maximum"
onKeyDown={handleTagKeyDown}
/>
<div className="flex flex-wrap gap-2 mb-2">
{videoTags.map((tag, index) => (
<Tag tag={tag} doShowControls={true} key={index} onSuppress={() => handleTagRemove(tag)} />
))}
</div>
<label className="block text-white text-xl font-montserrat font-semibold mb-2" htmlFor="visibility">Visibilité</label>
<select
name="visibility"
id="visibility"
className="w-full p-2 mb-4 glassmorphism focus:outline-none font-inter text-xl text-white"
value={visibility}
onChange={(e) => setVisibility(e.target.value)}
>
<option value="public">Public</option>
<option value="private">Privé</option>
</select>
<label className="block text-white text-xl font-montserrat font-semibold mb-2" htmlFor="videoThumbnail">Miniature</label>
<input
type="file"
id="videoThumbnail"
name="videoThumbnail"
className="w-full p-2 mb-4 glassmorphism focus:outline-none font-inter text-xl text-white"
accept="image/*"
onChange={(e) => setVideoThumbnail(e.target.files[0])}
required
/>
<label className="block text-white text-xl font-montserrat font-semibold mb-2" htmlFor="videoFile">Fichier vidéo</label>
<input
type="file"
id="videoFile"
name="videoFile"
className="w-full p-2 mb-4 glassmorphism focus:outline-none font-inter text-xl text-white"
accept="video/*"
onChange={(e) => setVideoFile(e.target.files[0])}
required
/>
<button
type="submit"
className="bg-primary text-white font-montserrat p-3 rounded-lg text-2xl font-bold w-full cursor-pointer"
onClick={(e) => { handleSubmit(e) }}
>
Ajouter la vidéo
</button>
</form>
{/* Right side: Preview of the video being added */}
<div className="flex-1 flex justify-center">
<div className="glassmorphism p-4 rounded-lg">
<img
src={videoThumbnail ? URL.createObjectURL(videoThumbnail) : "https://placehold.co/1280x720"} alt={videoTitle}
className="w-[480px] h-auto mb-4 rounded-lg"
/>
<h2 className="text-white text-xl font-montserrat font-semibold mb-2">{videoTitle || "Titre de la vidéo"}</h2>
<div className="glassmorphism p-4 rounded-sm">
<p className="text-white font-inter mb-2">
{videoDescription || "Description de la vidéo"}
</p>
<div className="flex flex-wrap gap-2">
{videoTags.length > 0 ? (
videoTags.map((tag, index) => (
<Tag tag={tag} doShowControls={false} key={index} />
))
) : (
<span className="text-gray-400">Aucun tag ajouté</span>
)}
</div>
</div>
</div>
</div>
</div>
</main>
</div>
);
}

208
frontend/src/pages/ManageChannel.jsx

@ -0,0 +1,208 @@
import Navbar from "../components/Navbar.jsx";
import {useEffect, useState} from "react";
import {useNavigate, useParams} from "react-router-dom";
import {useAuth} from "../contexts/AuthContext.jsx";
import VideoStatListElement from "../components/VideoStatListElement.jsx";
export default function ManageChannel() {
const {id} = useParams();
const {user} = useAuth();
const navigate = useNavigate();
const [channel, setChannel] = useState();
const [channelStats, setChannelStats] = useState();
const [channelName, setChannelName] = useState(null);
const [description, setDescription] = useState(null);
const [editMode, setEditMode] = useState(false);
const token = localStorage.getItem("token");
const nonEditModeClasses = "text-2xl font-bold text-white p-2 focus:text-white focus:outline-none w-full font-montserrat resizable-none text-center";
const editModeClasses = nonEditModeClasses + " glassmorphism";
const nonEditModeClassesTextArea = "text-md font-normal text-white p-2 focus:text-white focus:outline-none w-full font-montserrat resizable-none w-full"
const editModeClassesTextArea = nonEditModeClassesTextArea + " glassmorphism h-48";
useEffect(() => {
fetchChannelData()
fetchChannelStats()
}, []);
const fetchChannelData = async () => {
try {
const request = await fetch(`/api/channels/${id}`, {
method: "GET",
headers: {
"Authorization": `Bearer ${token}`
}
})
const result = await request.json();
setChannel(result);
} catch (error) {
console.error("Error fetching channel data:", error);
}
}
const fetchChannelStats = async () => {
try {
const request = await fetch(`/api/channels/${id}/stats`, {
method: "GET",
headers: {
"Authorization": `Bearer ${token}`
}
})
const result = await request.json();
setChannelStats(result);
} catch (error) {
console.error("Error fetching channel stats", error);
}
}
const handleUpdateChannel = async () => {
if (!editMode) return;
try {
const response = await fetch(`/api/channels/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({
name: channelName || channel.name,
description: description || channel.description,
})
});
if (response.ok) {
setEditMode(false);
fetchChannelData(); // Refresh channel data after update
} else {
console.error("Failed to update channel");
}
} catch (error) {
console.error("Error updating channel:", error);
}
}
return (
<div className="min-w-screen min-h-screen bg-linear-to-br from-left-gradient to-right-gradient">
<Navbar isSearchPage={false} />
<main className="pt-[118px] px-36 flex">
{/* LEFT SIDE */}
<form className="glassmorphism w-1/3 h-screen py-10 px-4">
<img src={user.picture} className="w-1/3 aspect-square object-cover rounded-full mx-auto" alt=""/>
<label htmlFor="name" className={`text-2xl text-white mb-1 block font-montserrat ${editMode ? "block" : "hidden"} `}>
Nom de chaine
</label>
<input
type="text"
id="name"
value={channelName || channelName === "" ? channelName : channel ? channel.name : "Chargement"}
className={(editMode ? editModeClasses : nonEditModeClasses)}
onChange={(e) => setChannelName(e.target.value)}
placeholder="Nom d'utilisateur"
disabled={!editMode}
/>
<label htmlFor="name" className={`text-2xl text-white mb-1 block font-montserrat`}>
Description
</label>
<textarea
name="description"
id=""
className={(editMode ? editModeClassesTextArea : nonEditModeClassesTextArea)}
value={description || description === "" ? description : channel ? channel.description : "Chargement"}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description de votre chaine"
disabled={!editMode}
></textarea>
{
editMode ? (
<div className="mt-4">
<button
type="button"
className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-black cursor-pointer"
onClick={handleUpdateChannel}
>
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>
) : (
<button
type="button"
className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-black cursor-pointer mt-4"
onClick={() => setEditMode(!editMode)}
>
Modifier
</button>
)
}
</form>
{/* RIGHT SIDE */}
<div className="w-2/3 h-screen pl-10" >
{/* VIEW / SUBSCRIBERS STATS */}
<div className="flex gap-4" >
<div className="glassmorphism flex-1 h-32 flex flex-col justify-center items-center" >
{/* TOTAL VIEWS */}
<p className="text-white text-xl font-montserrat font-semibold" >Vues totales</p>
<p className="text-white text-2xl font-montserrat font-bold" >{channelStats ? channelStats.views : "0"}</p>
</div>
<div className="glassmorphism flex-1 h-32 flex flex-col justify-center items-center" >
{/* TOTAL SUBSCRIBERS */}
<p className="text-white text-xl font-montserrat font-semibold" >Abonnés</p>
<p className="text-white text-2xl font-montserrat font-bold" >{channelStats ? channelStats.subscribers : "0"}</p>
</div>
</div>
{/* VIDEOS */}
<div className="flex justify-between">
<h2 className="text-white text-3xl font-montserrat font-bold mt-10" >Vidéos</h2>
<button
className="bg-primary px-2 py-1 rounded-sm text-white font-montserrat text-md font-semibold cursor-pointer mt-4"
onClick={() => navigate("/add-video")}
>
Ajouter une vidéo
</button>
</div>
{ channel?.videos?.length > 0 ? (
<div className="flex flex-col gap-4 mt-5">
{channel.videos.map((video) => (
<VideoStatListElement
video={video}
onClick={() => navigate("/manage-video/" + video.id)}
key={video.id}
/>
))}
</div>
) : (
<p className="text-white text-xl font-montserrat mt-4">Aucune vidéo trouvée pour cette chaîne.</p>
)}
</div>
</main>
</div>
)
}

417
frontend/src/pages/ManageVideo.jsx

@ -0,0 +1,417 @@
import Navbar from "../components/Navbar.jsx";
import {useParams} from "react-router-dom";
import {useAuth} from "../contexts/AuthContext.jsx";
import {useEffect, useState} from "react";
import LinearGraph from "../components/LinearGraph.jsx";
import Comment from "../components/Comment.jsx";
import Tag from "../components/Tag.jsx";
export default function ManageVideo() {
const { user } = useAuth();
const token = localStorage.getItem("token");
const {id} = useParams();
const [video, setVideo] = useState(null);
const [likesPerDay, setLikesPerDay] = useState([]);
const [viewsPerDay, setViewsPerDay] = useState([]);
const [videoTitle, setVideoTitle] = useState(null);
const [description, setDescription] = useState(null);
const [visibility, setVisibility] = useState("private");
const [editMode, setEditMode] = useState(false);
const [thumbnailPreview, setThumbnailPreview] = useState(null);
const [videoFile, setVideoFile] = useState(null);
const nonEditModeClasses = "text-md font-normal text-white p-2 focus:text-white focus:outline-none w-full font-montserrat resizable-none";
const editModeClasses = nonEditModeClasses + " glassmorphism";
const nonEditModeClassesTextArea = "text-md font-normal text-white p-2 focus:text-white focus:outline-none w-full font-montserrat resizable-none w-full"
const editModeClassesTextArea = nonEditModeClassesTextArea + " glassmorphism h-48";
const fetchVideo = async () => {
const request = await fetch(`/api/videos/${id}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
});
if (!request.ok) {
throw new Error("Failed to fetch video");
}
const data = await request.json();
setVideo(data);
setVisibility(data.visibility)
setVideoTitle(data.title);
setDescription(data.description);
}
const fetchLikesPerDay = async () => {
const request = await fetch(`/api/videos/${id}/likes/day`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
});
if (!request.ok) {
throw new Error("Failed to fetch likes per day");
}
const data = await request.json();
setLikesPerDay(data.likes);
setViewsPerDay(data.views);
}
useEffect(() => {
if (user) {
fetchVideo()
fetchLikesPerDay()
}
}, [user, id, token]);
const onSubmit = async (e) => {
e.preventDefault();
if (!editMode) return;
const request = await fetch(`/api/videos/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
title: videoTitle,
description: description,
visibility: visibility,
channel: video.channel
})
});
if (!request.ok) {
console.error("Failed to update video");
return;
}
const form = new FormData();
if (videoFile) {
form.append('file', videoFile);
form.append('video', id);
form.append('channel', video.channel);
const videoRequest = await fetch(`/api/videos/${id}/video`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`
},
body: form
});
if (!videoRequest.ok) {
console.error("Failed to update video file");
return;
}
}
const data = await request.json();
setVideo(data);
setEditMode(false);
}
const handleThumbnailChange = async (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
setThumbnailPreview(e.target.result);
};
reader.readAsDataURL(file);
}
const formData = new FormData();
formData.append('file', file);
formData.append('video', id);
formData.append('channel', video.channel);
const request = await fetch(`/api/videos/thumbnail`, {
"method": 'POST',
"headers": {
"Authorization": `Bearer ${token}`
},
body: formData
})
if (!request.ok) {
console.error("Failed to upload thumbnail");
return;
}
const data = await request.json();
};
const onAddTag = async (e) => {
if (e.key !== 'Enter' || e.target.value.trim() === "") return;
const newTag = e.target.value.trim();
e.target.value = "";
const request = await fetch(`/api/videos/${id}/tags`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
tags: [...video.tags, newTag],
channel: video.channel
})
});
if (!request.ok) {
console.error("Failed to add tag");
return;
}
const data = await request.json();
setVideo({
...video,
tags: [...video.tags, newTag]
});
}
const onSuppressTag = async (tag) => {
const request = await fetch(`/api/videos/${id}/tags`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
tags: video.tags.filter(t => t !== tag),
channel: video.channel
})
});
if (!request.ok) {
console.error("Failed to suppress tag");
return;
}
const data = await request.json();
const newTags = video.tags.filter(t => t !== tag);
setVideo({
...video,
tags: newTags
});
}
return (
<div className="min-w-screen min-h-screen bg-linear-to-br from-left-gradient to-right-gradient">
<Navbar/>
<main className="px-36 pb-36">
{ /* GRAPHS */ }
<div>
<div className="flex pt-[118px] gap-4" >
<LinearGraph
dataToGraph={viewsPerDay}
legend="Vues"
className="glassmorphism flex-1 h-[300px] p-4"
/>
<LinearGraph
dataToGraph={likesPerDay}
legend="Likes"
className="glassmorphism flex-1 h-[300px] p-4"
borderColor="#FF073A"
/>
</div>
<div className="flex gap-4 mt-4 ">
{ /* LEFT SIDE */ }
<div className="flex-1" >
{ /* THUMBNAIL */ }
<label htmlFor="thumbnail" className="glassmorphism flex justify-center items-center py-5 relative overflow-hidden ">
<img src={thumbnailPreview || (video ? video.thumbnail : "")} alt="" className=" rounded-sm"/>
<div className="absolute top-0 left-0 bg-[#00000080] w-full h-full flex justify-center items-center opacity-0 hover:opacity-100 transition" >
<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" name="thumbnail" id="thumbnail" className="opacity-0 w-0 h-0 hidden" onChange={handleThumbnailChange} accept="image/*"/>
{ /* VIDEO INFOS */ }
<form className="glassmorphism p-4 mt-4 flex-1" >
<label htmlFor="name" className={`text-2xl text-white mb-1 block font-montserrat`}>
Titre de la vidéo
</label>
<input
type="text"
id="name"
value={videoTitle || videoTitle === "" ? videoTitle : video ? video.title : "Chargement"}
onChange={(e) => setVideoTitle(e.target.value)}
className={(editMode ? editModeClasses : nonEditModeClasses) + " text-xl"}
placeholder="Titre de la vidéo"
disabled={!editMode}
/>
<label htmlFor="name" className={`text-2xl text-white mb-1 block font-montserrat`}>
Description
</label>
<textarea
name="description"
id=""
className={(editMode ? editModeClassesTextArea : nonEditModeClassesTextArea)}
value={description || description === "" ? description : video ? video.description : "Chargement"}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description de votre chaine"
disabled={!editMode}
></textarea>
<label htmlFor="visibility" className="text-2xl text-white mb-1 block font-montserrat">
Visibilité
</label>
<select
className={(editMode ? editModeClasses : nonEditModeClasses)}
value={visibility}
name="visibility"
onChange={(e) => setVisibility(e.target.value)}
disabled={!editMode}
>
<option value="public">Publique</option>
<option value="private">Privée</option>
</select>
<label htmlFor="video" className={`flex gap-2 glassmorphism p-2 items-center mt-4 cursor-pointer ${editMode ? "block" : "hidden"}`}>
<video src={video ? video.file : ""} className="w-1/8 aspect-video rounded-sm" ></video>
<p className="text-2xl text-white mb-1 block font-montserrat">Fichier vidéo</p>
</label>
<input
type="file"
name="video"
id="video"
className="hidden"
accept="video/*"
onChange={(e) => setVideoFile(e.target.files[0])}
/>
{
editMode ? (
<div className="mt-4">
<button
type="button"
className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-black cursor-pointer"
onClick={(e) => {onSubmit(e)}}
>
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>
) : (
<button
type="button"
className="bg-primary p-3 rounded-sm text-white font-montserrat text-2xl font-black cursor-pointer mt-4"
onClick={() => setEditMode(!editMode)}
>
Modifier
</button>
)
}
</form>
</div>
{ /* RIGHT SIDE */ }
<div className="flex-1">
<div className="flex gap-4">
{ /* TOTAL VIEWS */ }
<div className="glassmorphism py-16 flex-1">
<p className="text-2xl font-bold text-white mb-2 text-center">{video ? video.views : 0} vues</p>
</div>
{ /* TOTAL LIKES */ }
<div className="glassmorphism py-16 flex-1">
<p className="text-2xl font-bold text-white mb-2 text-center">{video ? video.likes : 0} likes</p>
</div>
</div>
{ /* COMMENTS */ }
<div className="glassmorphism p-4 mt-4 h-[500px] overflow-y-auto">
<h2 className="text-2xl font-bold text-white mb-4">Commentaires</h2>
{video && video.comments && video.comments.length > 0 ? (
video.comments.map((comment) => (
<Comment comment={comment} doShowCommands={false} />
))
) : (
<p className="text-gray-500">Aucun commentaire</p>
)}
</div>
{ /* TAGS */ }
<div className="glassmorphism p-4 mt-4">
<h2 className="text-2xl font-bold text-white mb-4">Tags</h2>
<div className="flex flex-wrap gap-2">
{ video && video.tags && video.tags.length > 0 ? (
video.tags.map((tag) => (
<Tag tag={tag} key={tag} onSuppress={() => onSuppressTag(tag)} />
))
) : (
<p className="text-gray-500">Aucun tag</p>
)}
</div>
<input
type="text"
className="glassmorphism text-md font-normal text-white p-2 focus:text-white focus:outline-none w-full font-montserrat resizable-none mt-4"
placeholder="Ajouter un tag"
onKeyPress={(e) => onAddTag(e)}
/>
</div>
{ /* LINK */ }
<div className="glassmorphism p-4 mt-4">
<h2 className="text-2xl font-bold text-white mb-4">Lien de la vidéo</h2>
<p className="text-md font-normal text-white">
<a href={`/video/${id}`} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">
{window.location.origin}/video/{id}
</a>
<button
className="ml-2 bg-primary text-white p-2 rounded-sm cursor-pointer"
onClick={() => {
navigator.clipboard.writeText(`${window.location.origin}/video/${id}`);
}}
>
Copier
</button>
</p>
</div>
</div>
</div>
</div>
</main>
</div>
)
}

8
frontend/src/pages/Video.jsx

@ -4,6 +4,7 @@ import Navbar from "../components/Navbar.jsx";
import { useAuth } from "../contexts/AuthContext.jsx"; import { useAuth } from "../contexts/AuthContext.jsx";
import Comment from "../components/Comment.jsx"; import Comment from "../components/Comment.jsx";
import VideoCard from "../components/VideoCard.jsx"; import VideoCard from "../components/VideoCard.jsx";
import Tag from "../components/Tag.jsx";
export default function Video() { export default function Video() {
@ -396,12 +397,7 @@ export default function Video() {
<div className="mb-3"> <div className="mb-3">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{video.tags.map((tag, index) => ( {video.tags.map((tag, index) => (
<span <Tag tag={tag} key={index} doShowControls={false} />
key={index}
className="bg-gray-700 text-white px-3 py-1 rounded-full text-sm font-montserrat"
>
#{tag}
</span>
))} ))}
</div> </div>
</div> </div>

27
frontend/src/routes/routes.jsx

@ -4,6 +4,9 @@ import Register from '../pages/Register.jsx'
import Video from '../pages/Video.jsx' import Video from '../pages/Video.jsx'
import ProtectedRoute from '../components/ProtectedRoute.jsx' import ProtectedRoute from '../components/ProtectedRoute.jsx'
import Account from "../pages/Account.jsx"; import Account from "../pages/Account.jsx";
import ManageChannel from "../pages/ManageChannel.jsx";
import ManageVideo from "../pages/ManageVideo.jsx";
import AddVideo from "../pages/AddVideo.jsx";
const routes = [ const routes = [
{ path: "/", element: <Home/> }, { path: "/", element: <Home/> },
@ -34,6 +37,30 @@ const routes = [
<Account/> <Account/>
</ProtectedRoute> </ProtectedRoute>
) )
},
{
path: "/manage-channel/:id",
element: (
<ProtectedRoute requireAuth={true}>
<ManageChannel/>
</ProtectedRoute>
)
},
{
path: "/manage-video/:id",
element: (
<ProtectedRoute requireAuth={true}>
<ManageVideo/>
</ProtectedRoute>
)
},
{
path: "/add-video",
element: (
<ProtectedRoute requireAuth={true}>
<AddVideo/>
</ProtectedRoute>
)
} }
] ]

21
nginx/default.conf

@ -2,17 +2,36 @@ server {
server_name localhost; server_name localhost;
listen 80; listen 80;
return 301 https://$host$request_uri;
}
server {
server_name localhost;
listen 443 ssl;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html index.htm; index index.html index.htm;
# Allow large file uploads for videos (up to 500MB)
client_max_body_size 500M;
ssl_certificate /etc/nginx/ssl/nginx-selfsigned.crt;
ssl_certificate_key /etc/nginx/ssl/nginx-selfsigned.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# API routes - proxy to backend (MUST come before static file rules) # API routes - proxy to backend (MUST come before static file rules)
location /api/ { location /api/ {
proxy_pass http://127.0.0.1:8000; proxy_pass http://resit_backend:8000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off; proxy_buffering off;
# Also set timeout for large uploads
proxy_read_timeout 300s;
proxy_send_timeout 300s;
} }
# Static assets - NO CACHING for development # Static assets - NO CACHING for development

23
nginx/nginx-selfsigned.crt

@ -0,0 +1,23 @@
-----BEGIN CERTIFICATE-----
MIID5zCCAs+gAwIBAgIUXzNzqa/12lyIcoxXf+v371J3fWkwDQYJKoZIhvcNAQEL
BQAwgYIxCzAJBgNVBAYTAkZSMREwDwYDVQQIDAhOb3JtYW5keTENMAsGA1UEBwwE
Q2FlbjERMA8GA1UECgwIRnJhbWluZm8xFTATBgNVBAMMDFNhY2hhIEdVRVJJTjEn
MCUGCSqGSIb3DQEJARYYc2FjaGEuZ3VlcmluQHN1cGluZm8uY29tMB4XDTI1MDcy
MTEzMzgwMVoXDTI2MDcyMTEzMzgwMVowgYIxCzAJBgNVBAYTAkZSMREwDwYDVQQI
DAhOb3JtYW5keTENMAsGA1UEBwwEQ2FlbjERMA8GA1UECgwIRnJhbWluZm8xFTAT
BgNVBAMMDFNhY2hhIEdVRVJJTjEnMCUGCSqGSIb3DQEJARYYc2FjaGEuZ3Vlcmlu
QHN1cGluZm8uY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvLg7
nR0UqRZ7UadhI8jrUjRMV1SZj+ljxEnV6tDOVMsvafsym1MhDZHb+cyv8769yqPv
CKtIOQKhMH0PkSqau8szNlF1Tg/1UzT+Mkd4zvLvGE5+aW/oDMg7E2LMJZuCyO4X
9SzWDVA5+b1QFIw6vvb3mCkUOtVDkOFreBBwryZKcWJ0b8o1hT60oB2wr18P14j0
0C2/TmHMtim0o4r3gKGvpatqt1fXJo0UlYOwTvfMrYhu2VHqsQ2qP7ocazXEWt5u
Alf1vNPkAenF0ZV/2UiaL41Q8GMoV1enDP7k7/qfgXvta/hOeYnLtmv5Qpi4XiWz
xKjSukTUD2sRtSX+YQIDAQABo1MwUTAdBgNVHQ4EFgQUVj9KtmjLFy4xWzkNI9Kq
NAxNsfUwHwYDVR0jBBgwFoAUVj9KtmjLFy4xWzkNI9KqNAxNsfUwDwYDVR0TAQH/
BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAGpUPMoF/ASIqOOfX5anDtaPvnslj
DuEVbU7Uoyc4EuSD343DPV7iMUKoFvVLPxJJqMLhSo3aEGJyqOF6q3fvq/vX2VE7
9MhwS1t2DBGb5foWRosnT1EuqFU1/S0RJ/Y+GNcoY1PrUES4+r7zqqJJjwKOzneV
ktUVCdKl0C1gtw6W4Ajxse3fm9DNLxnZZXbyNqn+KbI8QdO0xSEl+gyiycvPu/NT
+EesdlFoYjO7gdA8dXkmu+Z7R61MYhE9Zvyop5KVMqgU8/Ym04UUWjWQYWWLMyuu
bxngE4XNEI5fhg+0e/I25xJJ9wVV/ZNAF4+XOylHz/CmU8V/SPKuGXBGHg==
-----END CERTIFICATE-----

28
nginx/nginx-selfsigned.key

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC8uDudHRSpFntR
p2EjyOtSNExXVJmP6WPESdXq0M5Uyy9p+zKbUyENkdv5zK/zvr3Ko+8Iq0g5AqEw
fQ+RKpq7yzM2UXVOD/VTNP4yR3jO8u8YTn5pb+gMyDsTYswlm4LI7hf1LNYNUDn5
vVAUjDq+9veYKRQ61UOQ4Wt4EHCvJkpxYnRvyjWFPrSgHbCvXw/XiPTQLb9OYcy2
KbSjiveAoa+lq2q3V9cmjRSVg7BO98ytiG7ZUeqxDao/uhxrNcRa3m4CV/W80+QB
6cXRlX/ZSJovjVDwYyhXV6cM/uTv+p+Be+1r+E55icu2a/lCmLheJbPEqNK6RNQP
axG1Jf5hAgMBAAECggEAAj+hmDRx6jafAAf67sqi3ZgEGEmBkXNeeLGBTPc/qhxd
ip6krTELnz8TE26RG5LYXzslasUNrn42nIImvBT5ZkcjcosKpWfEqQEAjc1PQovC
9eyKnKfw4TpUvvmiveT4T98vCYEOOqHE0/WTdlOoaBY/f+sZKQYu+1NMtAjFcg2r
vVqwsZb5vGyh7CKmIHZnz3UP8P+7G5digiNRne18pGnE2oTnSoQ3/QIqUWBs69DS
k5ew+CSyTLiUFFnMnE4adwyg6wAud5fBlzowF6UF2agToX7pxEaGxGvpBGG034kk
1UXaB/d5YwcsBeH+x5cNMLKZy4zqjoxEEW31Q466NQKBgQDtKk1R/slpTpRqvtBT
NC7InvjcCBXkXttylQHJRN9glqhmflEOe8iMW1/qRwBPlQgK1wq/sXySanVv2+gO
JGq8XNRLbHyG3YRyshdnJHP1HoWQE0uedD/rfqgkNaW5S1IvHrD7Q7tOvCrF+KbS
612pmIgNVzn+inafDXPhMZc4pQKBgQDLtQGAu2eK58ewndyL8+7+VHZSTEtKpt+h
G/U/ccv+6NGqdxI5YUkrJ7k6vV81VeRMvmN9uUS/i8znORFQmm6noRVkhXytwW5B
HXq2co4WRvv9b/XqcqS0GSYVPJ1u4YNH6lvtDZ4UWPyBzYl700GdHrGa+erT44yL
tnibHx9GDQKBgFW1J+Qt85O+9hvtgVPQU+fkq4K42VCCh0PNXavi2+cICyufEqPt
T/iJPQxpRE9+SD3CoPvNpHs1ReN60U3rEzenRIFNX2NNwoPAoHyBy/YVZac/keBd
mov8Zb9QM+fWtIiaytLDE3nMvph017T5ogucN+66SxcV6vBn6CzFwySRAoGAcUf2
Tv1ohkGAtgIDrLx5cmvL5NZSpHAKOpDOoHqLA/W66v4OX2RviRUtF7JJ6OIb9GWH
9Fl8Fr0KtKbyrw1CbevRdrYY8JN52bIoFJ+9zjupVHXXnookd5boq7SqpAe6ttpo
RnplJ1GZEiIXy4lemp6AC/vhD/YhqWxOw4zaGl0CgYBslhqVt5F0EHf94p7NrCuY
hNHKHaNaULYP0VXKefQamt/ssDuktqb6DNSIvx2rbbB5+33nTlLTya67gimY1lKt
WeNB33/yBkCjfSP/J5UDD9mE/oPLt3vAOkOUgMCfp2IpC2Wez1QGqLHS260zpotP
VpgalHuSWtn8D4nO2pk1hg==
-----END PRIVATE KEY-----
Loading…
Cancel
Save