Skip to content

Commit e82e5f9

Browse files
authored
Comment tree (#155)
* Basic comment function to each reviews * anonymous function to review and comments return * 3 comments restriction to each review, and delete button to review, implementing delete button to comment * the delete button to comments works * Add refined course rating display and comment deletion * merging to main
1 parent 0e066be commit e82e5f9

File tree

11 files changed

+426
-236
lines changed

11 files changed

+426
-236
lines changed

my-app/firebase.js

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -497,4 +497,45 @@ export async function getCommentsForReview(courseCode, reviewUserId) {
497497
});
498498
});
499499
return comments;
500-
}
500+
}
501+
/**
502+
* Delete a review for a course (by userId).
503+
* @param {string} courseCode
504+
* @param {string} userId
505+
*/
506+
export async function deleteReview(courseCode, userId) {
507+
const reviewRef = ref(db, `reviews/${courseCode}/${userId}`);
508+
await set(reviewRef, null);
509+
}
510+
511+
/**
512+
* Delete a specific comment from a review.
513+
* @param {string} courseCode
514+
* @param {string} reviewUserId - UID of the review's author
515+
* @param {string} commentId - ID of the comment (Firebase push key)
516+
*/
517+
export async function deleteComment(courseCode, reviewUserId, commentId) {
518+
const commentRef = ref(db, `reviews/${courseCode}/${reviewUserId}/comments/${commentId}`);
519+
await set(commentRef, null);
520+
}
521+
// Delete a review or comment by its ID
522+
export const deleteReviewById = async (courseCode, commentId, parentId = null) => {
523+
const db = getDatabase();
524+
525+
if (!parentId) {
526+
// Top-level review
527+
const reviewRef = ref(db, `reviews/${courseCode}/${commentId}`);
528+
await remove(reviewRef);
529+
} else {
530+
// Nested reply - remove it from the parent's replies array
531+
const parentRef = ref(db, `reviews/${courseCode}/${parentId}`);
532+
const snapshot = await get(parentRef);
533+
if (snapshot.exists()) {
534+
const parentData = snapshot.val();
535+
const replies = parentData.replies || [];
536+
537+
const updatedReplies = replies.filter((r) => r.id !== commentId);
538+
await update(parentRef, { replies: updatedReplies });
539+
}
540+
}
541+
};

my-app/firebase_rules.json

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,48 +3,45 @@
33
// Courses and Metadata
44
"courses": {
55
".read": true,
6-
".write": "auth != null && (auth.uid === '6qKa992eL4fRkGKzp3OG5Sjjk983' || auth.uid === 'wa9HoCfWe2Vpw6J7oiq5oCxNYz52')"
6+
".write": "auth != null && auth.uid === 'adminuid'"
77
},
88
"metadata": {
99
".read": true,
10-
".write": "auth != null && (auth.uid === '6qKa992eL4fRkGKzp3OG5Sjjk983' || auth.uid === 'wa9HoCfWe2Vpw6J7oiq5oCxNYz52')"
10+
".write": "auth != null && auth.uid === 'adminuid'"
1111
},
1212
"departments": {
1313
".read": true,
14-
".write": "auth != null && (auth.uid === '6qKa992eL4fRkGKzp3OG5Sjjk983' || auth.uid === 'wa9HoCfWe2Vpw6J7oiq5oCxNYz52')"
14+
".write": "auth != null && auth.uid === 'adminuid'"
1515
},
1616
"locations": {
1717
".read": true,
18-
".write": "auth != null && (auth.uid === '6qKa992eL4fRkGKzp3OG5Sjjk983' || auth.uid === 'wa9HoCfWe2Vpw6J7oiq5oCxNYz52')"
18+
".write": "auth != null && auth.uid === 'adminuid'"
1919
},
2020

