diff --git a/src/app/addCourse/AddCourseToGlobalSettingsForm.tsx b/src/app/addCourse/AddCourseToGlobalSettingsForm.tsx index c39037b..509f4e6 100644 --- a/src/app/addCourse/AddCourseToGlobalSettingsForm.tsx +++ b/src/app/addCourse/AddCourseToGlobalSettingsForm.tsx @@ -24,6 +24,7 @@ import { useCourseListInTermQuery } from "@/features/canvas/hooks/canvasCourseHo import { useCanvasTermsQuery } from "@/features/canvas/hooks/canvasHooks"; import { useDirectoryExistsQuery } from "@/features/local/utils/storageDirectoryHooks"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars const sampleCompose = `services: canvas_manager: image: alexmickelson/canvas_management:2 # pull this image regularly diff --git a/src/app/course/[courseName]/components/ItemNavigationButtons.tsx b/src/app/course/[courseName]/components/ItemNavigationButtons.tsx new file mode 100644 index 0000000..934d231 --- /dev/null +++ b/src/app/course/[courseName]/components/ItemNavigationButtons.tsx @@ -0,0 +1,24 @@ +import Link from "next/link"; + +export default function ItemNavigationButtons({ + previousUrl, + nextUrl, +}: { + previousUrl: string | null; + nextUrl: string | null; +}) { + return ( + <> + {previousUrl && ( + + Previous + + )} + {nextUrl && ( + + Next + + )} + + ); +} diff --git a/src/app/course/[courseName]/hooks/navigationLogic.test.ts b/src/app/course/[courseName]/hooks/navigationLogic.test.ts new file mode 100644 index 0000000..8e33a78 --- /dev/null +++ b/src/app/course/[courseName]/hooks/navigationLogic.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import { + getOrderedItems, + getOrderedLectures, + getNavigationLinks, + OrderedCourseItem, +} from "./navigationLogic"; +import { CalendarItemsInterface } from "../context/calendarItemsContext"; + +describe("navigationLogic", () => { + const courseName = "testCourse"; + + it("getOrderedItems should order items by date, then alphabetically by name", () => { + const createMock = ( + date: string, + name: string, + key: "assignments" | "quizzes" | "pages" + ) => + ({ + [date]: { "Module 1": { [key]: [{ name }] } }, + } as unknown as CalendarItemsInterface); + + const orderedItems = getOrderedItems( + courseName, + createMock("2023-01-01", "Z Assignment", "assignments"), + createMock("2023-01-01", "A Quiz", "quizzes"), + createMock("2023-01-02", "B Assignment", "assignments"), + createMock("2023-01-02", "A Page", "pages") + ); + + expect(orderedItems.map((i) => `${i.date} ${i.name}`)).toEqual([ + "2023-01-01 A Quiz", + "2023-01-01 Z Assignment", + "2023-01-02 A Page", + "2023-01-02 B Assignment", + ]); + }); + + it("getNavigationLinks should handle wrapping and normal navigation", () => { + const items: OrderedCourseItem[] = [ + { type: "assignment", name: "1", moduleName: "M", date: "D", url: "u1" }, + { type: "quiz", name: "2", moduleName: "M", date: "D", url: "u2" }, + { type: "page", name: "3", moduleName: "M", date: "D", url: "u3" }, + ]; + + // Forward wrap (last -> first) + expect(getNavigationLinks(items, "page", "3", "M").nextUrl).toBe("u1"); + + // Backward wrap (first -> last) + expect(getNavigationLinks(items, "assignment", "1", "M").previousUrl).toBe( + "u3" + ); + + // Normal navigation (middle) + const middle = getNavigationLinks(items, "quiz", "2", "M"); + expect(middle.previousUrl).toBe("u1"); + expect(middle.nextUrl).toBe("u3"); + }); + + it("getOrderedLectures should flatten weeks and generate correct URLs", () => { + const weeks = [ + { lectures: [{ date: "01/01/2023" }] }, + { lectures: [{ date: "01/02/2023" }, { date: "01/03/2023" }] }, + ]; + const lectures = getOrderedLectures(weeks, courseName); + expect(lectures).toHaveLength(3); + expect(lectures[0].url).toContain(encodeURIComponent("01/01/2023")); + expect(lectures[0].type).toBe("lecture"); + }); +}); diff --git a/src/app/course/[courseName]/hooks/navigationLogic.ts b/src/app/course/[courseName]/hooks/navigationLogic.ts new file mode 100644 index 0000000..8884382 --- /dev/null +++ b/src/app/course/[courseName]/hooks/navigationLogic.ts @@ -0,0 +1,83 @@ +import { CalendarItemsInterface } from "../context/calendarItemsContext"; +import { getLectureUrl, getModuleItemUrl } from "@/services/urlUtils"; + +export type CourseItemType = "assignment" | "quiz" | "page" | "lecture"; + +export interface OrderedCourseItem { + type: CourseItemType; + name: string; + moduleName?: string; + date: string; + url: string; +} + +export function getOrderedItems( + courseName: string, + ...calendars: CalendarItemsInterface[] +): OrderedCourseItem[] { + const itemTypes = [ + { key: "assignments" as const, type: "assignment" as const }, + { key: "quizzes" as const, type: "quiz" as const }, + { key: "pages" as const, type: "page" as const }, + ]; + + return calendars + .flatMap((calendar) => + Object.entries(calendar).flatMap(([date, modules]) => + Object.entries(modules).flatMap(([moduleName, moduleData]) => + itemTypes.flatMap(({ key, type }) => + (moduleData[key] || []).map((item) => ({ + type, + name: item.name, + moduleName, + date, + url: getModuleItemUrl(courseName, moduleName, type, item.name), + })) + ) + ) + ) + ) + .sort((a, b) => { + const dateCompare = a.date.localeCompare(b.date); + if (dateCompare !== 0) return dateCompare; + return a.name.localeCompare(b.name); + }); +} + +export function getOrderedLectures( + weeks: { lectures: { date: string }[] }[], + courseName: string +): OrderedCourseItem[] { + return weeks + .flatMap((week) => week.lectures) + .map((lecture) => ({ + type: "lecture", + name: lecture.date, + date: lecture.date, + url: getLectureUrl(courseName, lecture.date), + })); +} + +export function getNavigationLinks( + list: OrderedCourseItem[], + type: CourseItemType, + name: string, + moduleName?: string +) { + const index = list.findIndex((item) => { + if (type === "lecture") return item.date === name; + return ( + item.name === name && item.type === type && item.moduleName === moduleName + ); + }); + + if (index === -1) return { previousUrl: null, nextUrl: null }; + + const previousIndex = (index - 1 + list.length) % list.length; + const nextIndex = (index + 1) % list.length; + + return { + previousUrl: list[previousIndex].url, + nextUrl: list[nextIndex].url, + }; +} diff --git a/src/app/course/[courseName]/hooks/useItemNavigation.ts b/src/app/course/[courseName]/hooks/useItemNavigation.ts new file mode 100644 index 0000000..fcf5428 --- /dev/null +++ b/src/app/course/[courseName]/hooks/useItemNavigation.ts @@ -0,0 +1,14 @@ +import { useOrderedCourseItems } from "./useOrderedCourseItems"; +import { getNavigationLinks, CourseItemType } from "./navigationLogic"; + +export function useItemNavigation( + type: CourseItemType, + name: string, + moduleName?: string +) { + const { orderedItems, orderedLectures } = useOrderedCourseItems(); + + const list = type === "lecture" ? orderedLectures : orderedItems; + + return getNavigationLinks(list, type, name, moduleName); +} diff --git a/src/app/course/[courseName]/hooks/useOrderedCourseItems.ts b/src/app/course/[courseName]/hooks/useOrderedCourseItems.ts new file mode 100644 index 0000000..6ac70c5 --- /dev/null +++ b/src/app/course/[courseName]/hooks/useOrderedCourseItems.ts @@ -0,0 +1,24 @@ +import { + useCourseAssignmentsByModuleByDateQuery, + useCoursePagesByModuleByDateQuery, + useCourseQuizzesByModuleByDateQuery, +} from "@/features/local/modules/localCourseModuleHooks"; +import { useLecturesSuspenseQuery } from "@/features/local/lectures/lectureHooks"; +import { useCourseContext } from "../context/courseContext"; +import { getOrderedItems, getOrderedLectures } from "./navigationLogic"; + +export function useOrderedCourseItems() { + const { courseName } = useCourseContext(); + const { data: weeks } = useLecturesSuspenseQuery(); + + const orderedItems = getOrderedItems( + courseName, + useCourseAssignmentsByModuleByDateQuery(), + useCourseQuizzesByModuleByDateQuery(), + useCoursePagesByModuleByDateQuery() + ); + + const orderedLectures = getOrderedLectures(weeks, courseName); + + return { orderedItems, orderedLectures }; +} diff --git a/src/app/course/[courseName]/lecture/[lectureDay]/LectureButtons.tsx b/src/app/course/[courseName]/lecture/[lectureDay]/LectureButtons.tsx index 1bb98df..e96b1bf 100644 --- a/src/app/course/[courseName]/lecture/[lectureDay]/LectureButtons.tsx +++ b/src/app/course/[courseName]/lecture/[lectureDay]/LectureButtons.tsx @@ -9,6 +9,8 @@ import { useCourseContext } from "../../context/courseContext"; import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks"; import { useDeleteLectureMutation } from "@/features/local/lectures/lectureHooks"; import Link from "next/link"; +import { useItemNavigation } from "../../hooks/useItemNavigation"; +import ItemNavigationButtons from "../../components/ItemNavigationButtons"; export default function LectureButtons({ lectureDay }: { lectureDay: string }) { const { courseName } = useCourseContext(); @@ -17,6 +19,7 @@ export default function LectureButtons({ lectureDay }: { lectureDay: string }) { const [isLoading, setIsLoading] = useState(false); const modal = useModal(); const deleteLecture = useDeleteLectureMutation(); + const { previousUrl, nextUrl } = useItemNavigation("lecture", lectureDay); return (
@@ -61,6 +64,7 @@ export default function LectureButtons({ lectureDay }: { lectureDay: string }) { Go Back + {isLoading && }
); diff --git a/src/app/course/[courseName]/modules/[moduleName]/assignment/[assignmentName]/AssignmentFooterButtons.tsx b/src/app/course/[courseName]/modules/[moduleName]/assignment/[assignmentName]/AssignmentFooterButtons.tsx index 239c9bd..cb93233 100644 --- a/src/app/course/[courseName]/modules/[moduleName]/assignment/[assignmentName]/AssignmentFooterButtons.tsx +++ b/src/app/course/[courseName]/modules/[moduleName]/assignment/[assignmentName]/AssignmentFooterButtons.tsx @@ -17,6 +17,8 @@ import { getCourseUrl } from "@/services/urlUtils"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState } from "react"; +import { useItemNavigation } from "../../../../hooks/useItemNavigation"; +import ItemNavigationButtons from "../../../../components/ItemNavigationButtons"; export function AssignmentFooterButtons({ moduleName, @@ -42,6 +44,11 @@ export function AssignmentFooterButtons({ const deleteLocal = useDeleteAssignmentMutation(); const [isLoading, setIsLoading] = useState(false); const modal = useModal(); + const { previousUrl, nextUrl } = useItemNavigation( + "assignment", + assignmentName, + moduleName + ); const assignmentInCanvas = canvasAssignments?.find( (a) => a.name === assignmentName @@ -155,6 +162,7 @@ export function AssignmentFooterButtons({ Go Back + ); diff --git a/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPageButtons.tsx b/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPageButtons.tsx index cd5c662..fade644 100644 --- a/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPageButtons.tsx +++ b/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPageButtons.tsx @@ -17,6 +17,8 @@ import { getCourseUrl } from "@/services/urlUtils"; import Link from "next/link"; import { useRouter } from "next/navigation"; import React, { useState } from "react"; +import { useItemNavigation } from "../../../../hooks/useItemNavigation"; +import ItemNavigationButtons from "../../../../components/ItemNavigationButtons"; export default function EditPageButtons({ moduleName, @@ -36,6 +38,11 @@ export default function EditPageButtons({ const deletePageLocal = useDeletePageMutation(); const modal = useModal(); const [loading, setLoading] = useState(false); + const { previousUrl, nextUrl } = useItemNavigation( + "page", + pageName, + moduleName + ); const pageInCanvas = canvasPages?.find((p) => p.title === pageName); @@ -125,6 +132,7 @@ export default function EditPageButtons({ Go Back + ); } diff --git a/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/QuizButton.tsx b/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/QuizButton.tsx index ded4ed0..12e12fe 100644 --- a/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/QuizButton.tsx +++ b/src/app/course/[courseName]/modules/[moduleName]/quiz/[quizName]/QuizButton.tsx @@ -15,6 +15,8 @@ import { import { getCourseUrl } from "@/services/urlUtils"; import Link from "next/link"; import { useRouter } from "next/navigation"; +import { useItemNavigation } from "../../../../hooks/useItemNavigation"; +import ItemNavigationButtons from "../../../../components/ItemNavigationButtons"; export function QuizButtons({ moduleName, @@ -35,6 +37,11 @@ export function QuizButtons({ const deleteFromCanvas = useDeleteQuizFromCanvasMutation(); const deleteLocal = useDeleteQuizMutation(); const modal = useModal(); + const { previousUrl, nextUrl } = useItemNavigation( + "quiz", + quizName, + moduleName + ); const quizInCanvas = canvasQuizzes?.find((c) => c.title === quizName); @@ -111,6 +118,7 @@ export function QuizButtons({ Go Back + ); diff --git a/src/services/htmlMarkdownUtils.ts b/src/services/htmlMarkdownUtils.ts index 24209f5..876b331 100644 --- a/src/services/htmlMarkdownUtils.ts +++ b/src/services/htmlMarkdownUtils.ts @@ -52,15 +52,18 @@ marked.use({ extensions: [mermaidExtension] }); // The renderer only applies to markdown tables. marked.use({ renderer: { - tablecell({ text, header, align }) { + tablecell(token) { + const content = this.parser.parseInline(token.tokens); + const { header, align } = token; const type = header ? "th" : "td"; const alignAttr = align ? ` align="${align}"` : ""; const scopeAttr = header ? ' scope="col"' : ""; - return `<${type}${scopeAttr}${alignAttr}>${text}\n`; + return `<${type}${scopeAttr}${alignAttr}>${content}\n`; }, }, }); + export function extractImageSources(htmlString: string) { const srcUrls = []; const regex = /]+src=["']?([^"'>]+)["']?/g; diff --git a/src/services/markdownReferenceLinks.test.ts b/src/services/markdownReferenceLinks.test.ts new file mode 100644 index 0000000..35b2ade --- /dev/null +++ b/src/services/markdownReferenceLinks.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest'; +import { markdownToHtmlNoImages } from './htmlMarkdownUtils'; + +describe('markdownToHtmlNoImages reference links', () => { + it('renders reference links inside a table', () => { + const markdown = ` +| Header | +| --- | +| [QuickStart][Fort1] | + +[Fort1]: https://example.com/fort1 +`; + const html = markdownToHtmlNoImages(markdown); + expect(html).toContain('QuickStart'); + }); +});