diff --git a/apps/frontend/src/components/Class/Overview/index.tsx b/apps/frontend/src/components/Class/Overview/index.tsx index c731dc8ed..0ba3c3041 100644 --- a/apps/frontend/src/components/Class/Overview/index.tsx +++ b/apps/frontend/src/components/Class/Overview/index.tsx @@ -1,24 +1,21 @@ import Details from "@/components/Details"; import useClass from "@/hooks/useClass"; - import styles from "./Overview.module.scss"; +import AttendanceRequirements from "@/components/Detail"; export default function Overview() { - const { class: _class } = useClass(); - - return ( -
-
-

Description

-

- {_class.description ?? _class.course.description} -

- {_class.course.requirements && ( - <> -

Prerequisites

-

{_class.course.requirements}

- - )} -
- ); -} + const { class: _class } = useClass(); + return ( +
+
+

Description

+

+ {_class.description ?? _class.course.description} +

+ +
+ ); +} \ No newline at end of file diff --git a/apps/frontend/src/components/Class/Ratings/Ratings.module.scss b/apps/frontend/src/components/Class/Ratings/Ratings.module.scss index 274516fff..b0e936a9b 100644 --- a/apps/frontend/src/components/Class/Ratings/Ratings.module.scss +++ b/apps/frontend/src/components/Class/Ratings/Ratings.module.scss @@ -8,7 +8,6 @@ border-radius: 8px; box-shadow: 0 1px 2px rgb(0 0 0 / 5%); border: 1px solid var(--border-color); - } .ratingSection { @@ -115,6 +114,7 @@ .ratingContent { margin-top: 16px; margin-left: 25%; + animation: slideDown 300ms ease forwards; } .statRow { @@ -122,6 +122,9 @@ align-items: center; gap: 12px; margin-bottom: 8px; + opacity: 0; + animation: fadeIn 500ms ease forwards; + animation-delay: var(--delay); &:last-child { margin-bottom: 0; @@ -132,6 +135,9 @@ color: var(--heading-color); font-weight: 500; text-align: center; + opacity: 0; + animation: fadeIn 300ms ease forwards; + animation-delay: calc(var(--delay) + 100ms); } .barContainer { @@ -145,7 +151,8 @@ height: 100%; background-color: var(--blue-500); border-radius: 4px; - transition: width 0.3s ease; + transition: width 1000ms cubic-bezier(0.4, 0, 0.2, 1); + width: 0; } } @@ -154,6 +161,10 @@ color: var(--paragraph-color); font-size: 14px; text-align: right; + opacity: 0; + animation: fadeIn 300ms ease forwards; + animation-delay: calc(var(--delay) + 200ms); + transition: all 1000ms cubic-bezier(0.4, 0, 0.2, 1); } } @@ -161,202 +172,34 @@ margin-bottom: 16px; } -.overlay { - background-color: rgb(0 0 0 / 50%); - position: fixed; - inset: 0; - animation: overlayShow 150ms cubic-bezier(0.16, 1, 0.3, 1); - z-index: 50; /* Ensure overlay is above other content */ -} - - -.modal { - background-color: var(--foreground-color); - border-radius: 8px; - box-shadow: 0 4px 32px rgb(0 0 0 / 25%); - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 90vw; - max-width: 600px; - max-height: 85vh; - padding: 24px; - animation: contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1); - overflow-y: auto; - z-index: 51; /* Ensure modal is above the overlay */ -} - -.modalHeader { - margin-bottom: 24px; - text-align: left; -} - -.modalTitle { - color: var(--heading-color); - font-size: 24px; - font-weight: 500; - margin-bottom: 4px; -} - -.modalSubtitle { - color: var(--paragraph-color); - font-size: 16px; -} - -.modalContent { - margin-bottom: 24px; -} - -.ratingQuestion { - margin-bottom: 24px; - - h3 { - color: var(--heading-color); - font-size: 16px; - font-weight: 500; - margin-bottom: 16px; - } -} - -.ratingScale { - display: flex; - align-items: center; - gap: 16px; - margin-top: 8px; - - span { - color: var(--paragraph-color); - font-size: 14px; - min-width: 80px; - } -} - -.ratingButtons { - display: flex; - gap: 8px; - flex-grow: 1; - justify-content: center; -} - -.ratingButton { - width: 40px; - height: 40px; - border: 1px solid var(--border-color); - border-radius: 4px; - background: none; - color: var(--heading-color); - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; - - &:hover { - background-color: var(--background-hover-color); - } - - &[data-state='checked'] { - background-color: var(--blue-500); - border-color: var(--blue-500); - color: white; - } -} - -.radioGroup { - display: flex; - flex-direction: column; - gap: 12px; - margin-top: 8px; - - label { - display: flex; - align-items: center; - gap: 8px; - color: var(--paragraph-color); - font-size: 14px; - cursor: pointer; - - input { - width: 16px; - height: 16px; - } - } -} - -.modalFooter { - display: flex; - justify-content: flex-end; - gap: 12px; - border-top: 1px solid var(--border-color); - padding-top: 24px; -} - -@keyframes overlayShow { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes contentShow { +@keyframes slideDown { from { opacity: 0; - transform: translate(-50%, -48%) scale(0.96); + transform: translateY(-20px); } to { opacity: 1; - transform: translate(-50%, -50%) scale(1); - } -} - -.ratingButton { - width: 40px; - height: 40px; - border: 1px solid var(--border-color); - border-radius: 4px; - background: none; - color: var(--heading-color); - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; - - &:hover { - background-color: var(--background-hover-color); - border-color: var(--blue-500); - } - - &.selected { - background-color: var(--blue-500); - border-color: var(--blue-500); - color: white; - } - - &:focus { - outline: none; - box-shadow: 0 0 0 2px var(--blue-200); + transform: translateY(0); } } -@keyframes contentSlide { +@keyframes fadeIn { from { opacity: 0; - transform: translateY(-10px); + transform: translateX(-10px); } to { opacity: 1; - transform: translateY(0); + transform: translateX(0); } } -@keyframes fadeIn { +@keyframes barFill { from { - opacity: 0; - transform: translateX(-10px); + transform: scaleX(0); } to { - opacity: 1; - transform: translateX(0); + transform: scaleX(1); } } @@ -400,8 +243,10 @@ height: 100%; background-color: var(--blue-500); border-radius: 4px; - transition: width 1000ms cubic-bezier(0.4, 0, 0.2, 1); width: 0; + transform-origin: left; + transition: width 600ms cubic-bezier(0.4, 0, 0.2, 1); + will-change: width; } } @@ -413,7 +258,6 @@ opacity: 0; animation: fadeIn 300ms ease forwards; animation-delay: calc(var(--delay) + 200ms); - transition: all 1000ms cubic-bezier(0.4, 0, 0.2, 1); } } @@ -437,13 +281,4 @@ opacity: 1; transform: translateX(0); } -} - -@keyframes barFill { - from { - transform: scaleX(0); - } - to { - transform: scaleX(1); - } } \ No newline at end of file diff --git a/apps/frontend/src/components/Class/Ratings/index.tsx b/apps/frontend/src/components/Class/Ratings/index.tsx index 030a8f8b0..71f2844d7 100644 --- a/apps/frontend/src/components/Class/Ratings/index.tsx +++ b/apps/frontend/src/components/Class/Ratings/index.tsx @@ -1,10 +1,22 @@ import React, { useState, useContext, useEffect } from 'react'; import { NavArrowDown } from 'iconoir-react'; import * as Tooltip from "@radix-ui/react-tooltip"; -import * as Dialog from "@radix-ui/react-dialog"; import { Container, Button } from "@repo/theme"; -import styles from './Ratings.module.scss'; +import { UserFeedbackModal } from '@/components/UserFeedbackModal'; import ClassContext from "@/contexts/ClassContext"; +import styles from './Ratings.module.scss'; + +interface TooltipContentProps { + title: string; + description: string; +} + +const TooltipContent: React.FC = ({ title, description }) => ( +
+

{title}

+

{description}

+
+); interface RatingDetailProps { title: string; @@ -27,19 +39,22 @@ const RatingDetail: React.FC = ({ reviewCount }) => { const [isExpanded, setIsExpanded] = useState(true); - const [shouldAnimate, setShouldAnimate] = useState(true); + const [shouldAnimate, setShouldAnimate] = useState(false); - // Start animation slightly after expansion useEffect(() => { + let timer: NodeJS.Timeout; if (isExpanded) { - const timer = setTimeout(() => { - setShouldAnimate(true); - }, 200); // Delay to match the slideDown animation - return () => { - clearTimeout(timer); - setShouldAnimate(false); - }; + setShouldAnimate(false); + // Using requestAnimationFrame for smoother animation + requestAnimationFrame(() => { + timer = setTimeout(() => { + setShouldAnimate(true); + }, 50); + }); } + return () => { + if (timer) clearTimeout(timer); + }; }, [isExpanded]); return ( @@ -92,7 +107,7 @@ const RatingDetail: React.FC = ({ className={styles.bar} style={{ width: shouldAnimate ? `${stat.percentage}%` : '0%', - transitionDelay: `${index * 100}ms` + transitionDelay: `${index * 60}ms` }} /> @@ -107,122 +122,10 @@ const RatingDetail: React.FC = ({ ); }; -interface TooltipContentProps { - title: string; - description: string; -} - -const TooltipContent: React.FC = ({ title, description }) => ( -
-

{title}

-

{description}

-
-); - -function RatingModal() { +export default function Ratings() { + const [isModalOpen, setModalOpen] = useState(false); const { class: currentClass } = useContext(ClassContext); - const [ratings, setRatings] = useState({ - usefulness: 0, - difficulty: 0, - workload: 0 - }); - - const handleRatingClick = (type: 'usefulness' | 'difficulty' | 'workload', value: number) => { - setRatings(prev => ({ - ...prev, - [type]: value, - [type]: prev[type] === value ? 0 : value - })); - }; - - const getRatingButtonClass = (type: 'usefulness' | 'difficulty' | 'workload', value: number) => { - return `${styles.ratingButton} ${ratings[type] === value ? styles.selected : ''}`; - }; - - return ( - - - -
- - Rate Course - - - {currentClass.subject} {currentClass.courseNumber} • {currentClass.semester} {currentClass.year} - -
- -
-
-

1. How would you rate the usefulness of this course?

-
- Not useful -
- {[1, 2, 3, 4, 5].map((value) => ( - - ))} -
- Very useful -
-
- -
-

2. How would you rate the difficulty of this course?

-
- Very easy -
- {[1, 2, 3, 4, 5].map((value) => ( - - ))} -
- Very difficult -
-
- -
-

3. How would you rate the workload of this course?

-
- Very light -
- {[1, 2, 3, 4, 5].map((value) => ( - - ))} -
- Very heavy -
-
-
-
- - - - -
-
-
- ); -} - -export default function Ratings() { const ratingsData = [ { title: "Usefulness", @@ -272,12 +175,7 @@ export default function Ratings() {
- - - - - - +
{ratingsData.map((ratingData) => ( @@ -287,6 +185,14 @@ export default function Ratings() { /> ))}
+ + setModalOpen(false)} + title="Rate Course" + subtitle={`${currentClass.subject} ${currentClass.courseNumber} • ${currentClass.semester} ${currentClass.year}`} + currentClass={currentClass} + />
); diff --git a/apps/frontend/src/components/Detail/Detail.module.scss b/apps/frontend/src/components/Detail/Detail.module.scss new file mode 100644 index 000000000..b0e009047 --- /dev/null +++ b/apps/frontend/src/components/Detail/Detail.module.scss @@ -0,0 +1,35 @@ +.label { + margin-top: 20px; + margin-bottom: 10px; + color: var(--label-color); + line-height: 1; +} + +.description { + color: var(--paragraph-color); + font-size: 14px; + margin-top: 6px; + line-height: 1.5; +} + +.attendanceRequirements { + .icon { + margin-right: 8px; + font-size: 1.2rem; + color: #6c757d; + vertical-align: middle; + } +} + +.suggestEdit { + color: var(--blue-500); + text-decoration: none; + font-size: 0.9rem; + margin-top: 10px; + display: flex; + align-items: center; + + &:hover { + text-decoration: underline; + } +} \ No newline at end of file diff --git a/apps/frontend/src/components/Detail/index.tsx b/apps/frontend/src/components/Detail/index.tsx new file mode 100644 index 000000000..34186d7bb --- /dev/null +++ b/apps/frontend/src/components/Detail/index.tsx @@ -0,0 +1,52 @@ +import { useState } from "react"; +import { UserCircle, Camera } from "iconoir-react"; +import { UserFeedbackModal } from '@/components/UserFeedbackModal'; +import useClass from "@/hooks/useClass"; +import styles from "./Detail.module.scss"; + +interface AttendanceRequirementsProps { + attendanceRequired: boolean | null; + lecturesRecorded: boolean | null; +} + +export default function AttendanceRequirements({ + attendanceRequired, + lecturesRecorded, +}: AttendanceRequirementsProps) { + const [isModalOpen, setModalOpen] = useState(false); + const { class: currentClass } = useClass(); + + return ( +
+

Attendance Requirements

+
+ + + {attendanceRequired ? "Attendance Required" : "Attendance Not Required"} + +
+
+ + {lecturesRecorded ? "Lectures Recorded" : "Lectures Not Recorded"} +
+ { + e.preventDefault(); + setModalOpen(true); + }} + > + Look inaccurate? Suggest an edit + + + setModalOpen(false)} + title="Suggest an edit" + subtitle={`${currentClass.subject} ${currentClass.courseNumber} • ${currentClass.semester} ${currentClass.year}`} + currentClass={currentClass} + /> +
+ ); +} \ No newline at end of file diff --git a/apps/frontend/src/components/UserFeedbackModal/AttendanceForm.tsx b/apps/frontend/src/components/UserFeedbackModal/AttendanceForm.tsx new file mode 100644 index 000000000..10a91f63b --- /dev/null +++ b/apps/frontend/src/components/UserFeedbackModal/AttendanceForm.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import styles from './UserFeedbackModal.module.scss'; +import { ClassData } from './types'; + +interface AttendanceFormProps { + currentClass: ClassData; +} + +export function AttendanceForm({ currentClass }: AttendanceFormProps) { + return ( +
+

Attendance & Recording

+
+

1. Is lecture attendance required?

+
+ + +
+
+ +
+

2. (If applicable) Was discussion attendance required?

+
+ + +
+
+ +
+

3. Were lectures recorded?

+
+ + +
+
+
+ ); +} diff --git a/apps/frontend/src/components/UserFeedbackModal/RatingForm.tsx b/apps/frontend/src/components/UserFeedbackModal/RatingForm.tsx new file mode 100644 index 000000000..f25151d67 --- /dev/null +++ b/apps/frontend/src/components/UserFeedbackModal/RatingForm.tsx @@ -0,0 +1,76 @@ +import React, { useState } from 'react'; +import styles from './UserFeedbackModal.module.scss'; +import { ClassData } from './types'; + +interface RatingFormProps { + currentClass: ClassData; +} + +export function RatingsForm({ currentClass }: RatingFormProps) { + const [ratings, setRatings] = useState({ + usefulness: 0, + difficulty: 0, + workload: 0 + }); + + const handleRatingClick = (type: keyof typeof ratings, value: number) => { + setRatings(prev => ({ + ...prev, + [type]: prev[type] === value ? 0 : value + })); + }; + + const renderRatingScale = ( + type: keyof typeof ratings, + question: string, + leftLabel: string, + rightLabel: string + ) => ( +
+

{question}

+
+ {leftLabel} +
+ {[1, 2, 3, 4, 5].map((value) => ( + + ))} +
+ {rightLabel} +
+
+ ); + + return ( +
+

Course Ratings

+ + {renderRatingScale( + 'usefulness', + '1. How would you rate the usefulness of this course?', + 'Not useful', + 'Very useful' + )} + + {renderRatingScale( + 'difficulty', + '2. How would you rate the difficulty of this course?', + 'Very easy', + 'Very difficult' + )} + + {renderRatingScale( + 'workload', + '3. How would you rate the workload of this course?', + 'Very light', + 'Very heavy' + )} +
+ ); +} diff --git a/apps/frontend/src/components/UserFeedbackModal/UserFeedbackModal.module.scss b/apps/frontend/src/components/UserFeedbackModal/UserFeedbackModal.module.scss new file mode 100644 index 000000000..33de85abe --- /dev/null +++ b/apps/frontend/src/components/UserFeedbackModal/UserFeedbackModal.module.scss @@ -0,0 +1,278 @@ +.overlay { + background-color: rgb(0 0 0 / 60%); + position: fixed; + inset: 0; + animation: overlayShow 150ms cubic-bezier(0.16, 1, 0.3, 1); + z-index: 50; +} + +.modal { + background-color: var(--foreground-color); + border-radius: 8px; + box-shadow: 0 4px 32px rgb(0 0 0 / 25%); + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 90vw; + max-width: 600px; + max-height: 85vh; + padding: 24px; + animation: contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1); + z-index: 51; +} + +.modalHeader { + margin-bottom: 24px; + text-align: left; +} + +.modalTitle { + color: var(--heading-color); + font-size: 24px; + font-weight: 500; + margin-bottom: 12px; +} + +.subtitleRow { + display: flex; + align-items: center; + gap: 12px; +} + +.modalSubtitle { + color: var(--paragraph-color); + font-size: 16px; + margin: 0; +} + +.modalContent { + margin-bottom: 0px; +} + +.combinedForm { + max-height: calc(85vh - 220px); + overflow-y: auto; + padding-right: 12px; + margin-right: -12px; + margin-top:0; + + &::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + &::-webkit-scrollbar-track { + background: var(--background-color); + border-radius: 3px; + } + + &::-webkit-scrollbar-thumb { + background: var(--label-color); + border-radius: 3px; + opacity: 0.8; + + &:hover { + background: var(--paragraph-color); + } + } +} + +.termSelect { + display: none; +} + +.termDropdown { + padding: 4px 12px; + font-size: 14px; + font-weight: 400; + color: var(--heading-color); + background-color: var(--foreground-color); + border: 1px solid var(--border-color); + border-radius: 4px; + outline: none; + cursor: pointer; + transition: all 0.2s ease; + min-width: 120px; + + &:hover { + border-color: var(--blue-400); + } + + &:focus { + border-color: var(--blue-500); + box-shadow: 0 0 0 2px var(--blue-200); + } +} + +.modalFooter { + position: sticky; + + margin-top: 32px; + padding-top: 24px; + border-top: 1px solid var(--border-color); + display: flex; + justify-content: flex-end; + gap: 12px; +} + +.sectionTitle { + color: var(--heading-color); + font-size: 18px; + font-weight: 500; + margin-bottom: 20px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-color); +} + +.formGroup { + margin-bottom: 32px; + + &:last-child { + margin-bottom: 40px; + } + + h3, p { + color: var(--heading-color); + font-size: 16px; + font-weight: 500; + margin-bottom: 12px; + } +} + +.ratingScale { + display: flex; + align-items: center; + gap: 16px; + margin-top: 8px; + + span { + color: var(--paragraph-color); + font-size: 14px; + min-width: 80px; + } +} + +.ratingButtons { + display: flex; + gap: 8px; + flex-grow: 1; + justify-content: center; +} + +.ratingButton { + width: 40px; + height: 40px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: none; + color: var(--heading-color); + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background-color: var(--background-hover-color); + border-color: var(--blue-500); + } + + &.selected { + background-color: var(--blue-500); + border-color: var(--blue-500); + color: white; + } + + &:focus { + outline: none; + box-shadow: 0 0 0 2px var(--blue-200); + } +} + +.radioOptions { + display: flex; + flex-direction: column; + gap: 12px; + + label { + display: flex; + align-items: center; + gap: 12px; + color: var(--paragraph-color); + font-size: 14px; + cursor: pointer; + + input[type="radio"] { + appearance: none; + width: 16px; + height: 16px; + border: 2px solid var(--border-color); + border-radius: 50%; + margin: 0; + cursor: pointer; + transition: all 0.2s ease; + + &:checked { + border-color: var(--blue-500); + background-color: var(--blue-500); + box-shadow: inset 0 0 0 3px var(--foreground-color); + } + + &:hover:not(:checked) { + border-color: var(--blue-400); + } + } + } +} + +.attendanceSection { + margin-top: 32px; + padding-top: 32px; + border-top: 1px solid var(--border-color); +} + +@keyframes overlayShow { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes contentShow { + from { + opacity: 0; + transform: translate(-50%, -48%) scale(0.96); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} + +.closeButton { + position: absolute; + right: 24px; + top: 24px; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: none; + color: var(--label-color); + cursor: pointer; + font-size: 18px; + padding: 0; + transition: color 0.2s ease; + + &:hover { + color: var(--heading-color); + } + + &:focus { + outline: none; + color: var(--heading-color); + } +} diff --git a/apps/frontend/src/components/UserFeedbackModal/UserFeedbackModal.tsx b/apps/frontend/src/components/UserFeedbackModal/UserFeedbackModal.tsx new file mode 100644 index 000000000..0f03dcfcb --- /dev/null +++ b/apps/frontend/src/components/UserFeedbackModal/UserFeedbackModal.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import * as Dialog from "@radix-ui/react-dialog"; +import { Button } from "@repo/theme"; +import { RatingsForm } from './RatingForm'; +import { AttendanceForm } from './AttendanceForm'; +import { ClassData } from './types'; +import styles from './UserFeedbackModal.module.scss'; + +interface UserFeedbackModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + subtitle?: string; + currentClass: ClassData; +} + +export function UserFeedbackModal({ + isOpen, + onClose, + title, + subtitle, + currentClass +}: UserFeedbackModalProps) { + return ( + + + + + + ✕ + +
+ + {title} + +
+ + {currentClass.subject} {currentClass.courseNumber} + + +
+
+ +
+
+ + +
+
+ +
+ + + + +
+
+
+
+ ); +} diff --git a/apps/frontend/src/components/UserFeedbackModal/index.ts b/apps/frontend/src/components/UserFeedbackModal/index.ts new file mode 100644 index 000000000..f99cc2735 --- /dev/null +++ b/apps/frontend/src/components/UserFeedbackModal/index.ts @@ -0,0 +1,2 @@ +export { UserFeedbackModal } from './UserFeedbackModal'; +export type { ClassData } from './types'; \ No newline at end of file diff --git a/apps/frontend/src/components/UserFeedbackModal/types.ts b/apps/frontend/src/components/UserFeedbackModal/types.ts new file mode 100644 index 000000000..80a8701b2 --- /dev/null +++ b/apps/frontend/src/components/UserFeedbackModal/types.ts @@ -0,0 +1,6 @@ +export interface ClassData { + subject: string; + courseNumber: string; + semester: string; + year: string; +} diff --git a/apps/frontend/src/lib/api/classes.ts b/apps/frontend/src/lib/api/classes.ts index 38f036e59..c6b4b7e96 100644 --- a/apps/frontend/src/lib/api/classes.ts +++ b/apps/frontend/src/lib/api/classes.ts @@ -165,6 +165,8 @@ export interface ISection { startDate: string; endDate: string; exams: IExam[]; + attendanceRequired: boolean; + lecturesRecorded: boolean; } export interface IReservation {