diff --git a/client/src/components/Comment.tsx b/client/src/components/Comment.tsx
index b40d6d7..a0eecb8 100644
--- a/client/src/components/Comment.tsx
+++ b/client/src/components/Comment.tsx
@@ -1,182 +1,217 @@
-import Card from "react-bootstrap/Card";
-import Button from "./Button.tsx";
-import { CommentData } from "../types/CommentData.ts";
-import { Form } from "react-bootstrap";
-import { useEffect, useState } from "react";
-import { CaretUpFill, CaretUp } from "react-bootstrap-icons";
+import React, { useState, useEffect } from "react";
+import { Card, Button, Form, Dropdown, Modal } from "react-bootstrap";
import { useRemark } from "react-remark";
+import { CaretUpFill, CaretUp } from "react-bootstrap-icons";
import dayjs from "dayjs";
+import { CommentData } from "../types/CommentData";
+import CommentForm from "./CommentForm";
-type CommentProps = NonEditableCommentProps | EditableCommentProps;
-
-type NonEditableCommentProps = {
+type CommentProps = {
comment: CommentData;
onSubmit: (parentId: number, text: string) => void;
- editable: false;
+ onDelete: (commentId: number) => void;
+ onEdit: (commentId: number, newText: string) => void;
depth: number;
+ currentUserId: string;
+ isAdmin: boolean;
};
-type EditableCommentProps = {
- parentId?: number;
- onSubmit: (parentId: number, text: string) => void;
- editable: true;
-};
-
-type UpvoterProps = {
- commentId: number;
- upVotes: number;
- hasUpvoted: boolean;
-};
-
-const Upvoter = ({ commentId, upVotes, hasUpvoted }: UpvoterProps) => {
- const [upVotesState, setUpVotesState] = useState(upVotes);
- const [hasUpvotedState, setHasUpvotedState] = useState(hasUpvoted);
- return (
-
- );
-};
-
-const NonEditableComment = ({
+const Comment = ({
comment,
onSubmit,
+ onDelete,
+ onEdit,
depth,
-}: NonEditableCommentProps) => {
+ currentUserId,
+ isAdmin,
+}: CommentProps) => {
const [isReplying, setIsReplying] = useState(false);
+ const [isEditing, setIsEditing] = useState(false);
const [repliesOpen, setRepliesOpen] = useState(false);
- const repliesAvailable = comment.replies.length !== 0;
+ const [newText, setNewText] = useState(comment.text);
const [renderedMarkdown, setMarkdownSource] = useRemark();
+ const [showDeleteModal, setShowDeleteModal] = useState(false);
useEffect(() => {
setMarkdownSource(comment.text);
}, [comment.text]);
- console.log(comment);
+ const handleReply = (_: never, text: string) => {
+ if (typeof text !== "string" || !text.trim()) return;
+ onSubmit(comment.id, text);
+ setIsReplying(false);
+ };
+
+ const handleEdit = () => {
+ if (typeof newText !== "string" || !newText.trim()) return;
+ onEdit(comment.id, newText);
+ setIsEditing(false);
+ };
+
+ const handleDelete = () => {
+ onDelete(comment.id);
+ setShowDeleteModal(false);
+ };
+
+ const handleUpvote = () => {
+ fetch(`/api/comments/upvote/${comment.id}`, {
+ method: "POST",
+ })
+ .then((response) => {
+ if (!response.ok) {
+ throw new Error(`Failed to upvote comment: ${response.statusText}`);
+ }
+ // Optionally update UI optimistically or refetch comments
+ })
+ .catch((err) => {
+ console.error(err);
+ });
+ };
+
+ const handleShowDeleteModal = () => {
+ setShowDeleteModal(true);
+ };
+ const handleCloseDeleteModal = () => {
+ setShowDeleteModal(false);
+ };
return (
<>
- {comment.userId} {/* Displaying username */}
- {dayjs(comment.createdAt).format("MMM D, YYYY")}{" "}
- {/* Formatting and displaying date */}
-
-
-
- {renderedMarkdown}
-
-
- {depth === 0 && (
- <>
-
- {repliesAvailable && (
-
- )}
- >
+ Edit
+
+
+ Delete
+
+
+
+ )}
+
+
+
+ {isEditing ? (
+
+ {
+ setNewText(e.target.value);
+ }}
+ />
+
+
+
+ ) : (
+ {renderedMarkdown}
+ )}
+
+
+ {depth === 0 && (
+
+ )}
+ {depth === 0 && comment.replies.length > 0 && (
+
)}
-
- {depth === 0 && (
-
- {isReplying && (
+ {isReplying && (
+
+
+
+ )}
+ {repliesOpen && (
+
+ {comment.replies.map((reply) => (
- )}
- {repliesAvailable &&
- repliesOpen &&
- comment.replies.map((c) => (
-
- ))}
+ ))}
)}
- >
- );
-};
-
-const EditableComment = ({ onSubmit, parentId }: EditableCommentProps) => {
- const [newComment, setNewComment] = useState
("");
- return (
-
-
-
- {
- setNewComment(e.target.value);
- }}
- />
-
-
-
- );
-};
-
-const Comment = ({ editable, ...otherProps }: CommentProps) => {
- return editable ? (
-
- ) : (
-
+
+ Delete
+
+
+
+ >
);
};
diff --git a/client/src/components/CommentForm.tsx b/client/src/components/CommentForm.tsx
new file mode 100644
index 0000000..69615ac
--- /dev/null
+++ b/client/src/components/CommentForm.tsx
@@ -0,0 +1,42 @@
+import React, { useState } from "react";
+import { Card, Form, Button } from "react-bootstrap";
+
+type CommentFormProps = {
+ parentId?: number;
+ onSubmit: (parentId: number | undefined, text: string) => void;
+};
+
+const CommentForm = ({ parentId, onSubmit }: CommentFormProps) => {
+ const [text, setText] = useState("");
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (typeof text !== "string" || !text.trim()) return;
+ onSubmit(parentId, text);
+ setText("");
+ };
+
+ return (
+
+
+
+ {
+ setText(e.target.value);
+ }}
+ />
+
+
+ Submit
+
+
+
+
+ );
+};
+
+export default CommentForm;
diff --git a/client/src/pages/Solution.tsx b/client/src/pages/Solution.tsx
index 77f3fbc..dc16750 100644
--- a/client/src/pages/Solution.tsx
+++ b/client/src/pages/Solution.tsx
@@ -1,11 +1,19 @@
import React, { useState, useEffect } from "react";
-import { Container, Row, Col, Card } from "react-bootstrap";
+import {
+ Container,
+ Row,
+ Col,
+ Card,
+ Modal,
+ Button as BootstrapButton,
+ Dropdown,
+} from "react-bootstrap";
import { useParams } from "react-router-dom";
-import { SolutionData } from "../types/SolutionData.ts";
-import { CommentData } from "../types/CommentData.ts";
-import Button from "../components/Button.tsx";
-import Comment from "../components/Comment.tsx";
-import useCheckRole from "../hooks/useCheckRole.tsx"; // Make sure the path is correct
+import { SolutionData } from "../types/SolutionData";
+import { CommentData } from "../types/CommentData";
+import Comment from "../components/Comment";
+import CommentForm from "../components/CommentForm";
+import useCheckRole from "../hooks/useCheckRole"; // Make sure the path is correct
import dayjs from "dayjs";
function loadSolution(id, setter, setForbidden) {
@@ -43,6 +51,7 @@ const Solution = () => {
const { isAdmin, isLoading } = useCheckRole();
const [currentUserId, setCurrentUserId] = useState(null);
const [forbidden, setForbidden] = useState(false);
+ const [showDeleteModal, setShowDeleteModal] = useState(false);
useEffect(() => {
if (id) {
@@ -51,6 +60,8 @@ const Solution = () => {
}
}, [id]);
+ console.log(comments);
+
useEffect(() => {
fetch("/api/users/whoami")
.then((response) => response.json())
@@ -73,15 +84,7 @@ const Solution = () => {
});
};
- if (isLoading) {
- return Loading...
;
- }
- if (forbidden) {
- return Access denied
;
- }
-
- const handleDelete = (commentId) => {
- if (!isAdmin) return;
+ const handleDeleteComment = (commentId) => {
fetch(`/api/comments/${commentId}`, { method: "DELETE" })
.then(() => {
setComments((comments) =>
@@ -93,10 +96,24 @@ const Solution = () => {
});
};
- const handleSubmit = (parentId, text) => {
+ const handleEditComment = (commentId, newText) => {
+ fetch(`/api/comments/${commentId}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ text: newText }),
+ })
+ .then(() => {
+ loadComments(id, setComments);
+ })
+ .catch((err) => {
+ console.error("Failed to edit comment", err);
+ });
+ };
+
+ const handleSubmitComment = (parentId, text) => {
const endpoint = parentId
? `/api/comments/reply/${parentId}`
- : `/api/comments/${solutionData.id}`;
+ : `/api/comments/`;
fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -110,73 +127,114 @@ const Solution = () => {
});
};
+ const handleCloseDeleteModal = () => {
+ setShowDeleteModal(false);
+ };
+ const handleShowDeleteModal = () => {
+ setShowDeleteModal(true);
+ };
+
+ if (isLoading) {
+ return Loading...
;
+ }
+ if (forbidden) {
+ return Access denied
;
+ }
+
return (
-
- Solution
+
{solutionData && (
-
-
-
- By: {solutionData.userId}
-
- {dayjs(solutionData.createdAt).format("MMMM D, YYYY")}
-
-
-
-
- {solutionData.title}
- {solutionData.description}
-
- {solutionData.diagram && (
-
- )}
-
- {isAdmin || solutionData.userId === currentUserId ? (
-
-
- Delete Solution
-
-
- ) : null}
-
+ <>
+
+ {solutionData.challengeTitle} - Solution #{solutionData.id}
+
+
+
+
+
+ {solutionData.userId}
+
+ {dayjs(solutionData.createdAt).format("MMMM D, YYYY")}
+
+
+ {(isAdmin || solutionData.userId === currentUserId) && (
+
+
+
+
+
+
+ Delete
+
+
+
+ )}
+
+
+
+ {solutionData.title}
+ {solutionData.description}
+ {solutionData.diagram && (
+
+ )}
+
+
+ >
)}
-
- Comments
-
- {comments &&
+
+ Student's Answers
+ {Array.isArray(comments) &&
comments.map((comment) => (
-
-
- {isAdmin && (
- {
- handleDelete(comment.id);
- }}
- >
- Delete
-
- )}
-
+ {
+ handleSubmitComment(parentId, text);
+ }}
+ onDelete={handleDeleteComment}
+ onEdit={handleEditComment}
+ depth={0}
+ currentUserId={currentUserId}
+ isAdmin={isAdmin}
+ />
))}
+ {
+ handleSubmitComment(parentId, text);
+ }}
+ />
+
+
+
+ Confirm Deletion
+
+ Are you sure you want to delete this solution?
+
+
+ Cancel
+
+
+ Delete
+
+
+
);
};
-export default Solution;
\ No newline at end of file
+export default Solution;
diff --git a/client/src/types/CommentData.ts b/client/src/types/CommentData.ts
index 1bc2dac..7770ed0 100644
--- a/client/src/types/CommentData.ts
+++ b/client/src/types/CommentData.ts
@@ -1,7 +1,7 @@
export type CommentData = {
id: number;
solutionId: number;
- userId: number;
+ userId: string;
text: string;
upVotes: number;
hasUserUpvoted: boolean;
diff --git a/server/controllers/CommentController.js b/server/controllers/CommentController.js
index 01b115f..4fea05f 100644
--- a/server/controllers/CommentController.js
+++ b/server/controllers/CommentController.js
@@ -4,6 +4,7 @@ const Challenge = db.Challenge;
const Solution = db.Solution;
const User = db.User;
const { AITA } = require("../AI/AITA");
+const { Op } = require("sequelize");
function formatComments(extracted) {
const convertedReplies = extracted.map((c) => ({
@@ -118,7 +119,50 @@ exports.edit = async (req, res) => {
exports.delete = async (req, res) => {
const { id } = req.params;
- await Comment.destroy({ where: { id } });
+ const { user } = req;
+
+ if (!id || !user) {
+ res.status(400).send();
+ return;
+ }
+
+ const comment = await db.Comment.findOne({
+ where: { id },
+ });
+
+ if (!comment || !user) {
+ res.status(402).send();
+ return;
+ }
+
+ const parents = await db.Comment.findAll({
+ where: {
+ replies: {
+ [Op.substring]: `${comment.id}`,
+ },
+ },
+ });
+
+ if (user.role === "admin" || user.username === comment.userId) {
+ await Comment.destroy({ where: { id } });
+ await Promise.all(
+ parents.map((c) => {
+ return Comment.update(
+ {
+ replies: c.replies
+ .split(",")
+ .filter((x) => x !== `${id}`)
+ .join(","),
+ },
+ { where: { id: c.id } },
+ );
+ }),
+ );
+ } else {
+ res.status(403).send();
+ return;
+ }
+
res.status(204).send();
};
diff --git a/server/routes/CommentRoutes.js b/server/routes/CommentRoutes.js
index d9a47cd..33c41e7 100644
--- a/server/routes/CommentRoutes.js
+++ b/server/routes/CommentRoutes.js
@@ -15,19 +15,18 @@ router.get("/user/:username", Comment.getUserComments);
router.get("/:solutionId", Comment.get);
// Create a new comment for a solution in the database.
-router.post("/:id", Comment.create);
+router.post("/", Comment.create);
// Edit a comment for a solution in the database.
-router.put("/:id", Comment.edit);
+router.put("/:id", Comment.edit);
// Delete a comment for a solution from the database.
-router.delete("/:id", checkRole(['admin']), Comment.delete);
-
+router.delete("/:id", Comment.delete);
// Reply to a comment
router.post("/reply/:parentId", Comment.reply);
// Upvote a comment
-router.get("/upvote/:commentId", Comment.upvote);
+router.post("/upvote/:commentId", Comment.upvote);
module.exports = router;