This commit is contained in:
Adam Teichert
2026-01-15 13:55:59 -07:00
29 changed files with 473 additions and 196 deletions

View File

@@ -1,29 +1,23 @@
courses: courses:
- path: ./4850_AdvancedFE/2025-fall-alex/modules/
name: Adv Frontend
- path: ./1420/2025-fall-alex/modules/ - path: ./1420/2025-fall-alex/modules/
name: "1420" 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/ - path: ./1425/2025-fall-alex/modules/
name: "1425" name: "1425"
- path: ./4850_AdvancedFE/2026-spring-alex/modules - path: ./4850_AdvancedFE/2026-spring-alex/modules
name: Adv Frontend Spring name: Adv Frontend
- path: ./1400/2026_spring_alex/modules - path: ./1400/2026_spring_alex/modules
name: "1400" name: "1400"
- path: ./1405/2026_spring_alex - path: ./1405/2026_spring_alex
name: "1405" name: "1405"
- path: ./1810/2026-spring-alex/modules
name: Web Intro Spring
- path: ./3840_Telemetry/2026_spring_alex - path: ./3840_Telemetry/2026_spring_alex
name: Telem and Ops New name: Telem and Ops
- path: ./4620_Distributed/2026-spring-alex/modules - path: ./4620_Distributed/2026-spring-alex/modules
name: Distributed name: Distributed
- path: ./4620_Distributed/2025Spring/modules/ - path: ./4620_Distributed/2025Spring/modules/
name: distributed-old name: distributed-old
- path: ./3840_Telemetry/2025_spring_alex/modules/ - path: ./3840_Telemetry/2025_spring_alex/modules/
name: telemetry-old name: telemetry-old
- path: ./3840_Telemetry/2024Spring_alex/modules/ - path: ./4850_AdvancedFE/2025-fall-alex/modules/
name: telemetry-old-old name: adv-frontend-old
- path: ./1810/2026-spring-alex/modules/
name: Web Intro

View File

