diff --git a/my-app/firebase.js b/my-app/firebase.js index 012d98d..cad0f99 100644 --- a/my-app/firebase.js +++ b/my-app/firebase.js @@ -497,4 +497,45 @@ export async function getCommentsForReview(courseCode, reviewUserId) { }); }); return comments; -} \ No newline at end of file +} +/** + * Delete a review for a course (by userId). + * @param {string} courseCode + * @param {string} userId + */ +export async function deleteReview(courseCode, userId) { + const reviewRef = ref(db, `reviews/${courseCode}/${userId}`); + await set(reviewRef, null); +} + +/** + * Delete a specific comment from a review. + * @param {string} courseCode + * @param {string} reviewUserId - UID of the review's author + * @param {string} commentId - ID of the comment (Firebase push key) + */ +export async function deleteComment(courseCode, reviewUserId, commentId) { + const commentRef = ref(db, `reviews/${courseCode}/${reviewUserId}/comments/${commentId}`); + await set(commentRef, null); +} +// Delete a review or comment by its ID +export const deleteReviewById = async (courseCode, commentId, parentId = null) => { + const db = getDatabase(); + + if (!parentId) { + // Top-level review + const reviewRef = ref(db, `reviews/${courseCode}/${commentId}`); + await remove(reviewRef); + } else { + // Nested reply - remove it from the parent's replies array + const parentRef = ref(db, `reviews/${courseCode}/${parentId}`); + const snapshot = await get(parentRef); + if (snapshot.exists()) { + const parentData = snapshot.val(); + const replies = parentData.replies || []; + + const updatedReplies = replies.filter((r) => r.id !== commentId); + await update(parentRef, { replies: updatedReplies }); + } + } +}; \ No newline at end of file diff --git a/my-app/firebase_rules.json b/my-app/firebase_rules.json index 1550d1c..10fc332 100644 --- a/my-app/firebase_rules.json +++ b/my-app/firebase_rules.json @@ -3,40 +3,37 @@ // Courses and Metadata "courses": { ".read": true, - ".write": "auth != null && (auth.uid === '6qKa992eL4fRkGKzp3OG5Sjjk983' || auth.uid === 'wa9HoCfWe2Vpw6J7oiq5oCxNYz52')" + ".write": "auth != null && auth.uid === 'adminuid'" }, "metadata": { ".read": true, - ".write": "auth != null && (auth.uid === '6qKa992eL4fRkGKzp3OG5Sjjk983' || auth.uid === 'wa9HoCfWe2Vpw6J7oiq5oCxNYz52')" + ".write": "auth != null && auth.uid === 'adminuid'" }, "departments": { ".read": true, - ".write": "auth != null && (auth.uid === '6qKa992eL4fRkGKzp3OG5Sjjk983' || auth.uid === 'wa9HoCfWe2Vpw6J7oiq5oCxNYz52')" + ".write": "auth != null && auth.uid === 'adminuid'" }, "locations": { ".read": true, - ".write": "auth != null && (auth.uid === '6qKa992eL4fRkGKzp3OG5Sjjk983' || auth.uid === 'wa9HoCfWe2Vpw6J7oiq5oCxNYz52')" + ".write": "auth != null && auth.uid === 'adminuid'" }, // Reviews and Comments "reviews": { ".read": true, "$courseCode": { - "$reviewUserID": { - // Only the original author can write the main review - ".write": "auth != null && (auth.uid === $reviewUserID || data.child('uid').val() === auth.uid || !data.exists())", - ".validate": "newData.hasChildren(['text', 'timestamp']) && - newData.child('text').isString() && - newData.child('text').val().length <= 2501 && - newData.child('timestamp').isNumber()", + "$userID": { + // Only the review owner can write the main review fields (not including comments) + ".write": "auth != null && (auth.uid === $userID)", - // Allow any signed-in user to write comments under the review + // Allow anyone to write a comment "comments": { - ".write": "auth != null", + ".read": true, "$commentId": { - ".validate": "newData.hasChildren(['text', 'userName', 'timestamp']) && - newData.child('text').isString() && + ".write": "auth != null", + ".validate": "newData.hasChildren(['userName', 'text', 'timestamp']) && newData.child('userName').isString() && + newData.child('text').isString() && newData.child('timestamp').isNumber()" } } @@ -44,7 +41,7 @@ } }, - // User-specific Data + // Users "users": { "$userID": { ".read": "auth != null && auth.uid === $userID", diff --git a/my-app/index.html b/my-app/index.html index 9b3d900..dfa7e92 100644 --- a/my-app/index.html +++ b/my-app/index.html @@ -29,3 +29,4 @@ + diff --git a/my-app/package-lock.json b/my-app/package-lock.json index 360afab..2a49d1f 100644 --- a/my-app/package-lock.json +++ b/my-app/package-lock.json @@ -3869,15 +3869,6 @@ "node": ">=6" } }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001714", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001714.tgz", diff --git a/my-app/src/model.js b/my-app/src/model.js index 1dab325..ea361d0 100644 --- a/my-app/src/model.js +++ b/my-app/src/model.js @@ -188,15 +188,26 @@ export const model = { } }, - async getReviews(courseCode) { - try { - return await getReviewsForCourse(courseCode); - } catch (error) { - console.error("Error fetching reviews:", error); - return []; - } - }, - //for filters + async getReviews(courseCode) { + try { + const rawReviews = await getReviewsForCourse(courseCode); + if (!Array.isArray(rawReviews)) return []; + + const enriched = rawReviews.map((review) => { + return { + ...review, + uid: review.uid || review.id || "", + courseCode: courseCode || "", + }; + }); + + return enriched; + } catch (error) { + console.error("Error fetching reviews:", error); + return []; + } + }, + //for filters setFiltersChange() { this.filtersChange = true; diff --git a/my-app/src/presenters/ReviewPresenter.jsx b/my-app/src/presenters/ReviewPresenter.jsx index 7f3adcf..52238e1 100644 --- a/my-app/src/presenters/ReviewPresenter.jsx +++ b/my-app/src/presenters/ReviewPresenter.jsx @@ -60,6 +60,7 @@ export const ReviewPresenter = observer(({ model, course }) => { const review = { userName: anon ? "Anonymous" : model.user?.displayName, uid: model?.user?.uid, + userId: model?.user?.uid, timestamp: Date.now(), ...formData, }; diff --git a/my-app/src/views/Components/CommentTree.jsx b/my-app/src/views/Components/CommentTree.jsx index af4fcd0..b33bc7a 100644 --- a/my-app/src/views/Components/CommentTree.jsx +++ b/my-app/src/views/Components/CommentTree.jsx @@ -1,17 +1,20 @@ import React, { useState } from "react"; import RatingComponent from "./RatingComponent.jsx"; -import { model } from "../../model.js"; // Adjust the path if needed -import { addReviewForCourse } from "../../firebase"; // Adjust the path if needed +import { model } from "../../model.js"; +import { addReviewForCourse, deleteReviewById } from "../../firebase"; // we will add deleteReviewById function CommentTree({ courseCode, comment, level = 0 }) { const [showReply, setShowReply] = useState(false); const [replyText, setReplyText] = useState(""); + const currentUserId = model.user?.uid; + const handleReplySubmit = async () => { if (replyText.trim().length === 0) return; const reply = { userName: model.user?.displayName || "Anonymous", + userId: model.user?.uid || "anonymous", text: replyText, timestamp: Date.now(), overallRating: 0, @@ -23,7 +26,14 @@ function CommentTree({ courseCode, comment, level = 0 }) { }; await addReviewForCourse(courseCode, reply, comment.id); - window.location.reload(); // quick reload for now; optional optimization later + window.location.reload(); // quick reload for now + }; + + const handleDeleteComment = async () => { + if (!window.confirm("Are you sure you want to delete this comment?")) return; + + await deleteReviewById(courseCode, comment.id, comment.parentId || null); + window.location.reload(); // quick reload }; return ( @@ -38,12 +48,24 @@ function CommentTree({ courseCode, comment, level = 0 }) {

