From fd41dd0435b3705f1a1b3b2d705cc7af706580bc Mon Sep 17 00:00:00 2001 From: Manning Wu Date: Tue, 4 Feb 2025 18:16:43 -0800 Subject: [PATCH 1/7] fixed comments --- firestore.rules | 3 +-- src/app/admin/subject/[slug]/_components/unitTests.tsx | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/firestore.rules b/firestore.rules index 4e5db535..427e18b7 100644 --- a/firestore.rules +++ b/firestore.rules @@ -45,5 +45,4 @@ service cloud.firestore { allow read, write: if isAuthenticated() && request.auth.uid == userId; } } - -} \ No newline at end of file +} diff --git a/src/app/admin/subject/[slug]/_components/unitTests.tsx b/src/app/admin/subject/[slug]/_components/unitTests.tsx index b8c1cb92..46da5c2e 100644 --- a/src/app/admin/subject/[slug]/_components/unitTests.tsx +++ b/src/app/admin/subject/[slug]/_components/unitTests.tsx @@ -1,11 +1,11 @@ "use client"; import React, { useState, memo } from "react"; -import Link from "next/link"; import type { UnitTest } from "@/types/firestore"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Trash, PlusCircle } from "lucide-react"; +import { Link } from "../../link"; interface UnitTestsProps { unitId: string; From da076ef99bf9071f2081df822d82ce70491f5e9e Mon Sep 17 00:00:00 2001 From: Manning Wu Date: Wed, 12 Feb 2025 12:16:02 -0800 Subject: [PATCH 2/7] Merge complete --- package-lock.json | 26 ++++- package.json | 2 + src/app/account/page.tsx | 72 ++++++------ src/app/admin/page.tsx | 109 ++++++++++++------ .../admin/subject/[slug]/_components/unit.tsx | 16 ++- .../subject/[slug]/_components/unitTests.tsx | 8 +- src/app/admin/subject/[slug]/page.tsx | 69 ++++++----- src/app/layout.tsx | 2 + src/app/subject/[slug]/page.tsx | 7 +- .../QuestionsInputInterface.tsx | 63 +++++++--- .../RenderAdvancedTextbox.tsx | 29 ++--- src/components/questions/testRenderer.tsx | 1 + src/components/subject/subject-sidebar.tsx | 22 +++- src/components/subject/unit-accordion.tsx | 22 ++-- src/components/ui/sonner.tsx | 35 ++++++ 15 files changed, 317 insertions(+), 166 deletions(-) create mode 100644 src/components/ui/sonner.tsx diff --git a/package-lock.json b/package-lock.json index 5c7d2770..0295478b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,6 @@ "@editorjs/simple-image": "^1.6.0", "@editorjs/table": "^2.3.0", "@editorjs/underline": "^1.1.0", - "@firebasegen/default-connector": "file:dataconnect-generated/js/default-connector", "@hookform/resolvers": "^3.9.0", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-dialog": "^1.1.1", @@ -47,11 +46,13 @@ "katex": "^0.16.11", "lucide-react": "^0.469.0", "next": "^14.2.5", + "next-themes": "^0.4.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.52.1", "react-router-dom": "^6.26.1", "short-uuid": "^5.2.0", + "sonner": "^1.7.4", "tailwind": "^4.0.0", "tailwind-merge": "^2.4.0", "tailwind-scrollbar": "^3.1.0", @@ -82,6 +83,7 @@ "dataconnect-generated/js/default-connector": { "name": "@firebasegen/default-connector", "version": "1.0.0", + "extraneous": true, "license": "Apache-2.0", "engines": { "node": " >=18.0" @@ -971,10 +973,6 @@ "version": "1.0.1", "license": "Apache-2.0" }, - "node_modules/@firebasegen/default-connector": { - "resolved": "dataconnect-generated/js/default-connector", - "link": true - }, "node_modules/@floating-ui/core": { "version": "1.6.8", "license": "MIT", @@ -6614,6 +6612,15 @@ } } }, + "node_modules/next-themes": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.4.tgz", + "integrity": "sha512-LDQ2qIOJF0VnuVrrMSMLrWGjRMkq+0mpgl6e0juCLqdJ+oo8Q84JRWT6Wh11VDQKkMMe+dVzDKLWs5n87T+PkQ==", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -7985,6 +7992,15 @@ "node": ">=8" } }, + "node_modules/sonner": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz", + "integrity": "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index c9ed352c..31e3bebc 100644 --- a/package.json +++ b/package.json @@ -48,11 +48,13 @@ "katex": "^0.16.11", "lucide-react": "^0.469.0", "next": "^14.2.5", + "next-themes": "^0.4.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.52.1", "react-router-dom": "^6.26.1", "short-uuid": "^5.2.0", + "sonner": "^1.7.4", "tailwind": "^4.0.0", "tailwind-merge": "^2.4.0", "tailwind-scrollbar": "^3.1.0", diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx index ad52a879..ade57f3d 100644 --- a/src/app/account/page.tsx +++ b/src/app/account/page.tsx @@ -16,6 +16,7 @@ import ReauthenticateModal from "@/components/auth/ReauthenticateModal"; import Image from "next/image"; import { useUser } from "@/components/hooks/UserContext"; import { ArrowLeft } from "lucide-react"; +import { toast } from "sonner"; interface ManagementForm extends HTMLFormElement { displayName: { @@ -34,8 +35,7 @@ export default function UserManagementPage() { const router = useRouter(); const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); - const [errors, setErrors] = useState>({}); - const [successMessage, setSuccessMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(""); const [photoPreview, setPhotoPreview] = useState(null); const [reauthModalOpen, setReauthModalOpen] = useState(false); const [reauthAction, setReauthAction] = useState< @@ -51,10 +51,7 @@ export default function UserManagementPage() { setUser(fetchedUser); setPhotoPreview(fetchedUser?.photoURL ?? ""); } catch (error) { - setErrors((prev) => ({ - ...prev, - general: "Failed to load user data. Please try again.", - })); + console.error(error); } finally { setLoading(false); } @@ -64,8 +61,7 @@ export default function UserManagementPage() { const handleUpdateDisplayName = async (event: FormEvent) => { event.preventDefault(); - setErrors({}); - setSuccessMessage(null); + setErrorMessage(""); const displayName = event.currentTarget.displayName.value.trim(); if (!user || !displayName || displayName === user.displayName) return; @@ -78,20 +74,23 @@ export default function UserManagementPage() { clearUserCache(); await updateUser(); - setSuccessMessage("Display name updated successfully."); + toast.success("Display name updated successfully."); } catch (error: unknown) { - setErrors((prev) => ({ ...prev, displayName: error as string })); + if (error instanceof Error) { + setErrorMessage(error.message); + } else { + setErrorMessage("An unknown error occurred."); + } } }; const handleUpdatePassword = async (event: FormEvent) => { event.preventDefault(); - setErrors({}); - setSuccessMessage(null); + setErrorMessage(""); const newPassword = event.currentTarget.password.value.trim(); if (!newPassword) { - setErrors((prev) => ({ ...prev, password: "Password cannot be empty." })); + setErrorMessage("Password cannot be empty."); return; } @@ -104,17 +103,20 @@ export default function UserManagementPage() { const handleConfirmUpdatePassword = async () => { try { await updatePassword(tempPassword); - setSuccessMessage("Password updated successfully."); + toast.success("Password updated successfully."); setTempPassword(""); } catch (error: unknown) { - setErrors((prev) => ({ ...prev, password: error as string })); + if (error instanceof Error) { + setErrorMessage(error.message); + } else { + setErrorMessage("An unknown error occurred."); + } } }; const handleUpdatePhotoURL = async (event: FormEvent) => { event.preventDefault(); - setErrors({}); - setSuccessMessage(null); + setErrorMessage(""); const photoURL = event.currentTarget.photoURL.value.trim(); if (!user) return; @@ -127,15 +129,18 @@ export default function UserManagementPage() { clearUserCache(); await updateUser(); - setSuccessMessage("Photo URL updated successfully."); + toast.success("Photo URL updated successfully."); } catch (error: unknown) { - setErrors((prev) => ({ ...prev, photoURL: error as string })); + if (error instanceof Error) { + setErrorMessage(error.message); + } else { + setErrorMessage("An unknown error occurred."); + } } }; const handleDeleteAccount = async () => { - setErrors({}); - setSuccessMessage(null); + setErrorMessage(""); // Open reauthentication modal before proceeding setReauthAction("delete"); @@ -148,7 +153,11 @@ export default function UserManagementPage() { clearUserCache(); router.push("/login"); } catch (error: unknown) { - setErrors((prev) => ({ ...prev, general: error as string })); + if (error instanceof Error) { + setErrorMessage(error.message); + } else { + setErrorMessage("An unknown error occurred."); + } } }; @@ -204,15 +213,9 @@ export default function UserManagementPage() {

Manage your account details below.

- {errors.general && ( + {errorMessage && (
- {errors.general} -
- )} - - {successMessage && ( -
- {successMessage} + {errorMessage}
)} @@ -232,9 +235,6 @@ export default function UserManagementPage() { className="rounded-md border border-gray-300 px-3 py-2" required /> - {errors.displayName && ( - {errors.displayName} - )} @@ -256,9 +256,6 @@ export default function UserManagementPage() { className="rounded-md border border-gray-300 px-3 py-2" required /> - {errors.password && ( - {errors.password} - )} @@ -289,9 +286,6 @@ export default function UserManagementPage() { className="rounded-md border border-gray-300 px-3 py-2" required /> - {errors.photoURL && ( - {errors.photoURL} - )} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 5db049db..755655a1 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -2,14 +2,14 @@ import Navbar from "@/components/global/navbar"; import Footer from "@/components/global/footer"; import type { User } from "@/types/user"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useUserManagement } from "./useUserManagement"; import apClassesData from "@/components/apClasses.json"; import { useUser } from "../../components/hooks/UserContext"; import Link from "next/link"; import { formatSlug } from "@/lib/utils"; -import { PencilRuler, ShieldCheck } from "lucide-react"; +import { PencilRuler, ShieldCheck, X } from "lucide-react"; const apClasses = apClassesData.apClasses; @@ -90,23 +90,34 @@ function SelectCourse() { function AdminPanel({ user }: { user: User }) { // Users here is refering to the FiveHive users propagated in the changeUserRole (only seen by admins) - const { users, error, handleRoleChange } = useUserManagement(user); + const { + users: initialUsers, + error, + handleRoleChange, + } = useUserManagement(user); + const [users, setUsers] = useState(initialUsers); const [searchTermUsers, setSearchTermUsers] = useState(""); - const [isModalOpen, setIsModalOpen] = useState(false); const [selectedUser, setSelectedUser] = useState(null); - const openModal = (user: User) => { - setSelectedUser(user); - setIsModalOpen(true); + useEffect(() => { + setUsers(initialUsers); + }, [initialUsers]); + + const dialogRef = useRef(null); + + const openDialog = () => { + if (dialogRef.current) dialogRef.current.showModal(); }; - const closeModal = () => { - setSelectedUser(null); - setIsModalOpen(false); + const closeDialog = () => { + if (dialogRef.current) dialogRef.current.close(); }; - const filteredUsers = users.filter((user) => - user.displayName.toLowerCase().includes(searchTermUsers.toLowerCase()), + const filteredUsers = users.filter( + (user) => + user.displayName.toLowerCase().includes(searchTermUsers.toLowerCase()) || + user.email.toLowerCase().includes(searchTermUsers.toLowerCase()) || + user.access.toLowerCase().includes(searchTermUsers.toLowerCase()), ); return ( @@ -117,11 +128,21 @@ function AdminPanel({ user }: { user: User }) {
setSearchTermUsers(e.target.value)} /> +
+

{filteredUsers.length} result(s):

+

+ {filteredUsers.filter((u) => u.access === "admin").length} admin +
+ {filteredUsers.filter((u) => u.access === "member").length} member +
+ {filteredUsers.filter((u) => u.access === "user").length} user +

+
    {!error && filteredUsers.map( @@ -135,7 +156,8 @@ function AdminPanel({ user }: { user: User }) { alert("Admins cannot demote other admins"); return; } else { - openModal(u); + setSelectedUser(u); + openDialog(); } }} > @@ -156,20 +178,33 @@ function AdminPanel({ user }: { user: User }) {
- {/* Modal for Role Change */} - {isModalOpen && selectedUser && ( -
-
-

- Change role for {selectedUser.displayName} + + + {selectedUser && ( + <> +

+ Update role of {selectedUser.displayName} ({selectedUser.access})

-
- -

-
- )} + + )} + ); } diff --git a/src/app/admin/subject/[slug]/_components/unit.tsx b/src/app/admin/subject/[slug]/_components/unit.tsx index 0526c0c9..c6f821ed 100644 --- a/src/app/admin/subject/[slug]/_components/unit.tsx +++ b/src/app/admin/subject/[slug]/_components/unit.tsx @@ -15,6 +15,7 @@ import type { Unit, Chapter, UnitTest } from "@/types/firestore"; import short from "short-uuid"; import UnitTests from "./unitTests"; import ChapterContent from "./chapterContent"; +import { Input } from "@/components/ui/input"; const translator = short(short.constants.flickrBase58); @@ -112,9 +113,12 @@ function UnitComponent({ }; const handleChapterDelete = (chapterId: string) => { - if (!confirm( - "If you delete this chapter and save changes, you will lose all chapter data. Are you sure you want to delete this chapter?", - )) return; + if ( + !confirm( + "If you delete this chapter and save changes, you will lose all chapter data. Are you sure you want to delete this chapter?", + ) + ) + return; const updatedChapters = chapters.filter((c) => c.id !== chapterId); setChapters(updatedChapters); @@ -203,11 +207,11 @@ function UnitComponent({ {/* UNIT HEADER */}
onMoveUp(index)} /> onMoveDown(index)} /> - setNewChapterTitle(e.target.value)} placeholder="New chapter title" diff --git a/src/app/admin/subject/[slug]/_components/unitTests.tsx b/src/app/admin/subject/[slug]/_components/unitTests.tsx index 46da5c2e..c03603a4 100644 --- a/src/app/admin/subject/[slug]/_components/unitTests.tsx +++ b/src/app/admin/subject/[slug]/_components/unitTests.tsx @@ -9,7 +9,7 @@ import { Link } from "../../link"; interface UnitTestsProps { unitId: string; - subjectSlug: string; // If you want to build a dynamic link based on the subject slug + subjectSlug: string; // If you want to build a dynamic link based on the subject slug tests: UnitTest[]; onTestUpdate: (testId: string, newName: string) => void; onTestDelete: (testId: string) => void; @@ -44,7 +44,7 @@ function UnitTests({ return (
-

Unit Tests

+

Unit Tests

{tests.map((test, testIdx) => (
{/* Example Link to test editor (adjust as needed) */} Edit Test @@ -96,4 +96,4 @@ function UnitTests({ ); } - export default memo(UnitTests); +export default memo(UnitTests); diff --git a/src/app/admin/subject/[slug]/page.tsx b/src/app/admin/subject/[slug]/page.tsx index b27faa87..679640c3 100644 --- a/src/app/admin/subject/[slug]/page.tsx +++ b/src/app/admin/subject/[slug]/page.tsx @@ -1,11 +1,17 @@ "use client"; import React, { useState, useEffect } from "react"; -import Link from "next/link"; +import { Link } from "@/app/admin/subject/link"; import { ArrowLeft, Save, PlusCircle } from "lucide-react"; import { useUser } from "@/components/hooks/UserContext"; import { db } from "@/lib/firebase"; -import { collection, doc, getDoc, getDocs, writeBatch } from "firebase/firestore"; +import { + collection, + doc, + getDoc, + getDocs, + writeBatch, +} from "firebase/firestore"; import { Button, buttonVariants } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Blocker } from "@/app/admin/subject/navigation-block"; @@ -15,7 +21,6 @@ import short from "short-uuid"; import type { Subject, Unit } from "@/types/firestore"; import UnitComponent from "./_components/unit"; - const translator = short(short.constants.flickrBase58); function generateShortId() { @@ -60,7 +65,7 @@ const emptyData: Subject = { export default function Page({ params }: { params: { slug: string } }) { const { user, error, setError, setLoading } = useUser(); const [subjectTitle, setSubjectTitle] = useState(""); - const [units, setUnits] = useState([]); + const [units, setUnits] = useState([]); const [unsavedChanges, setUnsavedChanges] = useState(false); @@ -84,7 +89,7 @@ export default function Page({ params }: { params: { slug: string } }) { const foundTitle = apClasses.find( (apClass) => - formatSlug(apClass.replace(/AP /g, "")) === params.slug + formatSlug(apClass.replace(/AP /g, "")) === params.slug, ) ?? ""; const newSubject = structuredClone(emptyData); newSubject.title = foundTitle; @@ -153,9 +158,7 @@ export default function Page({ params }: { params: { slug: string } }) { // Called by each whenever that unit updates const handleUnitChange = (unitId: string, updatedUnit: Unit) => { - setUnits((prev) => - prev.map((u) => (u.id === unitId ? updatedUnit : u)) - ); + setUnits((prev) => prev.map((u) => (u.id === unitId ? updatedUnit : u))); setUnsavedChanges(true); }; @@ -172,18 +175,18 @@ export default function Page({ params }: { params: { slug: string } }) { title: subjectTitle, units: units, }; - + try { const batch = writeBatch(db); - + // 1. Save the main subject doc batch.set(doc(db, "subjects", params.slug), subjectToSave); - + // 2. For each Unit, update or create the unit doc, then manage sub-collections for (const unit of subjectToSave.units) { // Set (upsert) the Unit itself batch.set(doc(db, "subjects", params.slug, "units", unit.id), unit); - + // ----- Chapters ----- const chapterCollectionRef = collection( db, @@ -191,28 +194,28 @@ export default function Page({ params }: { params: { slug: string } }) { params.slug, "units", unit.id, - "chapters" + "chapters", ); - + // a) Fetch all existing chapters in Firestore const existingChaptersSnap = await getDocs(chapterCollectionRef); - + // b) Build a set of local chapter IDs so we know what should exist const localChapterIds = new Set(unit.chapters.map((c) => c.id)); - + // c) For each chapter in Firestore, if it's NOT in our local data, delete it existingChaptersSnap.forEach((chapterDoc) => { if (!localChapterIds.has(chapterDoc.id)) { batch.delete(chapterDoc.ref); } }); - + // d) Now, upsert all chapters from our local data for (const chapter of unit.chapters) { const chapterDocRef = doc(chapterCollectionRef, chapter.id); batch.set(chapterDocRef, chapter, { merge: true }); } - + // ----- Tests ----- if (unit.tests) { const testsCollectionRef = collection( @@ -221,33 +224,33 @@ export default function Page({ params }: { params: { slug: string } }) { params.slug, "units", unit.id, - "tests" + "tests", ); - + // a) Fetch all existing tests in Firestore const existingTestsSnap = await getDocs(testsCollectionRef); - + // b) Build a set of local test IDs const localTestIds = new Set(unit.tests.map((t) => t.id)); - + // c) Delete any Firestore test that is no longer in our local data existingTestsSnap.forEach((testDoc) => { if (!localTestIds.has(testDoc.id)) { batch.delete(testDoc.ref); } }); - + // d) Upsert all tests from our local data for (const test of unit.tests) { const testDocRef = doc(testsCollectionRef, test.id); - batch.set(testDocRef, test, { merge: true }); + batch.set(testDocRef, { name: test.name }, { merge: true }); } } - + // If you still have single-test logic (unit.test / unit.testId), // you'd have to decide how to handle that (like the multi-test approach). } - + // 3. Commit the batch await batch.commit(); alert("Subject content saved successfully."); @@ -258,7 +261,7 @@ export default function Page({ params }: { params: { slug: string } }) { } }; -// Adds save + // Adds save // const handleSave = async () => { // // Rebuild the Subject object from current state // const subjectToSave: Subject = { @@ -330,11 +333,17 @@ export default function Page({ params }: { params: { slug: string } }) {
- + Return to Admin Dashboard -
@@ -379,4 +388,4 @@ export default function Page({ params }: { params: { slug: string } }) {
); -} \ No newline at end of file +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c89cce65..1a493c67 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import "@/styles/globals.css"; import { Outfit } from "next/font/google"; import RootLayoutClient from "./RootLayoutClient"; +import { Toaster } from "@/components/ui/sonner"; const outfit = Outfit({ subsets: ["latin"], @@ -64,6 +65,7 @@ export default function RootLayout({ }} /> {children} + ); diff --git a/src/app/subject/[slug]/page.tsx b/src/app/subject/[slug]/page.tsx index 48504182..8a477963 100644 --- a/src/app/subject/[slug]/page.tsx +++ b/src/app/subject/[slug]/page.tsx @@ -55,8 +55,11 @@ const Page = ({ params }: { params: { slug: string } }) => { } if (error ?? !subject) { return ( -
- {error} + ); } diff --git a/src/components/article-creator/custom_questions/QuestionsInputInterface.tsx b/src/components/article-creator/custom_questions/QuestionsInputInterface.tsx index 627bf2d7..583e814a 100644 --- a/src/components/article-creator/custom_questions/QuestionsInputInterface.tsx +++ b/src/components/article-creator/custom_questions/QuestionsInputInterface.tsx @@ -2,7 +2,14 @@ import React, { useEffect, useState } from "react"; import type { QuestionFormat } from "@/types/questions"; -import { Trash, CirclePlus, ChevronDown, ChevronUp } from "lucide-react"; +import { + Trash, + CirclePlus, + ChevronDown, + ChevronUp, + MoveUp, + MoveDown, +} from "lucide-react"; import AdvancedTextbox from "./AdvancedTextbox"; import { Input } from "@/components/ui/input"; @@ -90,6 +97,24 @@ const QuestionsInputInterface: React.FC = ({ setQuestions(newQuestions); }; + const moveQuestionUp = (index: number) => { + if (index === 0) return; + const newQuestions = [...questions]; + const temp = newQuestions[index]; + newQuestions[index] = newQuestions[index - 1]!; + newQuestions[index - 1] = temp!; + setQuestions(newQuestions); + }; + + const moveQuestionDown = (index: number) => { + if (index === questions.length - 1) return; + const newQuestions = [...questions]; + const temp = newQuestions[index]; + newQuestions[index] = newQuestions[index + 1]!; + newQuestions[index + 1] = temp!; + setQuestions(newQuestions); + }; + const addOption = (qIndex: number) => { const newQuestions = [...questions]; const newOptions = [ @@ -139,20 +164,30 @@ const QuestionsInputInterface: React.FC = ({ {questions.map((questionInstance, qIndex) => (
- +
+ moveQuestionUp(qIndex)} + /> + moveQuestionDown(qIndex)} + /> + +
{!collapsed[qIndex] && (
{testRenderer && ( diff --git a/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx b/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx index b5594fa3..8bdc29d9 100644 --- a/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx +++ b/src/components/article-creator/custom_questions/RenderAdvancedTextbox.tsx @@ -9,11 +9,13 @@ interface Props { } interface FileWrapper { - file: File + file: File; } // Utility to retrieve a file from IndexedDB based on unique ID -export function getFileFromIndexedDB(name: string): Promise { +export function getFileFromIndexedDB( + name: string, +): Promise { return new Promise((resolve) => { const dbRequest = indexedDB.open("mediaFilesDB", 2); @@ -126,23 +128,22 @@ const FileRenderer: React.FC<{ file: QuestionFile }> = ({ file }) => { export function RenderContent({ content }: Props) { return ( -
+
{/* Render text content directly */} - {content.value?.split("$@").map((line, lineIndex) => { + {content.value?.split(/(\$@[^$]+\$)/g).map((line, lineIndex) => { if (line.endsWith("$")) { return ( -
-
-
+ ); } - return
{line}
; + return {line}; })} {/* Render files through individual components */} diff --git a/src/components/questions/testRenderer.tsx b/src/components/questions/testRenderer.tsx index f41198dd..4c94f4fd 100644 --- a/src/components/questions/testRenderer.tsx +++ b/src/components/questions/testRenderer.tsx @@ -11,6 +11,7 @@ import Highlighter, { type Highlight } from "./digital-testing/Highlighter"; import ReviewPage, { isQuestionCorrect } from "./digital-testing/ReviewPage"; import clsx from "clsx"; import { cn } from "@/lib/utils"; +import "katex/dist/katex.min.css"; interface Props { inputQuestions: QuestionFormat[]; diff --git a/src/components/subject/subject-sidebar.tsx b/src/components/subject/subject-sidebar.tsx index 6bfc69cf..c7685112 100644 --- a/src/components/subject/subject-sidebar.tsx +++ b/src/components/subject/subject-sidebar.tsx @@ -85,9 +85,25 @@ const SubjectSidebar = (props: Props) => { ))} - {unit.test && ( + {/* Handle multiple tests (unit.tests) first */} + {unit.tests ? ( + unit.tests.map((test, testIndex) => ( + + + {test.name + ? test.name + : // unit.tests cuz typescript doesn't recognize I checked for unit.tests already + `Unit ${unitIndex + 1} Test ${unit.tests && unit.tests.length > 1 ? ` ${testIndex + 1}` : ""}`} + + )) + ) : // Fallback: single test flow + unit.test && unit.testId ? ( @@ -95,7 +111,7 @@ const SubjectSidebar = (props: Props) => { ? unit.title : `Unit ${unitIndex + 1} Test`} - )} + ) : null}
diff --git a/src/components/subject/unit-accordion.tsx b/src/components/subject/unit-accordion.tsx index f5a1d3a2..a53e401d 100644 --- a/src/components/subject/unit-accordion.tsx +++ b/src/components/subject/unit-accordion.tsx @@ -51,31 +51,27 @@ const UnitAccordion = ({ unit, pathname, unitIndex }: Props) => { {unit.tests ? ( unit.tests.map((test, testIndex) => ( -
- {test.name - ? test.name - // unit.tests cuz typescript doesn't recognize I checked for unit.tests already - : `Unit ${unitIndex + 1} Test ${unit.tests && unit.tests.length > 1 ? ` ${testIndex + 1}` : ""}`} -
+ {test.name + ? test.name + : // unit.tests cuz typescript doesn't recognize I checked for unit.tests already + `Unit ${unitIndex + 1} Test ${unit.tests && unit.tests.length > 1 ? ` ${testIndex + 1}` : ""}`} )) ) : // Fallback: single test flow unit.test && unit.testId ? ( -
- {unit.title === "Subject Test" - ? unit.title - : `Unit ${unitIndex + 1} Test`} -
+ {unit.title === "Subject Test" + ? unit.title + : `Unit ${unitIndex + 1} Test`} ) : null} diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx new file mode 100644 index 00000000..433d2494 --- /dev/null +++ b/src/components/ui/sonner.tsx @@ -0,0 +1,35 @@ +// Suppressing errors from sonner library. +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner } from "sonner" + +type ToasterProps = React.ComponentProps + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ) +} + +export { Toaster } From 9f140f11106c4c5eaa758dbfc9c441dec2483e53 Mon Sep 17 00:00:00 2001 From: Manning Wu Date: Fri, 14 Feb 2025 16:53:19 -0800 Subject: [PATCH 3/7] Fixed xss vulnerability and normalized different keyboard numbers for putting answers in --- package-lock.json | 137 +++++++++++++++++- package.json | 2 + src/components/article-creator/Renderer.tsx | 59 +++++++- .../QuestionsInputInterface.tsx | 15 +- .../questions/utils/normalizeAnswer.tsx | 22 +++ 5 files changed, 230 insertions(+), 5 deletions(-) create mode 100644 src/components/questions/utils/normalizeAnswer.tsx diff --git a/package-lock.json b/package-lock.json index 0295478b..4917cf9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "react-dom": "^18.3.1", "react-hook-form": "^7.52.1", "react-router-dom": "^6.26.1", + "sanitize-html": "^2.14.0", "short-uuid": "^5.2.0", "sonner": "^1.7.4", "tailwind": "^4.0.0", @@ -65,6 +66,7 @@ "@types/node": "^20.11.20", "@types/react": "^18.2.57", "@types/react-dom": "^18.2.19", + "@types/sanitize-html": "^2.13.0", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^7.1.1", "@typescript-eslint/parser": "^7.1.1", @@ -2347,6 +2349,16 @@ "form-data": "^2.5.0" } }, + "node_modules/@types/sanitize-html": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.13.0.tgz", + "integrity": "sha512-X31WxbvW9TjIhZZNyNBZ/p5ax4ti7qsNDBDEnH4zAgmEh35YnFD1UiS6z9Cd34kKm0LslFW0KPmTQzu/oGtsqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "htmlparser2": "^8.0.0" + } + }, "node_modules/@types/send": { "version": "0.17.4", "license": "MIT", @@ -3648,6 +3660,15 @@ "dev": true, "license": "MIT" }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "license": "MIT", @@ -3753,6 +3774,61 @@ "node": ">=6.0.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.4.5", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", @@ -3899,6 +3975,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.23.3", "license": "MIT", @@ -4062,7 +4150,6 @@ }, "node_modules/escape-string-regexp": { "version": "4.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -5483,6 +5570,25 @@ "license": "MIT", "optional": true }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -5853,6 +5959,15 @@ "node": ">=8" } }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-regex": { "version": "1.1.4", "license": "MIT", @@ -6910,6 +7025,12 @@ "node": ">=6" } }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", + "license": "MIT" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -7812,6 +7933,20 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sanitize-html": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.14.0.tgz", + "integrity": "sha512-CafX+IUPxZshXqqRaG9ZClSlfPVjSxI0td7n07hk8QO2oO+9JDnlcL8iM8TWeOXOIBFgIOx6zioTzM53AOMn3g==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^8.0.0", + "is-plain-object": "^5.0.0", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + } + }, "node_modules/scheduler": { "version": "0.23.2", "license": "MIT", diff --git a/package.json b/package.json index 31e3bebc..2a2440a1 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "react-dom": "^18.3.1", "react-hook-form": "^7.52.1", "react-router-dom": "^6.26.1", + "sanitize-html": "^2.14.0", "short-uuid": "^5.2.0", "sonner": "^1.7.4", "tailwind": "^4.0.0", @@ -67,6 +68,7 @@ "@types/node": "^20.11.20", "@types/react": "^18.2.57", "@types/react-dom": "^18.2.19", + "@types/sanitize-html": "^2.13.0", "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^7.1.1", "@typescript-eslint/parser": "^7.1.1", diff --git a/src/components/article-creator/Renderer.tsx b/src/components/article-creator/Renderer.tsx index 264b0d54..ad3439e2 100644 --- a/src/components/article-creator/Renderer.tsx +++ b/src/components/article-creator/Renderer.tsx @@ -8,6 +8,7 @@ import { createRoot, type Root } from "react-dom/client"; import { QuestionsOutput } from "./custom_questions/QuestionInstance"; import type { QuestionFormat } from "@/types/questions"; import "@/styles/katexStyling.css"; +import sanitizeHtml from "sanitize-html"; const customParsers = { alert: (data: { align: string; message: string; type: string }) => { @@ -107,7 +108,7 @@ const Renderer = (props: { content: OutputData }) => { // Process data.blocks only once data.forEach((block) => { if (block.type === "questionsAddCard") { - // block.data is a and since its part of editorjs, im not changing the type. + // block.data is a and since its part of editorjs, im not changing the type. // As long as editorjs doesnt depricate in a way that affects this, then this should be fine /* eslint-disable-next-line */ const instanceId = block.data.instanceId as string; @@ -204,12 +205,66 @@ const Renderer = (props: { content: OutputData }) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument const markup = parser.parse(props.content); + // XSS Prevention + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment + const sanitizedMarkup = sanitizeHtml(markup, { + allowedTags: [ + "p", + "div", + "span", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "ul", + "ol", + "li", + "a", + "img", + "code", + "pre", + "blockquote", + "table", + "thead", + "tbody", + "tr", + "th", + "td", + "hr", + "br", + "cite", + "figure", + "figcaption", + "iframe", + "math", + ], + allowedAttributes: { + "*": ["class", "id", "style"], + a: ["href", "target", "rel"], + img: ["src", "alt", "class"], + iframe: [ + "src", + "height", + "width", + "frameborder", + "allowtransparency", + "scrolling", + ], + }, + allowedClasses: { + "*": [/^.*$/], // Might want to restrict this later (if there are any xss attacks via css) + }, + }); + return (
); diff --git a/src/components/article-creator/custom_questions/QuestionsInputInterface.tsx b/src/components/article-creator/custom_questions/QuestionsInputInterface.tsx index 583e814a..22892603 100644 --- a/src/components/article-creator/custom_questions/QuestionsInputInterface.tsx +++ b/src/components/article-creator/custom_questions/QuestionsInputInterface.tsx @@ -140,17 +140,28 @@ const QuestionsInputInterface: React.FC = ({ setQuestions(newQuestions); }; + // Filters out different keyboard numbers + const normalizeAnswer = (answer: string): string => { + // Normalize the string and remove any special characters or different encodings + return answer + .normalize("NFKD") // Normalize to decomposed form + .replace(/[\u0300-\u036f]/g, "") // Remove diacritics + .replace(/[^\d,]/g, ""); // Keep only digits and commas + }; + const validateCorrectAnswer = ( value: string, type: "mcq" | "multi-answer", ) => { + const normalizedValue = normalizeAnswer(value); let errorMessage = ""; + if (type === "mcq") { - if (!/^\d$/.test(value)) { + if (!/^\d$/.test(normalizedValue)) { errorMessage = "Only a single number is allowed for MCQ. (eg 1)"; } } else { - if (!/^\d(,\d){0,7}$/.test(value) || value.length > 8) { + if (!/^\d(,\d){0,7}$/.test(normalizedValue) || normalizedValue.length > 8) { errorMessage = "Only numbers separated by commas are allowed, max correct questions is 4. (eg 1,2,4)"; } diff --git a/src/components/questions/utils/normalizeAnswer.tsx b/src/components/questions/utils/normalizeAnswer.tsx new file mode 100644 index 00000000..f0951256 --- /dev/null +++ b/src/components/questions/utils/normalizeAnswer.tsx @@ -0,0 +1,22 @@ +export const normalizeAnswer = (input: string | number): string => { + // Convert to string if number + const str = input.toString(); + + // Normalize the string to remove any special characters or different encodings + const normalized = str + .normalize("NFKD") // Normalize to decomposed form + .replace(/[\u0300-\u036f]/g, "") // Remove diacritics + .replace(/[^\d,]/g, ""); // Keep only digits and commas + + // Sort numbers for consistent comparison if multiple answers + if (normalized.includes(",")) { + return normalized + .split(",") + .map((n) => parseInt(n, 10)) + .filter((n) => !isNaN(n)) // Filter out invalid numbers + .sort((a, b) => a - b) + .join(","); + } + + return normalized; +}; From 350b9b370fe6611433ba509c21324026ac358be1 Mon Sep 17 00:00:00 2001 From: Manning Wu Date: Sat, 15 Feb 2025 12:50:47 -0800 Subject: [PATCH 4/7] Fixed build errors --- src/app/admin/subject/[slug]/_components/unitTests.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/app/admin/subject/[slug]/_components/unitTests.tsx b/src/app/admin/subject/[slug]/_components/unitTests.tsx index 8c02421b..c03603a4 100644 --- a/src/app/admin/subject/[slug]/_components/unitTests.tsx +++ b/src/app/admin/subject/[slug]/_components/unitTests.tsx @@ -1,10 +1,6 @@ "use client"; import React, { useState, memo } from "react"; -<<<<<<< HEAD -======= -import { Link } from "../../link"; ->>>>>>> 1cb89ce97c22a9c51a34471ddd66f31d23fca231 import type { UnitTest } from "@/types/firestore"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; From 50bbdcca8792f9ce134bccb6d5e1c10172ba502a Mon Sep 17 00:00:00 2001 From: BrilliantDeviation7 <54754524+BrilliantDeviation7@users.noreply.github.com> Date: Sun, 23 Mar 2025 15:53:31 -0400 Subject: [PATCH 5/7] Remove comments suppressing sonner.tsx errors --- src/components/ui/sonner.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx index 288750e5..452f4d9f 100644 --- a/src/components/ui/sonner.tsx +++ b/src/components/ui/sonner.tsx @@ -1,6 +1,3 @@ -// Suppressing errors from sonner library. -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ "use client" import { useTheme } from "next-themes" From e8d7da45749559ca5504790aa878005796eaa895 Mon Sep 17 00:00:00 2001 From: BrilliantDeviation7 <54754524+BrilliantDeviation7@users.noreply.github.com> Date: Sun, 23 Mar 2025 15:55:09 -0400 Subject: [PATCH 6/7] Update unitTests.tsx --- src/app/admin/subject/[slug]/_components/unitTests.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/admin/subject/[slug]/_components/unitTests.tsx b/src/app/admin/subject/[slug]/_components/unitTests.tsx index c03603a4..7f69d31e 100644 --- a/src/app/admin/subject/[slug]/_components/unitTests.tsx +++ b/src/app/admin/subject/[slug]/_components/unitTests.tsx @@ -1,11 +1,11 @@ "use client"; import React, { useState, memo } from "react"; +import { Link } from "../../link"; import type { UnitTest } from "@/types/firestore"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Trash, PlusCircle } from "lucide-react"; -import { Link } from "../../link"; interface UnitTestsProps { unitId: string; From 45489fa17d1ec2b95eaf5e10bf92b373da7062c0 Mon Sep 17 00:00:00 2001 From: BrilliantDeviation7 <54754524+BrilliantDeviation7@users.noreply.github.com> Date: Thu, 27 Mar 2025 18:01:35 -0400 Subject: [PATCH 7/7] Update package.json --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 2a2440a1..2372ea8b 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "build": "next build", "dev": "next dev", "lint": "next lint", - "start": "next start" + "start": "next start", + "emulate": "firebase emulators:start --import emulator --export-on-exit" }, "dependencies": { "@editorjs/attaches": "^1.3.0",