diff --git a/globalSettings.yml b/globalSettings.yml index 1553301..99e25c9 100644 --- a/globalSettings.yml +++ b/globalSettings.yml @@ -1,29 +1,23 @@ courses: - - path: ./4850_AdvancedFE/2025-fall-alex/modules/ - name: Adv Frontend - path: ./1420/2025-fall-alex/modules/ name: "1420" - - path: ./1810/2025-fall-alex/modules/ - name: Web Intro - - path: ./1430/2025-fall-alex/modules/ - name: UX - path: ./1425/2025-fall-alex/modules/ name: "1425" - path: ./4850_AdvancedFE/2026-spring-alex/modules - name: Adv Frontend Spring + name: Adv Frontend - path: ./1400/2026_spring_alex/modules name: "1400" - path: ./1405/2026_spring_alex name: "1405" - - path: ./1810/2026-spring-alex/modules - name: Web Intro Spring - path: ./3840_Telemetry/2026_spring_alex - name: Telem and Ops New + name: Telem and Ops - path: ./4620_Distributed/2026-spring-alex/modules name: Distributed - path: ./4620_Distributed/2025Spring/modules/ name: distributed-old - path: ./3840_Telemetry/2025_spring_alex/modules/ name: telemetry-old - - path: ./3840_Telemetry/2024Spring_alex/modules/ - name: telemetry-old-old + - path: ./4850_AdvancedFE/2025-fall-alex/modules/ + name: adv-frontend-old + - path: ./1810/2026-spring-alex/modules/ + name: Web Intro diff --git a/src/app/course/[courseName]/CollapsableSidebar.tsx b/src/app/course/[courseName]/CollapsableSidebar.tsx index b904747..67572e1 100644 --- a/src/app/course/[courseName]/CollapsableSidebar.tsx +++ b/src/app/course/[courseName]/CollapsableSidebar.tsx @@ -10,12 +10,15 @@ const collapseThreshold = 1400; export default function CollapsableSidebar() { const [windowCollapseRecommended, setWindowCollapseRecommended] = - useState(window.innerWidth <= collapseThreshold); + useState(false); const [userCollapsed, setUserCollapsed] = useState< "unset" | "collapsed" | "uncollapsed" >("unset"); useEffect(() => { + // Initialize on mount + setWindowCollapseRecommended(window.innerWidth <= collapseThreshold); + function handleResize() { if (window.innerWidth <= collapseThreshold) { setWindowCollapseRecommended(true); diff --git a/src/app/course/[courseName]/CourseNavigation.tsx b/src/app/course/[courseName]/CourseNavigation.tsx index 443224c..cedd7ab 100644 --- a/src/app/course/[courseName]/CourseNavigation.tsx +++ b/src/app/course/[courseName]/CourseNavigation.tsx @@ -1,4 +1,5 @@ "use client"; +import { BreadCrumbs } from "@/components/BreadCrumbs"; import { Spinner } from "@/components/Spinner"; import { useCanvasAssignmentsQuery, @@ -19,7 +20,6 @@ import { } from "@/features/canvas/hooks/canvasQuizHooks"; import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks"; import { useQueryClient } from "@tanstack/react-query"; -import Link from "next/link"; export function CourseNavigation() { const { data: settings } = useLocalCourseSettingsQuery(); @@ -33,9 +33,8 @@ export function CourseNavigation() { return (
- - Back to Course List - + + (null); - const [tooltipVisible, setTooltipVisible] = useState(false); + const { visible, targetRef, showTooltip, hideTooltip } = useTooltip(); const lectureName = todaysLecture && (todaysLecture.name || "lecture"); @@ -44,9 +43,9 @@ export function DayTitle({ day, dayAsDate }: { day: string; dayAsDate: Date }) { setIsDragging(true); } }} - ref={linkRef} - onMouseEnter={() => setTooltipVisible(true)} - onMouseLeave={() => setTooltipVisible(false)} + ref={targetRef} + onMouseEnter={showTooltip} + onMouseLeave={hideTooltip} > {dayAsDate.getDate()} {lectureName} @@ -65,8 +64,8 @@ export function DayTitle({ day, dayAsDate }: { day: string; dayAsDate: Date }) { )}
} - targetRef={linkRef} - visible={tooltipVisible} + targetRef={targetRef} + visible={visible} /> )} @@ -81,19 +80,19 @@ export function DayTitle({ day, dayAsDate }: { day: string; dayAsDate: Date }) { xmlns="http://www.w3.org/2000/svg" onClick={openModal} > - + diff --git a/src/app/course/[courseName]/calendar/day/ItemInDay.tsx b/src/app/course/[courseName]/calendar/day/ItemInDay.tsx index b6f1cbc..e7987e8 100644 --- a/src/app/course/[courseName]/calendar/day/ItemInDay.tsx +++ b/src/app/course/[courseName]/calendar/day/ItemInDay.tsx @@ -1,13 +1,48 @@ import { IModuleItem } from "@/features/local/modules/IModuleItem"; import { getModuleItemUrl } from "@/services/urlUtils"; import Link from "next/link"; -import { ReactNode, useRef, useState } from "react"; +import { ReactNode } from "react"; import { useCourseContext } from "../../context/courseContext"; +import { useTooltip } from "@/components/useTooltip"; +import MarkdownDisplay from "@/components/MarkdownDisplay"; import { DraggableItem } from "../../context/drag/draggingContext"; import ClientOnly from "@/components/ClientOnly"; import { useDragStyleContext } from "../../context/drag/dragStyleContext"; import { Tooltip } from "../../../../../components/Tooltip"; +function getPreviewContent( + type: "assignment" | "page" | "quiz", + item: IModuleItem +): ReactNode { + if (type === "assignment" && "description" in item) { + const assignment = item as { + description: string; + githubClassroomAssignmentShareLink?: string; + }; + return ( + + ); + } else if (type === "page" && "text" in item) { + return ; + } else if (type === "quiz" && "questions" in item) { + const quiz = item as { questions: { text: string }[] }; + return quiz.questions.map((q, i: number) => ( +
+ +
+ )); + } + return null; +} + export function ItemInDay({ type, moduleName, @@ -23,8 +58,8 @@ export function ItemInDay({ }) { const { courseName } = useCourseContext(); const { setIsDragging } = useDragStyleContext(); - const linkRef = useRef(null); - const [tooltipVisible, setTooltipVisible] = useState(false); + const { visible, targetRef, showTooltip, hideTooltip } = useTooltip(500); + return (
setTooltipVisible(true)} - onMouseLeave={() => setTooltipVisible(false)} - ref={linkRef} + onMouseEnter={showTooltip} + onMouseLeave={hideTooltip} + ref={targetRef} > {item.name} - + {status === "published" ? ( + getPreviewContent(type, item) && ( + {getPreviewContent(type, item)}
+ } + targetRef={targetRef} + visible={visible} + /> + ) + ) : ( + + )} ); diff --git a/src/app/course/[courseName]/lecture/[lectureDay]/EditLectureTitle.tsx b/src/app/course/[courseName]/lecture/[lectureDay]/EditLectureTitle.tsx index 5c4bff6..6ec18ca 100644 --- a/src/app/course/[courseName]/lecture/[lectureDay]/EditLectureTitle.tsx +++ b/src/app/course/[courseName]/lecture/[lectureDay]/EditLectureTitle.tsx @@ -1,10 +1,11 @@ import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks"; import { getDateFromString } from "@/features/local/utils/timeUtils"; import { getLectureWeekName } from "@/features/local/lectures/lectureUtils"; -import { getCourseUrl, getLecturePreviewUrl } from "@/services/urlUtils"; +import { getLecturePreviewUrl } from "@/services/urlUtils"; import { useCourseContext } from "../../context/courseContext"; import Link from "next/link"; import { getDayOfWeek } from "@/features/local/course/localCourseSettings"; +import { BreadCrumbs } from "@/components/BreadCrumbs"; export default function EditLectureTitle({ lectureDay, @@ -17,16 +18,7 @@ export default function EditLectureTitle({ const lectureWeekName = getLectureWeekName(settings.startDate, lectureDay); return (
-
- - {courseName} - -
+

Lecture

diff --git a/src/app/course/[courseName]/lecture/[lectureDay]/preview/LecturePreviewPage.tsx b/src/app/course/[courseName]/lecture/[lectureDay]/preview/LecturePreviewPage.tsx index d70f792..115c239 100644 --- a/src/app/course/[courseName]/lecture/[lectureDay]/preview/LecturePreviewPage.tsx +++ b/src/app/course/[courseName]/lecture/[lectureDay]/preview/LecturePreviewPage.tsx @@ -1,17 +1,14 @@ "use client"; import LecturePreview from "../LecturePreview"; -import { getCourseUrl, getLectureUrl } from "@/services/urlUtils"; -import { useCourseContext } from "../../../context/courseContext"; -import Link from "next/link"; import { useLecturesSuspenseQuery } from "@/features/local/lectures/lectureHooks"; +import { BreadCrumbs } from "@/components/BreadCrumbs"; export default function LecturePreviewPage({ lectureDay, }: { lectureDay: string; }) { - const { courseName } = useCourseContext(); const { data: weeks } = useLecturesSuspenseQuery(); const lecture = weeks .flatMap(({ lectures }) => lectures.map((lecture) => lecture)) @@ -23,20 +20,7 @@ export default function LecturePreviewPage({ return (
-
- - Edit Lecture - -
-
- - Course Calendar - -
+
{ - // Check for invalid file system characters - const invalidChars = [":", "/", "\\", "*", '"', "<", ">", "|"]; - - for (const char of fileName) { - if (invalidChars.includes(char)) { - return `Name contains invalid character: "${char}". Please avoid: ${invalidChars.join( - " " - )}`; - } - } - return ""; - }; - const handleNameChange = (newName: string) => { setName(newName); const error = validateFileName(newName); diff --git a/src/app/course/[courseName]/modules/[moduleName]/assignment/[assignmentName]/EditAssignmentHeader.tsx b/src/app/course/[courseName]/modules/[moduleName]/assignment/[assignmentName]/EditAssignmentHeader.tsx index bf871ef..8e27a8d 100644 --- a/src/app/course/[courseName]/modules/[moduleName]/assignment/[assignmentName]/EditAssignmentHeader.tsx +++ b/src/app/course/[courseName]/modules/[moduleName]/assignment/[assignmentName]/EditAssignmentHeader.tsx @@ -1,7 +1,6 @@ -import { useCourseContext } from "@/app/course/[courseName]/context/courseContext"; +import { BreadCrumbs } from "@/components/BreadCrumbs"; import { UpdateAssignmentName } from "./UpdateAssignmentName"; -import { getCourseUrl } from "@/services/urlUtils"; -import Link from "next/link"; +import { RightSingleChevron } from "@/components/icons/RightSingleChevron"; export default function EditAssignmentHeader({ moduleName, @@ -10,22 +9,21 @@ export default function EditAssignmentHeader({ assignmentName: string; moduleName: string; }) { - const { courseName } = useCourseContext(); return ( -
- - {courseName} - - -
{assignmentName}
+
+
+ + + + +
{assignmentName}
+
+
+ +
); } diff --git a/src/app/course/[courseName]/modules/[moduleName]/assignment/[assignmentName]/UpdateAssignmentName.tsx b/src/app/course/[courseName]/modules/[moduleName]/assignment/[assignmentName]/UpdateAssignmentName.tsx index 055c9ad..c33e744 100644 --- a/src/app/course/[courseName]/modules/[moduleName]/assignment/[assignmentName]/UpdateAssignmentName.tsx +++ b/src/app/course/[courseName]/modules/[moduleName]/assignment/[assignmentName]/UpdateAssignmentName.tsx @@ -40,8 +40,7 @@ export function UpdateAssignmentName({ if (name === assignmentName) closeModal(); setIsLoading(true); // page refresh resets flag - try{ - + try { await updateAssignment.mutateAsync({ assignment: assignment, moduleName, @@ -50,17 +49,28 @@ export function UpdateAssignmentName({ previousAssignmentName: assignmentName, courseName, }); - + // update url (will trigger reload...) router.replace( getModuleItemUrl(courseName, moduleName, "assignment", name), {} ); - }finally { + } finally { setIsLoading(false); } }} > +
+ Warning: does not rename in Canvas +
} Body={ -
-
+
+
-
+
{error && error}
-
+

diff --git a/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPageHeader.tsx b/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPageHeader.tsx index 8d91f7e..ce03b56 100644 --- a/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPageHeader.tsx +++ b/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPageHeader.tsx @@ -1,7 +1,6 @@ -import { useCourseContext } from "@/app/course/[courseName]/context/courseContext"; -import { getCourseUrl } from "@/services/urlUtils"; -import Link from "next/link"; import { UpdatePageName } from "./UpdatePageName"; +import { BreadCrumbs } from "@/components/BreadCrumbs"; +import { RightSingleChevron } from "@/components/icons/RightSingleChevron"; export default function EditPageHeader({ moduleName, @@ -10,19 +9,18 @@ export default function EditPageHeader({ pageName: string; moduleName: string; }) { - const { courseName } = useCourseContext(); return ( -
- - {courseName} - - -
{pageName}
+
+
+ + + + +
{pageName}
+
+
+ +
); } diff --git a/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/UpdatePageName.tsx b/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/UpdatePageName.tsx index cce8cd0..268c0c3 100644 --- a/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/UpdatePageName.tsx +++ b/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/UpdatePageName.tsx @@ -56,6 +56,17 @@ export function UpdatePageName({ ); }} > +
+ Warning: does not rename in Canvas +
{isLoading && } diff --git a/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/EditQuizHeader.tsx b/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/EditQuizHeader.tsx index 612814f..db31cb2 100644 --- a/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/EditQuizHeader.tsx +++ b/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/EditQuizHeader.tsx @@ -1,7 +1,6 @@ -import { useCourseContext } from "@/app/course/[courseName]/context/courseContext"; -import { getCourseUrl } from "@/services/urlUtils"; -import Link from "next/link"; +import { RightSingleChevron } from "@/components/icons/RightSingleChevron"; import { UpdateQuizName } from "./UpdateQuizName"; +import { BreadCrumbs } from "@/components/BreadCrumbs"; export default function EditQuizHeader({ moduleName, @@ -10,19 +9,18 @@ export default function EditQuizHeader({ quizName: string; moduleName: string; }) { - const { courseName } = useCourseContext(); return ( -
- - {courseName} - - -
{quizName}
+
+
+ + + + +
{quizName}
+
+
+ +
); } diff --git a/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/UpdateQuizName.tsx b/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/UpdateQuizName.tsx index f1968a0..5af26a7 100644 --- a/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/UpdateQuizName.tsx +++ b/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/UpdateQuizName.tsx @@ -56,6 +56,17 @@ export function UpdateQuizName({ ); }} > +
+ Warning: does not rename in Canvas +
{isLoading && } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 043dd9f..8b47eae 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -20,7 +20,7 @@ export default async function RootLayout({ return ( - +
@@ -29,7 +29,7 @@ export default async function RootLayout({ {children} - +
diff --git a/src/components/BreadCrumbs.tsx b/src/components/BreadCrumbs.tsx new file mode 100644 index 0000000..2d1edca --- /dev/null +++ b/src/components/BreadCrumbs.tsx @@ -0,0 +1,106 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import HomeIcon from "./icons/HomeIcon"; +import { RightSingleChevron } from "./icons/RightSingleChevron"; + +export const BreadCrumbs = () => { + const pathname = usePathname(); + + const pathSegments = pathname?.split("/").filter(Boolean) || []; + const isCourseRoute = pathSegments[0] === "course"; + + const courseName = + isCourseRoute && pathSegments[1] + ? decodeURIComponent(pathSegments[1]) + : null; + + const isLectureRoute = isCourseRoute && pathSegments[2] === "lecture"; + const lectureDate = + isLectureRoute && pathSegments[3] + ? decodeURIComponent(pathSegments[3]) + : null; + + const lectureDateOnly = lectureDate + ? (() => { + const dateStr = lectureDate.split(" ")[0]; + const date = new Date(dateStr); + const month = date.toLocaleDateString("en-US", { month: "short" }); + const day = date.getDate(); + return `${month} ${day}`; + })() + : null; + + const sharedBackgroundClassNames = ` + group + hover:bg-blue-900/30 + rounded-lg + h-full + flex + items-center + transition + `; + const sharedLinkClassNames = ` + text-slate-300 + transition + group-hover:text-slate-100 + rounded-lg + h-full + flex + items-center + px-3 + `; + + return ( + + ); +}; diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 3306ec1..a2fbcb6 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -50,18 +50,14 @@ export default function Modal({
{ - // e.preventDefault(); e.stopPropagation(); }} className={ diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx index 2a8a3c5..6d09044 100644 --- a/src/components/Tooltip.tsx +++ b/src/components/Tooltip.tsx @@ -18,10 +18,10 @@ export const Tooltip: React.FC<{ " absolute -translate-x-1/2 " + " bg-gray-900 text-slate-200 text-sm " + " rounded-md py-1 px-2 " + - " transition-all duration-400 " + + " transition-opacity duration-150 " + " border border-slate-700 shadow-[0px_0px_10px_5px] shadow-slate-500/20 " + " max-w-sm max-h-64 overflow-hidden " + - (visible ? " " : " hidden -z-50 ") + (visible ? " opacity-100 " : " opacity-0 pointer-events-none hidden ") } role="tooltip" > diff --git a/src/components/editor/MonacoEditor.css b/src/components/editor/MonacoEditor.css index 6252a83..7c578c9 100644 --- a/src/components/editor/MonacoEditor.css +++ b/src/components/editor/MonacoEditor.css @@ -9,6 +9,9 @@ background-color: #030712 !important; /* background-color: #101828 !important; */ } +.sticky-widget { + background-color: #0C0F17 !important; +} .monaco-editor { position: absolute !important; } diff --git a/src/components/icons/ExpandIcon.tsx b/src/components/icons/ExpandIcon.tsx index 6a3f10e..0ae03b8 100644 --- a/src/components/icons/ExpandIcon.tsx +++ b/src/components/icons/ExpandIcon.tsx @@ -16,19 +16,19 @@ export default function ExpandIcon({ fill="none" xmlns="http://www.w3.org/2000/svg" > - + diff --git a/src/components/icons/HomeIcon.tsx b/src/components/icons/HomeIcon.tsx new file mode 100644 index 0000000..6c59799 --- /dev/null +++ b/src/components/icons/HomeIcon.tsx @@ -0,0 +1,23 @@ +import React from "react"; + +// https://www.svgrepo.com/collection/wolf-kit-solid-glyph-icons/?search=home +export default function HomeIcon() { + return ( + + + + + + + + ); +} diff --git a/src/components/icons/RightSingleChevron.tsx b/src/components/icons/RightSingleChevron.tsx new file mode 100644 index 0000000..4d4f8ea --- /dev/null +++ b/src/components/icons/RightSingleChevron.tsx @@ -0,0 +1,27 @@ +import React from "react"; + +// https://www.svgrepo.com/svg/491374/chevron-small-right +export const RightSingleChevron = () => { + return ( + + + + + + + + ); +}; diff --git a/src/components/realtime/ClientCacheInvalidation.tsx b/src/components/realtime/ClientCacheInvalidation.tsx index dfbde2b..eef5b28 100644 --- a/src/components/realtime/ClientCacheInvalidation.tsx +++ b/src/components/realtime/ClientCacheInvalidation.tsx @@ -4,6 +4,8 @@ import { useTRPC } from "@/services/serverFunctions/trpcClient"; import React, { useCallback, useEffect, useState } from "react"; import { io, Socket } from "socket.io-client"; import { useQueryClient } from "@tanstack/react-query"; +import { useGlobalSettingsQuery } from "@/features/local/globalSettings/globalSettingsHooks"; +import { GlobalSettings } from "@/features/local/globalSettings/globalSettingsModels"; interface ServerToClientEvents { message: (data: string) => void; @@ -24,6 +26,22 @@ function removeFileExtension(fileName: string): string { return fileName; } +function getCourseNameByPath( + filePath: string, + settings: GlobalSettings +) { + const courseSettings = settings.courses.find((c) => { + const normalizedFilePath = filePath.startsWith("./") + ? filePath.substring(2) + : filePath; + const normalizedCoursePath = c.path.startsWith("./") + ? c.path.substring(2) + : c.path; + return normalizedFilePath.startsWith(normalizedCoursePath); + }); + return courseSettings?.name; +} + export function ClientCacheInvalidation() { const invalidateCache = useFilePathInvalidation(); const [connectionAttempted, setConnectionAttempted] = useState(false); @@ -62,13 +80,32 @@ export function ClientCacheInvalidation() { const useFilePathInvalidation = () => { const trpc = useTRPC(); const queryClient = useQueryClient(); + const { data: settings } = useGlobalSettingsQuery(); + return useCallback( (filePath: string) => { - const [courseName, moduleOrLectures, itemType, itemFile] = - filePath.split("/"); + const courseName = getCourseNameByPath(filePath, settings); + // console.log(filePath, settings, courseName); + if (!courseName) { + console.log( + "no course settings found for file path, not invalidating cache", + filePath + ); + return; + } + + const splitPath = filePath.split("/"); + const [moduleOrLectures, itemType, itemFile] = splitPath.slice(-3); const itemName = itemFile ? removeFileExtension(itemFile) : undefined; - const allParts = [courseName, moduleOrLectures, itemType, itemName]; + const allParts = { courseName, moduleOrLectures, itemType, itemName }; + // console.log( + // "received file to invalidate", + // filePath, + // allParts, + // itemName, + // itemType + // ); if (moduleOrLectures === "settings.yml") { queryClient.invalidateQueries({ @@ -141,6 +178,8 @@ const useFilePathInvalidation = () => { }); return; } + + console.log("no cache invalidation match for file ", allParts); }, [ queryClient, @@ -153,6 +192,7 @@ const useFilePathInvalidation = () => { trpc.quiz.getQuiz, trpc.settings.allCoursesSettings, trpc.settings.courseSettings, + settings, ] ); }; diff --git a/src/components/useTooltip.ts b/src/components/useTooltip.ts new file mode 100644 index 0000000..78cfbf2 --- /dev/null +++ b/src/components/useTooltip.ts @@ -0,0 +1,28 @@ +import { useState, useRef, useCallback } from "react"; + +export const useTooltip = (delayMs: number = 150) => { + const [visible, setVisible] = useState(false); + const timeoutRef = useRef(null); + const targetRef = useRef(null); + + const showTooltip = useCallback(() => { + timeoutRef.current = setTimeout(() => { + setVisible(true); + }, delayMs); + }, [delayMs]); + + const hideTooltip = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + setVisible(false); + }, []); + + return { + visible, + targetRef, + showTooltip, + hideTooltip, + }; +}; diff --git a/src/features/local/assignments/assignmentRouter.ts b/src/features/local/assignments/assignmentRouter.ts index 8bdf173..ef12292 100644 --- a/src/features/local/assignments/assignmentRouter.ts +++ b/src/features/local/assignments/assignmentRouter.ts @@ -10,6 +10,7 @@ import { getCoursePathByName } from "../globalSettings/globalSettingsFileStorage import { promises as fs } from "fs"; import { courseItemFileStorageService } from "../course/courseItemFileStorageService"; import { assignmentMarkdownSerializer } from "./models/utils/assignmentMarkdownSerializer"; +import { assertValidFileName } from "@/services/fileNameValidation"; export const assignmentRouter = router({ getAssignment: publicProcedure @@ -133,17 +134,7 @@ export async function updateOrCreateAssignmentFile({ assignmentName: string; assignment: LocalAssignment; }) { - const illegalCharacters = ["<", ">", ":", '"', "/", "\\", "|", "?", "*"]; - const foundIllegalCharacters = illegalCharacters.filter((char) => - assignmentName.includes(char) - ); - if (foundIllegalCharacters.length > 0) { - throw new Error( - `"${assignmentName}" cannot contain the following characters: ${foundIllegalCharacters.join( - " " - )}` - ); - } + assertValidFileName(assignmentName); const courseDirectory = await getCoursePathByName(courseName); const folder = path.join(courseDirectory, moduleName, "assignments"); diff --git a/src/features/local/pages/pageRouter.ts b/src/features/local/pages/pageRouter.ts index 0290a01..a97d968 100644 --- a/src/features/local/pages/pageRouter.ts +++ b/src/features/local/pages/pageRouter.ts @@ -1,11 +1,16 @@ import publicProcedure from "../../../services/serverFunctions/publicProcedure"; import { z } from "zod"; import { router } from "../../../services/serverFunctions/trpcSetup"; -import { LocalCoursePage, localPageMarkdownUtils, zodLocalCoursePage } from "@/features/local/pages/localCoursePageModels"; +import { + LocalCoursePage, + localPageMarkdownUtils, + zodLocalCoursePage, +} from "@/features/local/pages/localCoursePageModels"; import { courseItemFileStorageService } from "../course/courseItemFileStorageService"; import { promises as fs } from "fs"; import path from "path"; import { getCoursePathByName } from "../globalSettings/globalSettingsFileStorageService"; +import { assertValidFileName } from "@/services/fileNameValidation"; export const pageRouter = router({ getPage: publicProcedure @@ -115,31 +120,32 @@ export const pageRouter = router({ }); export async function updatePageFile({ - courseName, + courseName, + moduleName, + pageName, + page, +}: { + courseName: string; + moduleName: string; + pageName: string; + page: LocalCoursePage; +}) { + assertValidFileName(pageName); + const courseDirectory = await getCoursePathByName(courseName); + const folder = path.join(courseDirectory, moduleName, "pages"); + await fs.mkdir(folder, { recursive: true }); + + const filePath = path.join( + courseDirectory, moduleName, - pageName, - page, - }: { - courseName: string; - moduleName: string; - pageName: string; - page: LocalCoursePage; - }) { - const courseDirectory = await getCoursePathByName(courseName); - const folder = path.join(courseDirectory, moduleName, "pages"); - await fs.mkdir(folder, { recursive: true }); + "pages", + pageName + ".md" + ); - const filePath = path.join( - courseDirectory, - moduleName, - "pages", - pageName + ".md" - ); - - const pageMarkdown = localPageMarkdownUtils.toMarkdown(page); - console.log(`Saving page ${filePath}`); - await fs.writeFile(filePath, pageMarkdown); - } + const pageMarkdown = localPageMarkdownUtils.toMarkdown(page); + console.log(`Saving page ${filePath}`); + await fs.writeFile(filePath, pageMarkdown); +} async function deletePageFile({ courseName, moduleName, diff --git a/src/features/local/quizzes/quizRouter.ts b/src/features/local/quizzes/quizRouter.ts index a33e010..120013f 100644 --- a/src/features/local/quizzes/quizRouter.ts +++ b/src/features/local/quizzes/quizRouter.ts @@ -14,6 +14,7 @@ import { promises as fs } from "fs"; import { quizMarkdownUtils } from "./models/utils/quizMarkdownUtils"; import { courseItemFileStorageService } from "../course/courseItemFileStorageService"; import { getFeedbackDelimitersFromSettings } from "../globalSettings/globalSettingsUtils"; +import { assertValidFileName } from "@/services/fileNameValidation"; export const quizRouter = router({ getQuiz: publicProcedure @@ -153,6 +154,7 @@ export async function updateQuizFile({ quizName: string; quiz: LocalQuiz; }) { + assertValidFileName(quizName); const courseDirectory = await getCoursePathByName(courseName); const folder = path.join(courseDirectory, moduleName, "quizzes"); await fs.mkdir(folder, { recursive: true }); diff --git a/src/services/fileNameValidation.ts b/src/services/fileNameValidation.ts new file mode 100644 index 0000000..b3068a5 --- /dev/null +++ b/src/services/fileNameValidation.ts @@ -0,0 +1,28 @@ +export function validateFileName(fileName: string): string { + if (!fileName || fileName.trim() === "") { + return "Name cannot be empty"; + } + + const invalidChars = [":", "/", "\\", "*", "?", '"', "<", ">", "|"]; + + for (const char of fileName) { + if (invalidChars.includes(char)) { + return `Name contains invalid character: "${char}". Please avoid: ${invalidChars.join( + " " + )}`; + } + } + + if (fileName !== fileName.trimEnd()) { + return "Name cannot end with whitespace"; + } + + return ""; +} + +export function assertValidFileName(fileName: string): void { + const error = validateFileName(fileName); + if (error) { + throw new Error(error); + } +}