{comment.text}

- +
+ + + {/* Show delete button only if current user is the comment author */} + {currentUserId && comment.userId === currentUserId && ( + + )} +
{showReply && (
@@ -63,7 +85,6 @@ function CommentTree({ courseCode, comment, level = 0 }) { )}
- {/* Recursive rendering of replies */} {comment.replies && comment.replies.length > 0 && (
{comment.replies.map((child) => ( diff --git a/my-app/src/views/Components/RatingDisplay.jsx b/my-app/src/views/Components/RatingDisplay.jsx new file mode 100644 index 0000000..f871758 --- /dev/null +++ b/my-app/src/views/Components/RatingDisplay.jsx @@ -0,0 +1,16 @@ +import React from "react"; +import RatingComponent from "./RatingComponent"; + +/** + * A small read-only star rating display for course listings. + */ +const RatingDisplay = ({ value = 0 }) => { + return ( +
+ + {value.toFixed(1)} / 5 +
+ ); +}; + +export default RatingDisplay; diff --git a/my-app/src/views/Components/StarComponent.jsx b/my-app/src/views/Components/StarComponent.jsx index 33fb0ab..fefce62 100644 --- a/my-app/src/views/Components/StarComponent.jsx +++ b/my-app/src/views/Components/StarComponent.jsx @@ -1,39 +1,43 @@ import React from 'react'; /** - * Allows to rate things from 0 to 5 stars. + * Displays a star rating from 0 to 5 with partial star fill support. + * Works in read-only mode for displaying average rating (e.g., 3.6). */ const StarComponent = ({ index, rating, onRatingChange, onHover, readOnly = false }) => { - const handleLeftClick = () => { - if (!readOnly) onRatingChange(index, true); - }; - - const handleRightClick = () => { - if (!readOnly) onRatingChange(index, false); - }; - - const isFullStar = rating >= index + 1; - const isHalfStar = rating >= index + 0.5 && rating < index + 1; - const starClass = isFullStar ? "bxs-star" : isHalfStar ? "bxs-star-half" : "bx-star"; + const isInteractive = !readOnly && onRatingChange; + const fillPercentage = Math.max(0, Math.min(1, rating - index)) * 100; return (
!readOnly && onHover && onHover(index + 1)} - onMouseLeave={() => !readOnly && onHover && onHover(0)} + className={`relative inline-block w-5 h-5 ${readOnly ? 'cursor-default' : 'cursor-pointer'}`} + onMouseEnter={() => isInteractive && onHover?.(index + 1)} + onMouseLeave={() => isInteractive && onHover?.(0)} > + {/* Background empty star */} + + + {/* Foreground filled portion */} - {!readOnly && ( + className="bx bxs-star absolute top-0 left-0 text-xl text-violet-500" + style={{ + width: `${fillPercentage}%`, + overflow: 'hidden', + display: 'inline-block', + whiteSpace: 'nowrap' + }} + /> + + {/* Interaction buttons if not readOnly */} + {isInteractive && ( <>