@@ -10,12 +10,15 @@ const collapseThreshold = 1400;
export default function CollapsableSidebar() { export default function CollapsableSidebar() {
const [windowCollapseRecommended, setWindowCollapseRecommended] = const [windowCollapseRecommended, setWindowCollapseRecommended] =
useState(window.innerWidth <= collapseThreshold); useState(false);
const [userCollapsed, setUserCollapsed] = useState< const [userCollapsed, setUserCollapsed] = useState<
"unset" | "collapsed" | "uncollapsed" "unset" | "collapsed" | "uncollapsed"
>("unset"); >("unset");
useEffect(() => { useEffect(() => {
// Initialize on mount
setWindowCollapseRecommended(window.innerWidth <= collapseThreshold);
function handleResize() { function handleResize() {
if (window.innerWidth <= collapseThreshold) { if (window.innerWidth <= collapseThreshold) {
setWindowCollapseRecommended(true); setWindowCollapseRecommended(true);

View File

@@ -1,4 +1,5 @@
"use client"; "use client";
import { BreadCrumbs } from "@/components/BreadCrumbs";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
import { import {
useCanvasAssignmentsQuery, useCanvasAssignmentsQuery,
@@ -19,7 +20,6 @@ import {
} from "@/features/canvas/hooks/canvasQuizHooks"; } from "@/features/canvas/hooks/canvasQuizHooks";
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks"; import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
export function CourseNavigation() { export function CourseNavigation() {
const { data: settings } = useLocalCourseSettingsQuery(); const { data: settings } = useLocalCourseSettingsQuery();
@@ -33,9 +33,8 @@ export function CourseNavigation() {
return ( return (
<div className="pb-1 flex flex-row gap-3"> <div className="pb-1 flex flex-row gap-3">
<Link href={"/"} className="btn" shallow={true}> <BreadCrumbs />
Back to Course List
</Link>
<a <a
href={`https://snow.instructure.com/courses/${settings.canvasId}`} href={`https://snow.instructure.com/courses/${settings.canvasId}`}
className="btn" className="btn"

View File

@@ -9,7 +9,7 @@ import { getLectureForDay } from "@/features/local/utils/lectureUtils";
import { useLecturesSuspenseQuery } from "@/features/local/lectures/lectureHooks"; import { useLecturesSuspenseQuery } from "@/features/local/lectures/lectureHooks";
import ClientOnly from "@/components/ClientOnly"; import ClientOnly from "@/components/ClientOnly";
import { Tooltip } from "@/components/Tooltip"; import { Tooltip } from "@/components/Tooltip";
import { useRef, useState } from "react"; import { useTooltip } from "@/components/useTooltip";
export function DayTitle({ day, dayAsDate }: { day: string; dayAsDate: Date }) { export function DayTitle({ day, dayAsDate }: { day: string; dayAsDate: Date }) {
const { courseName } = useCourseContext(); const { courseName } = useCourseContext();
@@ -17,8 +17,7 @@ export function DayTitle({ day, dayAsDate }: { day: string; dayAsDate: Date }) {
const { setIsDragging } = useDragStyleContext(); const { setIsDragging } = useDragStyleContext();
const todaysLecture = getLectureForDay(weeks, dayAsDate); const todaysLecture = getLectureForDay(weeks, dayAsDate);
const modal = useModal(); const modal = useModal();
const linkRef = useRef<HTMLAnchorElement>(null); const { visible, targetRef, showTooltip, hideTooltip } = useTooltip();
const [tooltipVisible, setTooltipVisible] = useState(false);
const lectureName = todaysLecture && (todaysLecture.name || "lecture"); const lectureName = todaysLecture && (todaysLecture.name || "lecture");
@@ -44,9 +43,9 @@ export function DayTitle({ day, dayAsDate }: { day: string; dayAsDate: Date }) {
setIsDragging(true); setIsDragging(true);
} }
}} }}
ref={linkRef} ref={targetRef}
onMouseEnter={() => setTooltipVisible(true)} onMouseEnter={showTooltip}
onMouseLeave={() => setTooltipVisible(false)} onMouseLeave={hideTooltip}
> >
{dayAsDate.getDate()} {lectureName} {dayAsDate.getDate()} {lectureName}
</Link> </Link>
@@ -65,8 +64,8 @@ export function DayTitle({ day, dayAsDate }: { day: string; dayAsDate: Date }) {
)} )}
</div> </div>
} }
targetRef={linkRef} targetRef={targetRef}
visible={tooltipVisible} visible={visible}
/> />
)} )}
</ClientOnly> </ClientOnly>
@@ -81,19 +80,19 @@ export function DayTitle({ day, dayAsDate }: { day: string; dayAsDate: Date }) {
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
onClick={openModal} onClick={openModal}
> >
<g id="SVGRepo_bgCarrier" stroke-width="0"></g> <g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
<g <g
id="SVGRepo_tracerCarrier" id="SVGRepo_tracerCarrier"
stroke-linecap="round" strokeLinecap="round"
stroke-linejoin="round" strokeLinejoin="round"
></g> ></g>
<g id="SVGRepo_iconCarrier"> <g id="SVGRepo_iconCarrier">
<path <path
d="M6 12H18M12 6V18" d="M6 12H18M12 6V18"
className=" " className=" "
stroke-width="3" strokeWidth="3"
stroke-linecap="round" strokeLinecap="round"
stroke-linejoin="round" strokeLinejoin="round"
></path> ></path>
</g> </g>
</svg> </svg>

View File

@@ -1,13 +1,48 @@
import { IModuleItem } from "@/features/local/modules/IModuleItem"; import { IModuleItem } from "@/features/local/modules/IModuleItem";
import { getModuleItemUrl } from "@/services/urlUtils"; import { getModuleItemUrl } from "@/services/urlUtils";
import Link from "next/link"; import Link from "next/link";
import { ReactNode, useRef, useState } from "react"; import { ReactNode } from "react";
import { useCourseContext } from "../../context/courseContext"; import { useCourseContext } from "../../context/courseContext";
import { useTooltip } from "@/components/useTooltip";
import MarkdownDisplay from "@/components/MarkdownDisplay";
import { DraggableItem } from "../../context/drag/draggingContext"; import { DraggableItem } from "../../context/drag/draggingContext";
import ClientOnly from "@/components/ClientOnly"; import ClientOnly from "@/components/ClientOnly";
import { useDragStyleContext } from "../../context/drag/dragStyleContext"; import { useDragStyleContext } from "../../context/drag/dragStyleContext";
import { Tooltip } from "../../../../../components/Tooltip"; 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 (
<MarkdownDisplay
markdown={assignment.description}
replaceText={[
{
source: "insert_github_classroom_url",
destination: assignment.githubClassroomAssignmentShareLink || "",
},
]}
/>
);
} else if (type === "page" && "text" in item) {
return <MarkdownDisplay markdown={item.text as string} />;
} else if (type === "quiz" && "questions" in item) {
const quiz = item as { questions: { text: string }[] };
return quiz.questions.map((q, i: number) => (
<div key={i} className="">
<MarkdownDisplay markdown={q.text as string} />
</div>
));
}
return null;
}
export function ItemInDay({ export function ItemInDay({
type, type,
moduleName, moduleName,
@@ -23,8 +58,8 @@ export function ItemInDay({
}) { }) {
const { courseName } = useCourseContext(); const { courseName } = useCourseContext();
const { setIsDragging } = useDragStyleContext(); const { setIsDragging } = useDragStyleContext();
const linkRef = useRef<HTMLAnchorElement>(null); const { visible, targetRef, showTooltip, hideTooltip } = useTooltip(500);
const [tooltipVisible, setTooltipVisible] = useState(false);
return ( return (
<div className={" relative group "}> <div className={" relative group "}>
<Link <Link
@@ -52,18 +87,26 @@ export function ItemInDay({
); );
setIsDragging(true); setIsDragging(true);
}} }}
onMouseEnter={() => setTooltipVisible(true)} onMouseEnter={showTooltip}
onMouseLeave={() => setTooltipVisible(false)} onMouseLeave={hideTooltip}
ref={linkRef} ref={targetRef}
> >
{item.name} {item.name}
</Link> </Link>
<ClientOnly> <ClientOnly>
{status === "published" ? (
getPreviewContent(type, item) && (
<Tooltip <Tooltip
message={message} message={
targetRef={linkRef} <div className="max-w-md">{getPreviewContent(type, item)}</div>
visible={tooltipVisible && status === "incomplete"} }
targetRef={targetRef}
visible={visible}
/> />
)
) : (
<Tooltip message={message} targetRef={targetRef} visible={visible} />
)}
</ClientOnly> </ClientOnly>
</div> </div>
); );

View File

@@ -1,10 +1,11 @@
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks"; import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { getDateFromString } from "@/features/local/utils/timeUtils"; import { getDateFromString } from "@/features/local/utils/timeUtils";
import { getLectureWeekName } from "@/features/local/lectures/lectureUtils"; import { getLectureWeekName } from "@/features/local/lectures/lectureUtils";
import { getCourseUrl, getLecturePreviewUrl } from "@/services/urlUtils"; import { getLecturePreviewUrl } from "@/services/urlUtils";
import { useCourseContext } from "../../context/courseContext"; import { useCourseContext } from "../../context/courseContext";
import Link from "next/link"; import Link from "next/link";
import { getDayOfWeek } from "@/features/local/course/localCourseSettings"; import { getDayOfWeek } from "@/features/local/course/localCourseSettings";
import { BreadCrumbs } from "@/components/BreadCrumbs";
export default function EditLectureTitle({ export default function EditLectureTitle({
lectureDay, lectureDay,
@@ -17,16 +18,7 @@ export default function EditLectureTitle({
const lectureWeekName = getLectureWeekName(settings.startDate, lectureDay); const lectureWeekName = getLectureWeekName(settings.startDate, lectureDay);
return ( return (
<div className="flex justify-between sm:flex-row flex-col"> <div className="flex justify-between sm:flex-row flex-col">
<div className="my-auto"> <BreadCrumbs />
<Link
className="btn hidden sm:inline"
href={getCourseUrl(courseName)}
shallow={true}
prefetch={true}
>
{courseName}
</Link>
</div>
<div className="flex justify-center "> <div className="flex justify-center ">
<h3 className="mt-auto me-3 text-slate-500 ">Lecture</h3> <h3 className="mt-auto me-3 text-slate-500 ">Lecture</h3>
<h1 className=""> <h1 className="">

View File

@@ -1,17 +1,14 @@
"use client"; "use client";
import LecturePreview from "../LecturePreview"; 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 { useLecturesSuspenseQuery } from "@/features/local/lectures/lectureHooks";
import { BreadCrumbs } from "@/components/BreadCrumbs";
export default function LecturePreviewPage({ export default function LecturePreviewPage({
lectureDay, lectureDay,
}: { }: {
lectureDay: string; lectureDay: string;
}) { }) {
const { courseName } = useCourseContext();
const { data: weeks } = useLecturesSuspenseQuery(); const { data: weeks } = useLecturesSuspenseQuery();
const lecture = weeks const lecture = weeks
.flatMap(({ lectures }) => lectures.map((lecture) => lecture)) .flatMap(({ lectures }) => lectures.map((lecture) => lecture))
@@ -23,20 +20,7 @@ export default function LecturePreviewPage({
return ( return (
<div className="flex h-full xl:flex-row flex-col "> <div className="flex h-full xl:flex-row flex-col ">
<div className="flex-shrink flex-1 pb-1 ms-3 xl:ms-0 flex flex-row flex-wrap gap-3 content-start "> <div className="flex-shrink flex-1 pb-1 ms-3 xl:ms-0 flex flex-row flex-wrap gap-3 content-start ">
<div className=""> <BreadCrumbs />
<Link
className="btn"
href={getLectureUrl(courseName, lectureDay)}
shallow={true}
>
Edit Lecture
</Link>
</div>
<div className="">
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
Course Calendar
</Link>
</div>
</div> </div>
<div className="flex justify-center min-h-0 px-2"> <div className="flex justify-center min-h-0 px-2">
<div <div

View File

@@ -17,6 +17,7 @@ import {
getDateFromStringOrThrow, getDateFromStringOrThrow,
} from "@/features/local/utils/timeUtils"; } from "@/features/local/utils/timeUtils";
import { useCreateAssignmentMutation } from "@/features/local/assignments/assignmentHooks"; import { useCreateAssignmentMutation } from "@/features/local/assignments/assignmentHooks";
import { validateFileName } from "@/services/fileNameValidation";
export default function NewItemForm({ export default function NewItemForm({
moduleName: defaultModuleName, moduleName: defaultModuleName,
@@ -41,20 +42,6 @@ export default function NewItemForm({
const [name, setName] = useState(""); const [name, setName] = useState("");
const [nameError, setNameError] = 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) => { const handleNameChange = (newName: string) => {
setName(newName); setName(newName);
const error = validateFileName(newName); const error = validateFileName(newName);

View File

@@ -1,7 +1,6 @@
import { useCourseContext } from "@/app/course/[courseName]/context/courseContext"; import { BreadCrumbs } from "@/components/BreadCrumbs";
import { UpdateAssignmentName } from "./UpdateAssignmentName"; import { UpdateAssignmentName } from "./UpdateAssignmentName";
import { getCourseUrl } from "@/services/urlUtils"; import { RightSingleChevron } from "@/components/icons/RightSingleChevron";
import Link from "next/link";
export default function EditAssignmentHeader({ export default function EditAssignmentHeader({
moduleName, moduleName,
@@ -10,22 +9,21 @@ export default function EditAssignmentHeader({
assignmentName: string; assignmentName: string;
moduleName: string; moduleName: string;
}) { }) {
const { courseName } = useCourseContext();
return ( return (
<div className="py-1 flex flex-row justify-start gap-3"> <div className="py-1 flex flex-row justify-between">
<Link <div className="flex flex-row">
className="btn" <BreadCrumbs />
href={getCourseUrl(courseName)} <span className="text-slate-500 cursor-default select-none my-auto">
shallow={true} <RightSingleChevron />
prefetch={true} </span>
> <div className="my-auto px-3">{assignmentName}</div>
{courseName} </div>
</Link> <div className="px-1">
<UpdateAssignmentName <UpdateAssignmentName
assignmentName={assignmentName} assignmentName={assignmentName}
moduleName={moduleName} moduleName={moduleName}
/> />
<div className="my-auto">{assignmentName}</div> </div>
</div> </div>
); );
} }

View File

@@ -41,7 +41,6 @@ export function UpdateAssignmentName({
setIsLoading(true); // page refresh resets flag setIsLoading(true); // page refresh resets flag
try { try {
await updateAssignment.mutateAsync({ await updateAssignment.mutateAsync({
assignment: assignment, assignment: assignment,
moduleName, moduleName,
@@ -61,6 +60,17 @@ export function UpdateAssignmentName({
} }
}} }}
> >
<div
className="
text-yellow-300
bg-yellow-950/30
border-2
rounded-lg
border-yellow-800
p-1 text-sm mb-2"
>
Warning: does not rename in Canvas
</div>
<TextInput <TextInput
value={name} value={name}
setValue={setName} setValue={setName}

View File

@@ -102,13 +102,13 @@ export default function EditPage({
<EditLayout <EditLayout
Header={<EditPageHeader pageName={pageName} moduleName={moduleName} />} Header={<EditPageHeader pageName={pageName} moduleName={moduleName} />}
Body={ Body={
<div className="columns-2 min-h-0 flex-1"> <div className="flex min-h-0 flex-1 gap-4 overflow-hidden">
<div className="flex-1 h-full"> <div className="flex-1 h-full min-w-0 overflow-hidden">
<MonacoEditor key={monacoKey} value={text} onChange={textUpdate} /> <MonacoEditor key={monacoKey} value={text} onChange={textUpdate} />
</div> </div>
<div className="h-full"> <div className="flex-1 h-full min-w-0 flex flex-col overflow-hidden">
<div className="text-red-300">{error && error}</div> <div className="text-red-300">{error && error}</div>
<div className="h-full overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<br /> <br />
<PagePreview page={page} /> <PagePreview page={page} />
</div> </div>

View File

@@ -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 { UpdatePageName } from "./UpdatePageName";
import { BreadCrumbs } from "@/components/BreadCrumbs";
import { RightSingleChevron } from "@/components/icons/RightSingleChevron";
export default function EditPageHeader({ export default function EditPageHeader({
moduleName, moduleName,
@@ -10,19 +9,18 @@ export default function EditPageHeader({
pageName: string; pageName: string;
moduleName: string; moduleName: string;
}) { }) {
const { courseName } = useCourseContext();
return ( return (
<div className="py-1 flex flex-row justify-start gap-3"> <div className="py-1 flex flex-row justify-between">
<Link <div className="flex flex-row">
className="btn" <BreadCrumbs />
href={getCourseUrl(courseName)} <span className="text-slate-500 cursor-default select-none my-auto">
shallow={true} <RightSingleChevron />
prefetch={true} </span>
> <div className="my-auto px-3">{pageName}</div>
{courseName} </div>
</Link> <div className="px-1">
<UpdatePageName pageName={pageName} moduleName={moduleName} /> <UpdatePageName pageName={pageName} moduleName={moduleName} />
<div className="my-auto">{pageName}</div> </div>
</div> </div>
); );
} }

View File

@@ -56,6 +56,17 @@ export function UpdatePageName({
); );
}} }}
> >
<div
className="
text-yellow-300
bg-yellow-950/30
border-2
rounded-lg
border-yellow-800
p-1 text-sm mb-2"
>
Warning: does not rename in Canvas
</div>
<TextInput value={name} setValue={setName} label={"Rename Page"} /> <TextInput value={name} setValue={setName} label={"Rename Page"} />
<button className="w-full my-3">Save New Name</button> <button className="w-full my-3">Save New Name</button>
{isLoading && <Spinner />} {isLoading && <Spinner />}

View File

@@ -1,7 +1,6 @@
import { useCourseContext } from "@/app/course/[courseName]/context/courseContext"; import { RightSingleChevron } from "@/components/icons/RightSingleChevron";
import { getCourseUrl } from "@/services/urlUtils";
import Link from "next/link";
import { UpdateQuizName } from "./UpdateQuizName"; import { UpdateQuizName } from "./UpdateQuizName";
import { BreadCrumbs } from "@/components/BreadCrumbs";
export default function EditQuizHeader({ export default function EditQuizHeader({
moduleName, moduleName,
@@ -10,19 +9,18 @@ export default function EditQuizHeader({
quizName: string; quizName: string;
moduleName: string; moduleName: string;
}) { }) {
const { courseName } = useCourseContext();
return ( return (
<div className="py-1 flex flex-row justify-start gap-3"> <div className="py-1 flex flex-row justify-between">
<Link <div className="flex flex-row">
className="btn" <BreadCrumbs />
href={getCourseUrl(courseName)} <span className="text-slate-500 cursor-default select-none my-auto">
shallow={true} <RightSingleChevron />
prefetch={true} </span>
> <div className="my-auto px-3">{quizName}</div>
{courseName} </div>
</Link> <div className="px-1">
<UpdateQuizName quizName={quizName} moduleName={moduleName} /> <UpdateQuizName quizName={quizName} moduleName={moduleName} />
<div>{quizName}</div> </div>
</div> </div>
); );
} }

View File

@@ -56,6 +56,17 @@ export function UpdateQuizName({
); );
}} }}
> >
<div
className="
text-yellow-300
bg-yellow-950/30
border-2
rounded-lg
border-yellow-800
p-1 text-sm mb-2"
>
Warning: does not rename in Canvas
</div>
<TextInput value={name} setValue={setName} label={"Rename Quiz"} /> <TextInput value={name} setValue={setName} label={"Rename Quiz"} />
<button className="w-full my-3">Save New Name</button> <button className="w-full my-3">Save New Name</button>
{isLoading && <Spinner />} {isLoading && <Spinner />}

View File

@@ -20,7 +20,7 @@ export default async function RootLayout({
return ( return (
<html lang="en"> <html lang="en">
<head></head> <head></head>
<body className="flex justify-center"> <body className="flex justify-center h-screen" suppressHydrationWarning>
<div className="bg-gray-950 h-screen text-slate-300 w-screen sm:p-1"> <div className="bg-gray-950 h-screen text-slate-300 w-screen sm:p-1">
<MyToaster /> <MyToaster />
<Suspense> <Suspense>

View File

@@ -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 (
<nav className="flex flex-row font-bold text-sm items-center">
<span className={sharedBackgroundClassNames}>
<Link
href="/"
shallow={true}
className="flex items-center gap-1 rounded-lg h-full "
>
<span className={sharedLinkClassNames}>
<HomeIcon />
</span>
</Link>
</span>
{courseName && (
<>
<span className="text-slate-500 cursor-default select-none">
<RightSingleChevron />
</span>
<span className={sharedBackgroundClassNames}>
<Link
href={`/course/${encodeURIComponent(courseName)}`}
shallow={true}
className={sharedLinkClassNames}
>
{courseName}
</Link>
</span>
</>
)}
{isLectureRoute && lectureDate && courseName && (
<>
<span className="text-slate-500 cursor-default select-none">
<RightSingleChevron />
</span>
<span className={sharedBackgroundClassNames}>
<Link
href={`/course/${encodeURIComponent(
courseName
)}/lecture/${encodeURIComponent(lectureDate)}`}
shallow={true}
className={sharedLinkClassNames}
>
{lectureDateOnly}
</Link>
</span>
</>
)}
</nav>
);
};

View File

@@ -50,18 +50,14 @@ export default function Modal({
<div <div
className={ className={
" fixed inset-0 flex items-center justify-center transition-all duration-400 h-screen w-screen " + modalControl.isOpen
" bg-black" + ? "transition-all duration-400 fixed inset-0 flex items-center justify-center h-screen bg-black/80 z-50 w-screen"
(modalControl.isOpen : "hidden h-0 w-0 p-1 -z-50"
? " bg-opacity-50 z-50 "
: " bg-opacity-0 -z-50 ")
} }
onClick={modalControl.closeModal} onClick={modalControl.closeModal}
// if mouse up here, do not, if mouse down then still do
> >
<div <div
onClick={(e) => { onClick={(e) => {
// e.preventDefault();
e.stopPropagation(); e.stopPropagation();
}} }}
className={ className={

View File

@@ -18,10 +18,10 @@ export const Tooltip: React.FC<{
" absolute -translate-x-1/2 " + " absolute -translate-x-1/2 " +
" bg-gray-900 text-slate-200 text-sm " + " bg-gray-900 text-slate-200 text-sm " +
" rounded-md py-1 px-2 " + " 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 " + " border border-slate-700 shadow-[0px_0px_10px_5px] shadow-slate-500/20 " +
" max-w-sm max-h-64 overflow-hidden " + " max-w-sm max-h-64 overflow-hidden " +
(visible ? " " : " hidden -z-50 ") (visible ? " opacity-100 " : " opacity-0 pointer-events-none hidden ")
} }
role="tooltip" role="tooltip"
> >

View File

@@ -9,6 +9,9 @@
background-color: #030712 !important; background-color: #030712 !important;
/* background-color: #101828 !important; */ /* background-color: #101828 !important; */
} }
.sticky-widget {
background-color: #0C0F17 !important;
}
.monaco-editor { .monaco-editor {
position: absolute !important; position: absolute !important;
} }

View File

@@ -16,19 +16,19 @@ export default function ExpandIcon({
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<g id="SVGRepo_bgCarrier" stroke-width="0"></g> <g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
<g <g
id="SVGRepo_tracerCarrier" id="SVGRepo_tracerCarrier"
stroke-linecap="round" strokeLinecap="round"
stroke-linejoin="round" strokeLinejoin="round"
></g> ></g>
<g id="SVGRepo_iconCarrier"> <g id="SVGRepo_iconCarrier">
<path <path
className="stroke-slate-300" className="stroke-slate-300"
d="M9 6L15 12L9 18" d="M9 6L15 12L9 18"
stroke-width="2" strokeWidth="2"
stroke-linecap="round" strokeLinecap="round"
stroke-linejoin="round" strokeLinejoin="round"
></path> ></path>
</g> </g>
</svg> </svg>

View File

@@ -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 (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
>
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
<g
id="SVGRepo_tracerCarrier"
strokeLinecap="round"
strokeLinejoin="round"
></g>
<g id="SVGRepo_iconCarrier">
<path d="M11.3861 1.21065C11.7472 0.929784 12.2528 0.929784 12.6139 1.21065L21.6139 8.21065C21.8575 8.4001 22 8.69141 22 9V20.5C22 21.3284 21.3284 22 20.5 22H15V14C15 13.4477 14.5523 13 14 13H10C9.44772 13 9 13.4477 9 14V22H3.5C2.67157 22 2 21.3284 2 20.5V9C2 8.69141 2.14247 8.4001 2.38606 8.21065L11.3861 1.21065Z"></path>
</g>
</svg>
);
}

View File

@@ -0,0 +1,27 @@
import React from "react";
// https://www.svgrepo.com/svg/491374/chevron-small-right
export const RightSingleChevron = () => {
return (
<svg
viewBox="7 4 11 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="h-3 w-3 fill-slate-600"
>
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
<g
id="SVGRepo_tracerCarrier"
strokeLinecap="round"
strokeLinejoin="round"
></g>
<g id="SVGRepo_iconCarrier">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8.08586 5.41412C7.69534 5.80465 7.69534 6.43781 8.08586 6.82834L13.3788 12.1212L8.08586 17.4141C7.69534 17.8046 7.69534 18.4378 8.08586 18.8283L8.79297 19.5354C9.18349 19.926 9.81666 19.926 10.2072 19.5354L16.5607 13.1819C17.1465 12.5961 17.1465 11.6464 16.5607 11.0606L10.2072 4.70702C9.81666 4.31649 9.18349 4.31649 8.79297 4.70702L8.08586 5.41412Z"
></path>
</g>
</svg>
);
};

View File

@@ -4,6 +4,8 @@ import { useTRPC } from "@/services/serverFunctions/trpcClient";
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { io, Socket } from "socket.io-client"; import { io, Socket } from "socket.io-client";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useGlobalSettingsQuery } from "@/features/local/globalSettings/globalSettingsHooks";
import { GlobalSettings } from "@/features/local/globalSettings/globalSettingsModels";
interface ServerToClientEvents { interface ServerToClientEvents {
message: (data: string) => void; message: (data: string) => void;
@@ -24,6 +26,22 @@ function removeFileExtension(fileName: string): string {
return fileName; 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() { export function ClientCacheInvalidation() {
const invalidateCache = useFilePathInvalidation(); const invalidateCache = useFilePathInvalidation();
const [connectionAttempted, setConnectionAttempted] = useState(false); const [connectionAttempted, setConnectionAttempted] = useState(false);
@@ -62,13 +80,32 @@ export function ClientCacheInvalidation() {
const useFilePathInvalidation = () => { const useFilePathInvalidation = () => {
const trpc = useTRPC(); const trpc = useTRPC();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: settings } = useGlobalSettingsQuery();
return useCallback( return useCallback(
(filePath: string) => { (filePath: string) => {
const [courseName, moduleOrLectures, itemType, itemFile] = const courseName = getCourseNameByPath(filePath, settings);
filePath.split("/"); // 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 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") { if (moduleOrLectures === "settings.yml") {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -141,6 +178,8 @@ const useFilePathInvalidation = () => {
}); });
return; return;
} }
console.log("no cache invalidation match for file ", allParts);
}, },
[ [
queryClient, queryClient,
@@ -153,6 +192,7 @@ const useFilePathInvalidation = () => {
trpc.quiz.getQuiz, trpc.quiz.getQuiz,
trpc.settings.allCoursesSettings, trpc.settings.allCoursesSettings,
trpc.settings.courseSettings, trpc.settings.courseSettings,
settings,
] ]
); );
}; };

View File

@@ -0,0 +1,28 @@
import { useState, useRef, useCallback } from "react";
export const useTooltip = (delayMs: number = 150) => {
const [visible, setVisible] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const targetRef = useRef<HTMLAnchorElement>(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,
};
};

View File

@@ -10,6 +10,7 @@ import { getCoursePathByName } from "../globalSettings/globalSettingsFileStorage
import { promises as fs } from "fs"; import { promises as fs } from "fs";
import { courseItemFileStorageService } from "../course/courseItemFileStorageService"; import { courseItemFileStorageService } from "../course/courseItemFileStorageService";
import { assignmentMarkdownSerializer } from "./models/utils/assignmentMarkdownSerializer"; import { assignmentMarkdownSerializer } from "./models/utils/assignmentMarkdownSerializer";
import { assertValidFileName } from "@/services/fileNameValidation";
export const assignmentRouter = router({ export const assignmentRouter = router({
getAssignment: publicProcedure getAssignment: publicProcedure
@@ -133,17 +134,7 @@ export async function updateOrCreateAssignmentFile({
assignmentName: string; assignmentName: string;
assignment: LocalAssignment; assignment: LocalAssignment;
}) { }) {
const illegalCharacters = ["<", ">", ":", '"', "/", "\\", "|", "?", "*"]; assertValidFileName(assignmentName);
const foundIllegalCharacters = illegalCharacters.filter((char) =>
assignmentName.includes(char)
);
if (foundIllegalCharacters.length > 0) {
throw new Error(
`"${assignmentName}" cannot contain the following characters: ${foundIllegalCharacters.join(
" "
)}`
);
}
const courseDirectory = await getCoursePathByName(courseName); const courseDirectory = await getCoursePathByName(courseName);
const folder = path.join(courseDirectory, moduleName, "assignments"); const folder = path.join(courseDirectory, moduleName, "assignments");

View File

@@ -1,11 +1,16 @@
import publicProcedure from "../../../services/serverFunctions/publicProcedure"; import publicProcedure from "../../../services/serverFunctions/publicProcedure";
import { z } from "zod"; import { z } from "zod";
import { router } from "../../../services/serverFunctions/trpcSetup"; 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 { courseItemFileStorageService } from "../course/courseItemFileStorageService";
import { promises as fs } from "fs"; import { promises as fs } from "fs";
import path from "path"; import path from "path";
import { getCoursePathByName } from "../globalSettings/globalSettingsFileStorageService"; import { getCoursePathByName } from "../globalSettings/globalSettingsFileStorageService";
import { assertValidFileName } from "@/services/fileNameValidation";
export const pageRouter = router({ export const pageRouter = router({
getPage: publicProcedure getPage: publicProcedure
@@ -125,6 +130,7 @@ export async function updatePageFile({
pageName: string; pageName: string;
page: LocalCoursePage; page: LocalCoursePage;
}) { }) {
assertValidFileName(pageName);
const courseDirectory = await getCoursePathByName(courseName); const courseDirectory = await getCoursePathByName(courseName);
const folder = path.join(courseDirectory, moduleName, "pages"); const folder = path.join(courseDirectory, moduleName, "pages");
await fs.mkdir(folder, { recursive: true }); await fs.mkdir(folder, { recursive: true });

View File

@@ -14,6 +14,7 @@ import { promises as fs } from "fs";
import { quizMarkdownUtils } from "./models/utils/quizMarkdownUtils"; import { quizMarkdownUtils } from "./models/utils/quizMarkdownUtils";
import { courseItemFileStorageService } from "../course/courseItemFileStorageService"; import { courseItemFileStorageService } from "../course/courseItemFileStorageService";
import { getFeedbackDelimitersFromSettings } from "../globalSettings/globalSettingsUtils"; import { getFeedbackDelimitersFromSettings } from "../globalSettings/globalSettingsUtils";
import { assertValidFileName } from "@/services/fileNameValidation";
export const quizRouter = router({ export const quizRouter = router({
getQuiz: publicProcedure getQuiz: publicProcedure
@@ -153,6 +154,7 @@ export async function updateQuizFile({
quizName: string; quizName: string;
quiz: LocalQuiz; quiz: LocalQuiz;
}) { }) {
assertValidFileName(quizName);
const courseDirectory = await getCoursePathByName(courseName); const courseDirectory = await getCoursePathByName(courseName);
const folder = path.join(courseDirectory, moduleName, "quizzes"); const folder = path.join(courseDirectory, moduleName, "quizzes");
await fs.mkdir(folder, { recursive: true }); await fs.mkdir(folder, { recursive: true });

View File

@@ -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);
}
}