Browse Source

Merge pull request #9 from Astri4-4/manage-video

Manage video
features/create-channel
Sacha GUERIN 5 months ago
committed by GitHub
parent
commit
f1cd373ee2
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 9
      backend/app/controllers/channel.controller.js
  2. 6
      backend/app/controllers/comment.controller.js
  3. 18
      backend/app/controllers/playlist.controller.js
  4. 2
      backend/app/controllers/recommendation.controller.js
  5. 2
      backend/app/controllers/search.controller.js
  6. 19
      backend/app/controllers/user.controller.js
  7. 76
      backend/app/controllers/video.controller.js
  8. 5
      backend/app/routes/video.route.js
  9. BIN
      backend/app/uploads/videos/946FFC1D2D8C189D.mp4
  10. 3
      backend/app/utils/database.js
  11. 1773
      backend/logs/access.log
  12. 6
      backend/requests/video.http
  13. 6
      backend/server.js
  14. 37
      frontend/package-lock.json
  15. 2
      frontend/package.json
  16. 7
      frontend/src/components/Comment.jsx
  17. 52
      frontend/src/components/LinearGraph.jsx
  18. 22
      frontend/src/components/Tag.jsx
  19. 417
      frontend/src/pages/ManageVideo.jsx
  20. 8
      frontend/src/pages/Video.jsx
  21. 9
      frontend/src/routes/routes.jsx
  22. 6
      nginx/default.conf