2121
// Reviews and Comments
2222
"reviews": {
2323
".read": true,
2424
"$courseCode": {
25-
"$reviewUserID": {
26-
// Only the original author can write the main review
27-
".write": "auth != null && (auth.uid === $reviewUserID || data.child('uid').val() === auth.uid || !data.exists())",
28-
".validate": "newData.hasChildren(['text', 'timestamp']) &&
29-
newData.child('text').isString() &&
30-
newData.child('text').val().length <= 2501 &&
31-
newData.child('timestamp').isNumber()",
25+
"$userID": {
26+
// Only the review owner can write the main review fields (not including comments)
27+
".write": "auth != null && (auth.uid === $userID)",
3228

33-
// Allow any signed-in user to write comments under the review
29+
// Allow anyone to write a comment
3430
"comments": {
35-
".write": "auth != null",
31+
".read": true,
3632
"$commentId": {
37-
".validate": "newData.hasChildren(['text', 'userName', 'timestamp']) &&
38-
newData.child('text').isString() &&
33+
".write": "auth != null",
34+
".validate": "newData.hasChildren(['userName', 'text', 'timestamp']) &&
3935
newData.child('userName').isString() &&
36+
newData.child('text').isString() &&
4037
newData.child('timestamp').isNumber()"
4138
}
4239
}
4340
}
4441
}
4542
},
4643

47-
// User-specific Data
44+
// Users
4845
"users": {
4946
"$userID": {
5047
".read": "auth != null && auth.uid === $userID",

my-app/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,4 @@
2929
<script type="module" src="/src/index.jsx"></script>
3030
</body>
3131
</html>
32+

my-app/package-lock.json

Lines changed: 0 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

my-app/src/model.js

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -188,15 +188,26 @@ export const model = {
188188
}
189189
},
190190

191-
async getReviews(courseCode) {
192-
try {
193-
return await getReviewsForCourse(courseCode);
194-
} catch (error) {
195-
console.error("Error fetching reviews:", error);
196-
return [];
197-
}
198-
},
199-
//for filters
191+
async getReviews(courseCode) {
192+
try {
193+
const rawReviews = await getReviewsForCourse(courseCode);
194+
if (!Array.isArray(rawReviews)) return [];
195+
196+
const enriched = rawReviews.map((review) => {
197+
return {
198+
...review,
199+
uid: review.uid || review.id || "",
200+
courseCode: courseCode || "",
201+
};
202+
});
203+
204+
return enriched;
205+
} catch (error) {
206+
console.error("Error fetching reviews:", error);
207+
return [];
208+
}
209+
},
210+
//for filters
200211

201212
setFiltersChange() {
202213
this.filtersChange = true;

my-app/src/presenters/ReviewPresenter.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export const ReviewPresenter = observer(({ model, course }) => {
6060
const review = {
6161
userName: anon ? "Anonymous" : model.user?.displayName,
6262
uid: model?.user?.uid,
63+
userId: model?.user?.uid,
6364
timestamp: Date.now(),
6465
...formData,
6566
};

my-app/src/views/Components/CommentTree.jsx

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
import React, { useState } from "react";
22
import RatingComponent from "./RatingComponent.jsx";
3-
import { model } from "../../model.js"; // Adjust the path if needed
4-
import { addReviewForCourse } from "../../firebase"; // Adjust the path if needed
3+
import { model } from "../../model.js";
4+
import { addReviewForCourse, deleteReviewById } from "../../firebase"; // we will add deleteReviewById
55

66
function CommentTree({ courseCode, comment, level = 0 }) {
77
const [showReply, setShowReply] = useState(false);
88
const [replyText, setReplyText] = useState("");
99

10+
const currentUserId = model.user?.uid;
11+
1012
const handleReplySubmit = async () => {
1113
if (replyText.trim().length === 0) return;
1214

1315
const reply = {
1416
userName: model.user?.displayName || "Anonymous",
17+
userId: model.user?.uid || "anonymous",
1518
text: replyText,
1619
timestamp: Date.now(),
1720
overallRating: 0,
@@ -23,7 +26,14 @@ function CommentTree({ courseCode, comment, level = 0 }) {
2326
};
2427

2528
await addReviewForCourse(courseCode, reply, comment.id);
26-
window.location.reload(); // quick reload for now; optional optimization later
29+
window.location.reload(); // quick reload for now
30+
};
31+
32+
const handleDeleteComment = async () => {
33+
if (!window.confirm("Are you sure you want to delete this comment?")) return;
34+
35+
await deleteReviewById(courseCode, comment.id, comment.parentId || null);
36+
window.location.reload(); // quick reload
2737
};
2838

2939
return (
@@ -38,12 +48,24 @@ function CommentTree({ courseCode, comment, level = 0 }) {
3848

3949
<p className="text-sm text-gray-700 mb-1">{comment.text}</p>
4050

41-
<button
42-
className="text-blue-500 text-sm hover:underline"
43-
onClick={() => setShowReply(!showReply)}
44-
>
45-
{showReply ? "Cancel" : "Reply"}
46-
</button>
51+
<div className="flex gap-3 items-center">
52+
<button
53+
className="text-blue-500 text-sm hover:underline"
54+
onClick={() => setShowReply(!showReply)}
55+
>
56+
{showReply ? "Cancel" : "Reply"}
57+
</button>
58+
59+
{/* Show delete button only if current user is the comment author */}
60+
{currentUserId && comment.userId === currentUserId && (
61+
<button
62+
className="text-red-500 text-sm hover:underline"
63+
onClick={handleDeleteComment}
64+
>
65+
Delete
66+
</button>
67+
)}
68+
</div>
4769

4870
{showReply && (
4971
<div className="mt-2">
@@ -63,7 +85,6 @@ function CommentTree({ courseCode, comment, level = 0 }) {
6385
)}
6486
</div>
6587

66-
{/* Recursive rendering of replies */}
6788
{comment.replies && comment.replies.length > 0 && (
6889
<div className="mt-2 space-y-2">
6990
{comment.replies.map((child) => (
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import React from "react";
2+
import RatingComponent from "./RatingComponent";
3+
4+
/**
5+
* A small read-only star rating display for course listings.
6+
*/
7+
const RatingDisplay = ({ value = 0 }) => {
8+
return (
9+
<div className="flex items-center gap-2 mt-1">
10+
<RatingComponent value={value} readOnly className="scale-90" />
11+
<span className="text-gray-600 text-sm">{value.toFixed(1)} / 5</span>
12+
</div>
13+
);
14+
};
15+
16+
export default RatingDisplay;

my-app/src/views/Components/StarComponent.jsx

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,43 @@
11
import React from 'react';
22

33
/**
4-
* Allows to rate things from 0 to 5 stars.
4+
* Displays a star rating from 0 to 5 with partial star fill support.
5+
* Works in read-only mode for displaying average rating (e.g., 3.6).
56
*/
67
const StarComponent = ({ index, rating, onRatingChange, onHover, readOnly = false }) => {
7-
const handleLeftClick = () => {
8-
if (!readOnly) onRatingChange(index, true);
9-
};
10-
11-
const handleRightClick = () => {
12-
if (!readOnly) onRatingChange(index, false);
13-
};
14-
15-
const isFullStar = rating >= index + 1;
16-
const isHalfStar = rating >= index + 0.5 && rating < index + 1;
17-
const starClass = isFullStar ? "bxs-star" : isHalfStar ? "bxs-star-half" : "bx-star";
8+
const isInteractive = !readOnly && onRatingChange;
9+
const fillPercentage = Math.max(0, Math.min(1, rating - index)) * 100;
1810

1911
return (
2012
<div
21-
className={`relative group leading-none ${readOnly ? 'cursor-default' : 'cursor-pointer'}`}
22-
onMouseEnter={() => !readOnly && onHover && onHover(index + 1)}
23-
onMouseLeave={() => !readOnly && onHover && onHover(0)}
13+
className={`relative inline-block w-5 h-5 ${readOnly ? 'cursor-default' : 'cursor-pointer'}`}
14+
onMouseEnter={() => isInteractive && onHover?.(index + 1)}
15+
onMouseLeave={() => isInteractive && onHover?.(0)}
2416
>
17+
{/* Background empty star */}
18+
<i className="bx bx-star absolute top-0 left-0 text-xl text-violet-500" />
19+
20+
{/* Foreground filled portion */}
2521
<i
26-
className={`bx ${starClass} text-xl text-violet-500 transition-transform duration-200 ${!readOnly && 'group-hover:scale-110'}`}
27-
></i>
28-
{!readOnly && (
22+
className="bx bxs-star absolute top-0 left-0 text-xl text-violet-500"
23+
style={{
24+
width: `${fillPercentage}%`,
25+
overflow: 'hidden',
26+
display: 'inline-block',
27+
whiteSpace: 'nowrap'
28+
}}
29+
/>
30+
31+
{/* Interaction buttons if not readOnly */}
32+
{isInteractive && (
2933
<>
3034
<button
3135
className="absolute top-0 right-1/2 w-1/2 h-full cursor-pointer"
32-
onClick={handleLeftClick}
36+
onClick={() => onRatingChange(index, true)}
3337
/>
3438
<button
3539
className="absolute top-0 left-1/2 w-1/2 h-full cursor-pointer"
36-
onClick={handleRightClick}
40+
onClick={() => onRatingChange(index, false)}
3741
/>
3842
</>
3943
)}

my-app/src/views/ListView.jsx

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import React, { useState, useEffect, useCallback } from 'react';
22
import { DotPulse, Quantum } from 'ldrs/react';
33
import 'ldrs/react/Quantum.css';
44
import InfiniteScroll from 'react-infinite-scroll-component';
5+
import RatingComponent from "./Components/RatingComponent";
6+
import { model } from "../model.js";
7+
8+
59

610
const highlightText = (text, query) => {
711
if (!query || !text) return text;
@@ -57,11 +61,15 @@ function ListView(props) {
5761

5862
useEffect(() => {
5963
setIsLoading(true);
60-
const initialCourses = props.sortedCourses.slice(0, 10);
64+
const initialCourses = props.sortedCourses.slice(0, 10).map(course => ({
65+
...course,
66+
avgRating: model.avgRatings[course.code]?.[0],
67+
}));
6168
setDisplayedCourses(initialCourses);
6269
setHasMore(props.sortedCourses.length > 10);
6370
setIsLoading(false);
6471
}, [props.sortedCourses]);
72+
6573

6674
const fetchMoreCourses = useCallback(() => {
6775
if (!hasMore) return;
@@ -194,6 +202,15 @@ function ListView(props) {
194202
__html: highlightText(course.name, props.query)
195203
}}
196204
/>
205+
{course.avgRating !== undefined && (
206+
<div className="flex items-center gap-1 mt-1">
207+
<RatingComponent value={course.avgRating} readOnly />
208+
<span className="text-sm text-gray-500">
209+
({course.avgRating.toFixed(1)} / 5)
210+
</span>
211+
</div>
212+
)}
213+
197214
<p
198215
className="text-gray-600"
199216
dangerouslySetInnerHTML={{
@@ -202,6 +219,19 @@ function ListView(props) {
202219
: highlightText(course?.description?.slice(0, 200) + "...", props.query)
203220
}}
204221
/>
222+
{/* Rating stars and number */}
223+
{props.model?.avgRating?.[course.code] !== undefined && (
224+
<div className="mt-2 flex items-center gap-2">
225+
<RatingComponent
226+
value={props.model.avgRating[course.code]}
227+
readOnly
228+
/>
229+
<span className="text-sm text-gray-500">
230+
({props.model.avgRating[course.code].toFixed(1)} / 5)
231+
</span>
232+
</div>
233+
)}
234+
205235
{course?.description?.length > 150 && (
206236

207237
<span

0 commit comments

Comments
 (0)