From 51f2be1988af33ca7cf7a80e655b88ec8a4d4815 Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Mon, 15 Dec 2025 09:18:06 -0700 Subject: [PATCH 01/10] fixed server side file editing --- .../realtime/ClientCacheInvalidation.tsx | 46 +++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) 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, ] ); }; From 20b14da1804561b85d28245c919159ea8019ca78 Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Mon, 22 Dec 2025 11:02:05 -0700 Subject: [PATCH 02/10] updating monaco style --- src/components/editor/MonacoEditor.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/editor/MonacoEditor.css b/src/components/editor/MonacoEditor.css index 6252a83..4b688f0 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: #111624 !important; +} .monaco-editor { position: absolute !important; } From 076c0b1025747ed81652185d06e4725327e7b040 Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Mon, 22 Dec 2025 11:02:24 -0700 Subject: [PATCH 03/10] more subtle --- src/components/editor/MonacoEditor.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/editor/MonacoEditor.css b/src/components/editor/MonacoEditor.css index 4b688f0..7c578c9 100644 --- a/src/components/editor/MonacoEditor.css +++ b/src/components/editor/MonacoEditor.css @@ -10,7 +10,7 @@ /* background-color: #101828 !important; */ } .sticky-widget { - background-color: #111624 !important; + background-color: #0C0F17 !important; } .monaco-editor { position: absolute !important; From 8c01cb2422f038e6844c5de2d15f4cb92bfde132 Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Mon, 5 Jan 2026 10:22:12 -0700 Subject: [PATCH 04/10] adding breadcrumbs --- globalSettings.yml | 8 -- .../[courseName]/CollapsableSidebar.tsx | 5 +- .../course/[courseName]/CourseNavigation.tsx | 7 +- .../[courseName]/calendar/day/DayTitle.tsx | 12 +-- .../lecture/[lectureDay]/EditLectureTitle.tsx | 14 +-- .../preview/LecturePreviewPage.tsx | 20 +--- .../[assignmentName]/EditAssignmentHeader.tsx | 34 +++---- .../[assignmentName]/UpdateAssignmentName.tsx | 18 +++- .../page/[pageName]/EditPageHeader.tsx | 28 +++--- .../page/[pageName]/UpdatePageName.tsx | 11 +++ .../quiz/[quizName]/EditQuizHeader.tsx | 28 +++--- .../quiz/[quizName]/UpdateQuizName.tsx | 11 +++ src/app/layout.tsx | 4 +- src/components/BreadCrumbs.tsx | 98 +++++++++++++++++++ src/components/icons/ExpandIcon.tsx | 12 +-- src/components/icons/HomeIcon.tsx | 23 +++++ src/components/icons/RightSingleChevron.tsx | 27 +++++ 17 files changed, 252 insertions(+), 108 deletions(-) create mode 100644 src/components/BreadCrumbs.tsx create mode 100644 src/components/icons/HomeIcon.tsx create mode 100644 src/components/icons/RightSingleChevron.tsx diff --git a/globalSettings.yml b/globalSettings.yml index 1553301..d0372cc 100644 --- a/globalSettings.yml +++ b/globalSettings.yml @@ -1,12 +1,6 @@ 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 @@ -25,5 +19,3 @@ courses: name: distributed-old - path: ./3840_Telemetry/2025_spring_alex/modules/ name: telemetry-old - - path: ./3840_Telemetry/2024Spring_alex/modules/ - name: telemetry-old-old 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 - + + - + 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 - -
+
- - {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 +
- - {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..1793b71 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..566f38e --- /dev/null +++ b/src/components/BreadCrumbs.tsx @@ -0,0 +1,98 @@ +"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 isOnCoursePage = isCourseRoute && pathSegments.length === 2; + + const courseName = + isCourseRoute && !isOnCoursePage && pathSegments[1] + ? decodeURIComponent(pathSegments[1]) + : null; + + const isLectureRoute = isCourseRoute && pathSegments[2] === "lecture"; + const isOnLecturePage = isLectureRoute && pathSegments.length === 4; + const lectureDate = + isLectureRoute && !isOnLecturePage && pathSegments[3] + ? decodeURIComponent(pathSegments[3]) + : 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/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 ( + + + + + + + + ); +}; From 767528560c360924426d02e264d66e38b67faf35 Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Mon, 5 Jan 2026 10:30:26 -0700 Subject: [PATCH 05/10] adding file name validation --- .../[courseName]/modules/NewItemForm.tsx | 15 +----- .../local/assignments/assignmentRouter.ts | 12 +---- src/features/local/pages/pageRouter.ts | 54 ++++++++++--------- src/features/local/quizzes/quizRouter.ts | 2 + src/services/fileNameValidation.ts | 28 ++++++++++ 5 files changed, 63 insertions(+), 48 deletions(-) create mode 100644 src/services/fileNameValidation.ts diff --git a/src/app/course/[courseName]/modules/NewItemForm.tsx b/src/app/course/[courseName]/modules/NewItemForm.tsx index b4d0d64..abbe5de 100644 --- a/src/app/course/[courseName]/modules/NewItemForm.tsx +++ b/src/app/course/[courseName]/modules/NewItemForm.tsx @@ -17,6 +17,7 @@ import { getDateFromStringOrThrow, } from "@/features/local/utils/timeUtils"; import { useCreateAssignmentMutation } from "@/features/local/assignments/assignmentHooks"; +import { validateFileName } from "@/services/fileNameValidation"; export default function NewItemForm({ moduleName: defaultModuleName, @@ -41,20 +42,6 @@ export default function NewItemForm({ const [name, setName] = useState(""); const [nameError, setNameError] = useState(""); - const validateFileName = (fileName: string): string => { - // 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/features/local/assignments/assignmentRouter.ts b/src/features/local/assignments/assignmentRouter.ts index 8bdf173..5df4124 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,16 +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); 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 0c8d956..c82b33a 100644 --- a/src/features/local/quizzes/quizRouter.ts +++ b/src/features/local/quizzes/quizRouter.ts @@ -10,6 +10,7 @@ import path from "path"; import { promises as fs } from "fs"; import { quizMarkdownUtils } from "./models/utils/quizMarkdownUtils"; import { courseItemFileStorageService } from "../course/courseItemFileStorageService"; +import { assertValidFileName } from "@/services/fileNameValidation"; export const quizRouter = router({ getQuiz: publicProcedure @@ -149,6 +150,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); + } +} From b5899c02e483ed879eb3ba31597453de90befe55 Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Mon, 5 Jan 2026 14:46:18 -0700 Subject: [PATCH 06/10] width fix --- .../modules/[moduleName]/page/[pageName]/EditPage.tsx | 8 ++++---- src/features/local/assignments/assignmentRouter.ts | 1 - 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPage.tsx b/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPage.tsx index 9675af9..a896098 100644 --- a/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPage.tsx +++ b/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPage.tsx @@ -102,13 +102,13 @@ export default function EditPage({ } Body={ -
-
+
+
-
+
{error && error}
-
+

diff --git a/src/features/local/assignments/assignmentRouter.ts b/src/features/local/assignments/assignmentRouter.ts index 5df4124..ef12292 100644 --- a/src/features/local/assignments/assignmentRouter.ts +++ b/src/features/local/assignments/assignmentRouter.ts @@ -135,7 +135,6 @@ export async function updateOrCreateAssignmentFile({ assignment: LocalAssignment; }) { assertValidFileName(assignmentName); - } const courseDirectory = await getCoursePathByName(courseName); const folder = path.join(courseDirectory, moduleName, "assignments"); From 678727c65036ff34e4f510d550e499c7d1c8e224 Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Mon, 5 Jan 2026 14:57:32 -0700 Subject: [PATCH 07/10] more permissive breadcrumbs --- src/components/BreadCrumbs.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/BreadCrumbs.tsx b/src/components/BreadCrumbs.tsx index 566f38e..1933dd6 100644 --- a/src/components/BreadCrumbs.tsx +++ b/src/components/BreadCrumbs.tsx @@ -10,17 +10,15 @@ export const BreadCrumbs = () => { const pathSegments = pathname?.split("/").filter(Boolean) || []; const isCourseRoute = pathSegments[0] === "course"; - const isOnCoursePage = isCourseRoute && pathSegments.length === 2; const courseName = - isCourseRoute && !isOnCoursePage && pathSegments[1] + isCourseRoute && pathSegments[1] ? decodeURIComponent(pathSegments[1]) : null; const isLectureRoute = isCourseRoute && pathSegments[2] === "lecture"; - const isOnLecturePage = isLectureRoute && pathSegments.length === 4; const lectureDate = - isLectureRoute && !isOnLecturePage && pathSegments[3] + isLectureRoute && pathSegments[3] ? decodeURIComponent(pathSegments[3]) : null; From fb5ee94b559edc9859b4f43f71a30c769bc880a9 Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Wed, 7 Jan 2026 09:02:16 -0700 Subject: [PATCH 08/10] improved tooltips --- globalSettings.yml | 10 ++++--- .../[courseName]/calendar/day/DayTitle.tsx | 15 +++++----- src/components/BreadCrumbs.tsx | 12 +++++++- src/components/Tooltip.tsx | 4 +-- src/components/useTooltip.ts | 28 +++++++++++++++++++ 5 files changed, 54 insertions(+), 15 deletions(-) create mode 100644 src/components/useTooltip.ts diff --git a/globalSettings.yml b/globalSettings.yml index d0372cc..99e25c9 100644 --- a/globalSettings.yml +++ b/globalSettings.yml @@ -4,18 +4,20 @@ courses: - 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: ./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]/calendar/day/DayTitle.tsx b/src/app/course/[courseName]/calendar/day/DayTitle.tsx index 7fc805c..61f5d54 100644 --- a/src/app/course/[courseName]/calendar/day/DayTitle.tsx +++ b/src/app/course/[courseName]/calendar/day/DayTitle.tsx @@ -9,7 +9,7 @@ import { getLectureForDay } from "@/features/local/utils/lectureUtils"; import { useLecturesSuspenseQuery } from "@/features/local/lectures/lectureHooks"; import ClientOnly from "@/components/ClientOnly"; import { Tooltip } from "@/components/Tooltip"; -import { useRef, useState } from "react"; +import { useTooltip } from "@/components/useTooltip"; export function DayTitle({ day, dayAsDate }: { day: string; dayAsDate: Date }) { const { courseName } = useCourseContext(); @@ -17,8 +17,7 @@ export function DayTitle({ day, dayAsDate }: { day: string; dayAsDate: Date }) { const { setIsDragging } = useDragStyleContext(); const todaysLecture = getLectureForDay(weeks, dayAsDate); const modal = useModal(); - const linkRef = useRef(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} /> )} diff --git a/src/components/BreadCrumbs.tsx b/src/components/BreadCrumbs.tsx index 1933dd6..2d1edca 100644 --- a/src/components/BreadCrumbs.tsx +++ b/src/components/BreadCrumbs.tsx @@ -22,6 +22,16 @@ export const BreadCrumbs = () => { ? 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 @@ -86,7 +96,7 @@ export const BreadCrumbs = () => { shallow={true} className={sharedLinkClassNames} > - {lectureDate} + {lectureDateOnly} diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx index 2a8a3c5..18f9eff 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 ") } role="tooltip" > 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, + }; +}; From b6a84f2fbcc353b65763a49055b47c2086937403 Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Wed, 7 Jan 2026 09:10:42 -0700 Subject: [PATCH 09/10] more tooltips --- .../[courseName]/calendar/day/ItemInDay.tsx | 65 +++++++++++++++---- 1 file changed, 54 insertions(+), 11 deletions(-) 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} + /> + ) + ) : ( + + )}
); From 558eb74fbc39c771434ed57396284677a723bda7 Mon Sep 17 00:00:00 2001 From: Alex Mickelson Date: Mon, 12 Jan 2026 12:04:33 -0700 Subject: [PATCH 10/10] tooltip scrolling fixed --- src/app/layout.tsx | 2 +- src/components/Modal.tsx | 10 +++------- src/components/Tooltip.tsx | 2 +- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1793b71..8b47eae 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -20,7 +20,7 @@ export default async function RootLayout({ 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 18f9eff..6d09044 100644 --- a/src/components/Tooltip.tsx +++ b/src/components/Tooltip.tsx @@ -21,7 +21,7 @@ export const Tooltip: React.FC<{ " 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 ? " opacity-100 " : " opacity-0 pointer-events-none ") + (visible ? " opacity-100 " : " opacity-0 pointer-events-none hidden ") } role="tooltip" >