9
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.write("Successfully created new channel with name " + channel.name, 200);
client.end();
res.status(200).json(channel);
}
@ -50,6 +51,7 @@ export async function getById(req, res) {
result.rows[0].videos = videoResult.rows;
logger.write("Successfully get channel with id " + id, 200);
client.end();
res.status(200).json(result.rows[0]);
}
@ -85,6 +87,7 @@ export async function getAll(req, res) {
})
logger.write("Successfully get all channels", 200);
client.end();
res.status(200).json(result);
}
@ -108,6 +111,7 @@ export async function update(req, res) {
const nameResult = await client.query(nameQuery, [channel.name]);
if (nameResult.rows.length > 0) {
logger.write("failed to update channel because name already taken", 400);
client.end();
res.status(400).json({error: 'Name already used'});
return
}
@ -116,6 +120,7 @@ export async function update(req, res) {
const updateQuery = `UPDATE channels SET name = $1, description = $2 WHERE id = $3`;
await client.query(updateQuery, [channel.name, channel.description, id]);
logger.write("Successfully updated channel", 200);
client.end();
res.status(200).json(channel);
}
@ -128,6 +133,7 @@ export async function del(req, res) {
const query = `DELETE FROM channels WHERE id = $1`;
await client.query(query, [id]);
logger.write("Successfully deleted channel", 200);
client.end();
res.status(200).json({message: 'Successfully deleted'});
}
@ -152,6 +158,7 @@ export async function toggleSubscription(req, res) {
const remainingSubscriptions = countResult.rows[0].count;
logger.write("Successfully unsubscribed from channel", 200);
client.end();
res.status(200).json({message: 'Unsubscribed successfully', subscriptions: remainingSubscriptions});
} else {
// Subscribe
@ -164,6 +171,7 @@ export async function toggleSubscription(req, res) {
const totalSubscriptions = countResult.rows[0].count;
logger.write("Successfully subscribed to channel", 200);
client.end();
res.status(200).json({message: 'Subscribed successfully', subscriptions: totalSubscriptions});
}
}
@ -189,6 +197,7 @@ export async function getStats(req, res) {
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);

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

@ -39,7 +39,7 @@ export async function upload(req, res) {
createdAt: createdAt
}
client.end();
res.status(200).json(responseComment);
}
@ -52,6 +52,7 @@ export async function getByVideo(req, res) {
const query = `SELECT * FROM comments WHERE video = $1`;
const result = await client.query(query, [videoId]);
logger.write("successfully get comment", 200);
client.end()
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 result = await client.query(query, [id]);
logger.write("successfully get comment", 200);
client.end();
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 result = await client.query(query, [req.body.content, id]);
logger.write("successfully update comment", 200);
client.end();
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 result = await client.query(query, [id]);
logger.write("successfully deleted comment", 200);
client.end();
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 {
const result = await client.query(query, [name, userId]);
logger.write("Playlist created with id " + result.rows[0].id, 200);
client.end()
res.status(200).json({ id: result.rows[0].id });
} catch (error) {
logger.write("Error creating playlist: " + error.message, 500);
client.end();
res.status(500).json({ error: "Internal server error" });
}
}
@ -31,9 +33,11 @@ export async function addVideo(req, res) {
try {
const result = await client.query(query, [video, id]);
logger.write("Video added to playlist with id " + id, 200);
client.end();
res.status(200).json({id: result.rows[0].id});
} catch (error) {
logger.write("Error adding video to playlist: " + error.message, 500);
client.end();
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]);
if (result.rows.length === 0) {
logger.write("No playlists found for user with id " + id, 404);
client.end();
res.status(404).json({ error: "No playlists found" });
return;
}
logger.write("Playlists retrieved for user with id " + id, 200);
client.end();
res.status(200).json(result.rows);
} catch (error) {
logger.write("Error retrieving playlists: " + error.message, 500);
client.end();
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]);
if (result.rows.length === 0) {
logger.write("No playlist found with id " + id, 404);
client.end();
res.status(404).json({ error: "Playlist not found" });
return;
}
logger.write("Playlist retrieved with id " + id, 200);
client.end();
res.status(200).json(result.rows[0]);
} catch (error) {
logger.write("Error retrieving playlist: " + error.message, 500);
client.end();
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]);
if (result.rows.length === 0) {
logger.write("No playlist found with id " + id, 404);
client.end();
res.status(404).json({ error: "Playlist not found", result: result.rows, query: query });
return;
}
logger.write("Playlist updated with id " + result.rows[0].id, 200);
client.end();
res.status(200).json({ id: result.rows[0].id });
} catch (error) {
logger.write("Error updating playlist: " + error.message, 500);
client.end();
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]);
if (result.rows.length === 0) {
logger.write("No video found in playlist with id " + id, 404);
client.end();
res.status(404).json({ error: "Video not found in playlist" });
return;
}
logger.write("Video deleted from playlist with id " + id, 200);
client.end();
res.status(200).json({ id: result.rows[0].id });
} catch (error) {
logger.write("Error deleting video from playlist: " + error.message, 500);
client.end();
res.status(500).json({ error: "Internal server error" });
}
}
@ -148,9 +164,11 @@ export async function del(req, res) {
try {
const result = await client.query(query, [id]);
logger.write("Playlist deleted", 200);
client.end()
res.status(200).json({ "message": "playlist deleted" });
} catch (error) {
logger.write("Error deleting playlist: " + error.message, 500);
client.end();
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
client.end()
res.status(200).json({
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);
} catch (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);

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

@ -99,6 +99,7 @@ export async function login(req, res) {
}
logger.write("Successfully logged in", 200);
client.end();
res.status(200).json({token: token, user: userData});
}
@ -108,15 +109,20 @@ export async function getById(req, res) {
const logger = req.body.logger;
logger.action("try to retrieve user " + id);
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]);
if (!result.rows[0]) {
logger.write("failed to retrieve user " + id + " because it doesn't exist", 404);
client.end()
res.status(404).json({error: "Not Found"});
return
}
logger.write("successfully retrieved user " + id, 200);
if (result.rows[0].picture) {
return res.status(200).json({user: result.rows[0]});
}
}
export async function getByUsername(req, res) {
@ -128,10 +134,12 @@ export async function getByUsername(req, res) {
const result = await client.query(query, [username]);
if (!result.rows[0]) {
logger.write("failed to retrieve user " + username + " because it doesn't exist", 404);
client.end()
res.status(404).json({error: "Not Found"});
return
}
logger.write("successfully retrieved user " + username, 200);
client.end();
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]);
if (emailResult.rows[0]) {
logger.write("failed to update because email is already used", 400)
client.end();
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]);
if (usernameResult.rows[0]) {
logger.write("failed to update because username is already used", 400)
client.end();
res.status(400).json({error: "Username already exists"});
}
}
@ -204,9 +214,11 @@ export async function update(req, res) {
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, profilePicture, id]);
logger.write("successfully updated user " + id, 200);
client.end();
res.status(200).json(result.rows[0]);
} catch (err) {
console.log(err);
client.end()
res.status(500).json({error: err});
}
@ -220,6 +232,7 @@ export async function deleteUser(req, res) {
const query = `DELETE FROM users WHERE id = $1`;
await client.query(query, [id]);
logger.write("successfully deleted user " + id);
client.end();
res.status(200).json({message: 'User deleted'});
}
@ -234,11 +247,13 @@ export async function getChannel(req, res) {
if (!result.rows[0]) {
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"});
return;
}
logger.write("successfully retrieved channel of user " + id, 200);
client.end();
res.status(200).json({channel: result.rows[0]});
}
@ -285,9 +300,11 @@ export async function getHistory(req, res) {
if (!result.rows[0]) {
logger.write("failed to retrieve history of user " + id + " because it doesn't exist", 404);
res.status(404).json({error: "History Not Found"});
client.end();
return;
}
logger.write("successfully retrieved history of user " + id, 200);
client.end();
res.status(200).json(videos);
}

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

