Skip to content
Merged
43 changes: 42 additions & 1 deletion my-app/firebase.js
Original file line number Diff line number Diff line change
Expand Up @@ -497,4 +497,45 @@ export async function getCommentsForReview(courseCode, reviewUserId) {
});
});
return comments;
}
}
/**
* 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 });
}
}
};
29 changes: 13 additions & 16 deletions my-app/firebase_rules.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,48 +3,45 @@
// 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()"
}
}
}
}
},

// User-specific Data
// Users
"users": {
"$userID": {
".read": "auth != null && auth.uid === $userID",
Expand Down
1 change: 1 addition & 0 deletions my-app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@
<script type="module" src="/src/index.jsx"></script>
</body>
</html>

9 changes: 0 additions & 9 deletions my-app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 20 additions & 9 deletions my-app/src/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions my-app/src/presenters/ReviewPresenter.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
41 changes: 31 additions & 10 deletions my-app/src/views/Components/CommentTree.jsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 (
Expand All @@ -38,12 +48,24 @@ function CommentTree({ courseCode, comment, level = 0 }) {

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

<button
className="text-blue-500 text-sm hover:underline"
onClick={() => setShowReply(!showReply)}
>
{showReply ? "Cancel" : "Reply"}
</button>
<div className="flex gap-3 items-center">
<button
className="text-blue-500 text-sm hover:underline"
onClick={() => setShowReply(!showReply)}
>
{showReply ? "Cancel" : "Reply"}
</button>

{/* Show delete button only if current user is the comment author */}
{currentUserId && comment.userId === currentUserId && (
<button
className="text-red-500 text-sm hover:underline"
onClick={handleDeleteComment}
>
Delete
</button>
)}
</div>

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

{/* Recursive rendering of replies */}
{comment.replies && comment.replies.length > 0 && (
<div className="mt-2 space-y-2">
{comment.replies.map((child) => (
Expand Down
16 changes: 16 additions & 0 deletions my-app/src/views/Components/RatingDisplay.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center gap-2 mt-1">
<RatingComponent value={value} readOnly className="scale-90" />
<span className="text-gray-600 text-sm">{value.toFixed(1)} / 5</span>
</div>
);
};

export default RatingDisplay;
44 changes: 24 additions & 20 deletions my-app/src/views/Components/StarComponent.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={`relative group leading-none ${readOnly ? 'cursor-default' : 'cursor-pointer'}`}
onMouseEnter={() => !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 */}
<i className="bx bx-star absolute top-0 left-0 text-xl text-violet-500" />

{/* Foreground filled portion */}
<i
className={`bx ${starClass} text-xl text-violet-500 transition-transform duration-200 ${!readOnly && 'group-hover:scale-110'}`}
></i>
{!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 && (
<>
<button
className="absolute top-0 right-1/2 w-1/2 h-full cursor-pointer"
onClick={handleLeftClick}
onClick={() => onRatingChange(index, true)}
/>
<button
className="absolute top-0 left-1/2 w-1/2 h-full cursor-pointer"
onClick={handleRightClick}
onClick={() => onRatingChange(index, false)}
/>
</>
)}
Expand Down
32 changes: 31 additions & 1 deletion my-app/src/views/ListView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import React, { useState, useEffect, useCallback } from 'react';
import { DotPulse, Quantum } from 'ldrs/react';
import 'ldrs/react/Quantum.css';
import InfiniteScroll from 'react-infinite-scroll-component';
import RatingComponent from "./Components/RatingComponent";
import { model } from "../model.js";



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

useEffect(() => {
setIsLoading(true);
const initialCourses = props.sortedCourses.slice(0, 10);
const initialCourses = props.sortedCourses.slice(0, 10).map(course => ({
...course,
avgRating: model.avgRatings[course.code]?.[0],
}));
setDisplayedCourses(initialCourses);
setHasMore(props.sortedCourses.length > 10);
setIsLoading(false);
}, [props.sortedCourses]);


const fetchMoreCourses = useCallback(() => {
if (!hasMore) return;
Expand Down Expand Up @@ -194,6 +202,15 @@ function ListView(props) {
__html: highlightText(course.name, props.query)
}}
/>
{course.avgRating !== undefined && (
<div className="flex items-center gap-1 mt-1">
<RatingComponent value={course.avgRating} readOnly />
<span className="text-sm text-gray-500">
({course.avgRating.toFixed(1)} / 5)
</span>
</div>
)}

<p
className="text-gray-600"
dangerouslySetInnerHTML={{
Expand All @@ -202,6 +219,19 @@ function ListView(props) {
: highlightText(course?.description?.slice(0, 200) + "...", props.query)
}}
/>
{/* Rating stars and number */}
{props.model?.avgRating?.[course.code] !== undefined && (
<div className="mt-2 flex items-center gap-2">
<RatingComponent
value={props.model.avgRating[course.code]}
readOnly
/>
<span className="text-sm text-gray-500">
({props.model.avgRating[course.code].toFixed(1)} / 5)
</span>
</div>
)}

{course?.description?.length > 150 && (

<span
Expand Down
Loading