mirror of
https://github.com/alexmickelson/canvasManagement.git
synced 2026-03-25 23:28:33 -06:00
merged
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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="">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,8 +40,7 @@ export function UpdateAssignmentName({
|
|||||||
if (name === assignmentName) closeModal();
|
if (name === assignmentName) closeModal();
|
||||||
|
|
||||||
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,
|
||||||
@@ -56,11 +55,22 @@ export function UpdateAssignmentName({
|
|||||||
getModuleItemUrl(courseName, moduleName, "assignment", name),
|
getModuleItemUrl(courseName, moduleName, "assignment", name),
|
||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
}finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
106
src/components/BreadCrumbs.tsx
Normal file
106
src/components/BreadCrumbs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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={
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
23
src/components/icons/HomeIcon.tsx
Normal file
23
src/components/icons/HomeIcon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/components/icons/RightSingleChevron.tsx
Normal file
27
src/components/icons/RightSingleChevron.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
28
src/components/useTooltip.ts
Normal file
28
src/components/useTooltip.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -119,12 +124,13 @@ export async function updatePageFile({
|
|||||||
moduleName,
|
moduleName,
|
||||||
pageName,
|
pageName,
|
||||||
page,
|
page,
|
||||||
}: {
|
}: {
|
||||||
courseName: string;
|
courseName: string;
|
||||||
moduleName: string;
|
moduleName: string;
|
||||||
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 });
|
||||||
@@ -139,7 +145,7 @@ export async function updatePageFile({
|
|||||||
const pageMarkdown = localPageMarkdownUtils.toMarkdown(page);
|
const pageMarkdown = localPageMarkdownUtils.toMarkdown(page);
|
||||||
console.log(`Saving page ${filePath}`);
|
console.log(`Saving page ${filePath}`);
|
||||||
await fs.writeFile(filePath, pageMarkdown);
|
await fs.writeFile(filePath, pageMarkdown);
|
||||||
}
|
}
|
||||||
async function deletePageFile({
|
async function deletePageFile({
|
||||||
courseName,
|
courseName,
|
||||||
moduleName,
|
moduleName,
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
28
src/services/fileNameValidation.ts
Normal file
28
src/services/fileNameValidation.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user