@ -52,6 +52,7 @@ export async function upload(req, res) {
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)`;
await client.query(query, [video.title, 'null', video.description, video.channel, video.visibility, video.file, video.slug, video.format, releaseDate]);
logger.write("successfully uploaded video", 200);
await client.end()
res.status(200).json({"message": "Successfully uploaded video"});
}
@ -75,6 +76,7 @@ export async function uploadThumbnail(req, res) {
const updateQuery = `UPDATE videos SET thumbnail = $1 WHERE id = $2`;
await client.query(updateQuery, [file, req.body.video]);
logger.write("successfully uploaded thumbnail", 200);
await client.end();
res.status(200).json({"message": "Successfully uploaded thumbnail"});
}
@ -97,7 +99,7 @@ export async function getById(req, res) {
video.likes = likesResult.rows[0].like_count;
// 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]);
video.comments = commentsResult.rows;
@ -122,6 +124,7 @@ export async function getById(req, res) {
video.tags = tagsResult.rows.map(tag => tag.name);
logger.write("successfully get video " + id, 200);
client.end()
res.status(200).json(video);
}
@ -133,6 +136,7 @@ export async function getByChannel(req, res) {
const query = `SELECT * FROM videos WHERE channel = $1`;
const result = await client.query(query, [id]);
logger.write("successfully get video from channel " + id, 200);
client.end()
res.status(200).json(result.rows);
}
@ -144,6 +148,7 @@ export async function update(req, res) {
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]);
logger.write("successfully updated video", 200);
client.end()
res.status(200).json({"message": "Successfully updated video"});
}
@ -161,6 +166,7 @@ export async function updateVideo(req, res) {
fs.unlink(pathToDelete, (error) => {
if (error) {
logger.write(error, 500);
client.end()
res.status(500).json({"message": "Failed to delete video"});
return
}
@ -172,6 +178,7 @@ export async function updateVideo(req, res) {
fs.writeFileSync(destinationPath, fileBuffer);
logger.write("successfully updated video", 200);
client.end()
res.status(200).json({"message": "Successfully updated video"});
})
@ -193,6 +200,7 @@ export async function del(req, res) {
fs.unlink(pathToDelete, (error) => {
if (error) {
logger.write(error, 500);
client.end()
res.status(500).json({"message": "Failed to delete video"});
return
}
@ -207,6 +215,7 @@ export async function del(req, res) {
const query = `DELETE FROM videos WHERE id = $1`;
await client.query(query, [id]);
logger.write("successfully deleted video", 200);
client.end()
res.status(200).json({"message": "Successfully deleted video"});
})
})
@ -237,6 +246,7 @@ export async function toggleLike(req, res) {
const likesCount = likesCountResult.rows[0].like_count;
logger.write("no likes found adding likes for video " + id, 200);
client.end();
res.status(200).json({"message": "Successfully added like", "likes": likesCount});
} else {
const query = `DELETE FROM likes WHERE owner = $1 AND video = $2`;
@ -248,6 +258,7 @@ export async function toggleLike(req, res) {
const likesCount = likesCountResult.rows[0].like_count;
logger.write("likes found, removing like for video " + id, 200);
client.end();
res.status(200).json({"message": "Successfully removed like", "likes": likesCount});
}
@ -295,9 +306,15 @@ export async function addTags(req, res) {
const insertVideoTagQuery = `INSERT INTO video_tags (tag, video) VALUES ($1, $2)`;
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);
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 +372,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);
}
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) {
const id = req.params.id;
const logger = req.body.logger;
@ -378,5 +447,6 @@ export async function addViews(req, res) {
}
logger.write("successfully added views for video " + id, 200);
await client.end();
res.status(200).json({"message": "Successfully added views"});
}

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

@ -10,7 +10,7 @@ import {
uploadThumbnail,
updateVideo,
toggleLike,
addTags, getSimilarVideos, addViews
addTags, getSimilarVideos, addViews, getLikesPerDay
} from "../controllers/video.controller.js";
import {
doVideoExists,
@ -58,5 +58,8 @@ router.get("/:id/similar", [addLogger, Video.id, validator, doVideoExistsParam],
// ADD VIEWS
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;

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 (
id SERIAL PRIMARY KEY,
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);

1773
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
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}}
### 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
Authorization: Bearer {{token}}
@ -26,7 +26,7 @@ Authorization: Bearer {{token}}
"Create Mod",
"Redstone"
],
"channel": 2
"channel": 1
}
###

6
backend/server.js

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

37
frontend/package-lock.json

@ -9,7 +9,10 @@
"version": "0.0.0",
"dependencies": {
"@tailwindcss/vite": "^4.1.11",
"chart.js": "^4.5.0",
"chartjs": "^0.3.24",
"react": "^19.1.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.6.3",
"tailwindcss": "^4.1.11"
@ -1004,6 +1007,12 @@
"@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": {
"version": "1.0.0-beta.19",
"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"
}
},
"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": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@ -2999,6 +3026,16 @@
"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": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",

2
frontend/package.json

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

7
frontend/src/components/Comment.jsx

@ -2,7 +2,7 @@ import {useAuth} from "../contexts/AuthContext.jsx";
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 commentRef = useRef();
@ -82,8 +82,11 @@ export default function Comment({ comment, index, videoId, refetchVideo }) {
className="w-8 h-8 rounded-full object-cover mr-3"
/>
<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>
<p className={(editMode) ? editClass : "text-white focus:outline-none "} ref={commentRef}>{comment.content}</p>
{
doShowCommands && (
<div className="flex gap-2 items-center mt-2">
{ isAuthenticated && user.username === comment.username && editMode === false ? (
<button className="text-blue-500 mt-2 hover:underline" onClick={() => handleEdit(comment.id)}>
@ -115,6 +118,8 @@ export default function Comment({ comment, index, videoId, refetchVideo }) {
</button>
) : null }
</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>
)
}

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

9
frontend/src/routes/routes.jsx

@ -5,6 +5,7 @@ import Video from '../pages/Video.jsx'
import ProtectedRoute from '../components/ProtectedRoute.jsx'
import Account from "../pages/Account.jsx";
import ManageChannel from "../pages/ManageChannel.jsx";
import ManageVideo from "../pages/ManageVideo.jsx";
const routes = [
{ path: "/", element: <Home/> },
@ -43,6 +44,14 @@ const routes = [
<ManageChannel/>
</ProtectedRoute>
)
},
{
path: "/manage-video/:id",
element: (
<ProtectedRoute requireAuth={true}>
<ManageVideo/>
</ProtectedRoute>
)
}
]

6
nginx/default.conf

@ -12,6 +12,9 @@ server {
root /usr/share/nginx/html;
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;
@ -26,6 +29,9 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
# Also set timeout for large uploads
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
# Static assets - NO CACHING for development

Loading…
Cancel
Save