moving v2 to top level

This commit is contained in:
2024-12-17 09:19:21 -07:00
parent 5f0b3554dc
commit 576ee02afb
468 changed files with 79 additions and 15430 deletions

View File

@@ -0,0 +1,81 @@
"use client";
import { Spinner } from "@/components/Spinner";
import {
canvasAssignmentKeys,
useCanvasAssignmentsQuery,
} from "@/hooks/canvas/canvasAssignmentHooks";
import { canvasCourseKeys } from "@/hooks/canvas/canvasCourseHooks";
import {
canvasCourseModuleKeys,
useCanvasModulesQuery,
} from "@/hooks/canvas/canvasModuleHooks";
import {
canvasPageKeys,
useCanvasPagesQuery,
} from "@/hooks/canvas/canvasPageHooks";
import {
canvasQuizKeys,
useCanvasQuizzesQuery,
} from "@/hooks/canvas/canvasQuizHooks";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import { useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
export function CourseNavigation() {
const [settings] = useLocalCourseSettingsQuery();
const queryClient = useQueryClient();
const canvasAssignmentsQuery = useCanvasAssignmentsQuery();
const canvasAssignmentGroupsQuery = useCanvasAssignmentsQuery();
const canvasModulesQuery = useCanvasModulesQuery();
const canvasPagesQuery = useCanvasPagesQuery();
const canvasQuizzesQuery = useCanvasQuizzesQuery();
return (
<div className="pb-1 ps-5 flex flex-row gap-3">
<Link href={"/"} className="btn">
Back to Course List
</Link>
<a
href={`https://snow.instructure.com/courses/${settings.canvasId}`}
className="btn"
target="_blank"
>
View in Canvas
</a>
{canvasAssignmentsQuery.isFetching ||
canvasAssignmentGroupsQuery.isFetching ||
canvasModulesQuery.isFetching ||
canvasPagesQuery.isFetching ||
canvasQuizzesQuery.isFetching ? (
<div className="flex flex-row">
<Spinner />
<div className="ps-1">loading canvas data</div>
</div>
) : (
<button
className="unstyled btn-outline"
onClick={() => {
queryClient.invalidateQueries({
queryKey: canvasAssignmentKeys.assignments(settings.canvasId),
});
queryClient.invalidateQueries({
queryKey: canvasCourseKeys.assignmentGroups(settings.canvasId),
});
queryClient.invalidateQueries({
queryKey: canvasCourseModuleKeys.modules(settings.canvasId),
});
queryClient.invalidateQueries({
queryKey: canvasPageKeys.pagesInCourse(settings.canvasId),
});
queryClient.invalidateQueries({
queryKey: canvasQuizKeys.quizzes(settings.canvasId),
});
}}
>
Reload Canvas Data
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import Link from "next/link";
import { useCourseContext } from "./context/courseContext";
import { getCourseSettingsUrl } from "@/services/urlUtils";
export default function CourseSettingsLink() {
const { courseName } = useCourseContext();
const [settings] = useLocalCourseSettingsQuery();
return (
<div>
{settings.name}
<Link
className="mx-3 underline"
href={getCourseSettingsUrl(courseName)}
shallow={true}
>
Course Settings
</Link>
</div>
);
}

View File

@@ -0,0 +1,10 @@
"use client"
import { useCourseContext } from "./context/courseContext"
export default function CourseTitle() {
const {courseName}= useCourseContext()
return (
<title>{(process.env.NEXT_PUBLIC_TITLE_PREFIX ?? "")}{courseName}</title>
)
}

View File

@@ -0,0 +1,69 @@
"use client";
import { CalendarMonthModel, getWeekNumber } from "./calendarMonthUtils";
import { DayOfWeek } from "@/models/local/localCourseSettings";
import { Expandable } from "@/components/Expandable";
import { CalendarWeek } from "./CalendarWeek";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import { getDateFromStringOrThrow } from "@/models/local/utils/timeUtils";
export const CalendarMonth = ({ month }: { month: CalendarMonthModel }) => {
// const weekInMilliseconds = 604_800_000;
const four_days_in_milliseconds = 345_600_000;
const [settings] = useLocalCourseSettingsQuery();
const startDate = getDateFromStringOrThrow(
settings.startDate,
"week calculation start date"
);
const pastWeekNumber = getWeekNumber(
startDate,
new Date(Date.now() - four_days_in_milliseconds)
);
const startOfMonthWeekNumber = getWeekNumber(
startDate,
new Date(month.year, month.month, 1)
);
const isInPast = pastWeekNumber >= startOfMonthWeekNumber;
const monthName = new Date(month.year, month.month - 1, 1).toLocaleString(
"default",
{ month: "long" }
);
const weekDaysList: DayOfWeek[] = Object.values(DayOfWeek);
return (
<>
<Expandable
defaultExpanded={!isInPast}
ExpandableElement={({ setIsExpanded }) => (
<div className="flex justify-center">
<h3
className={
"text-2xl transition-all duration-500 " +
"hover:text-slate-50 underline hover:scale-105 "
}
onClick={() => setIsExpanded((e) => !e)}
role="button"
>
{monthName}
</h3>
</div>
)}
>
<div className="grid grid-cols-7 text-center fw-bold ms-3">
{weekDaysList.map((day) => (
<div key={day} className={""}>
<span className="hidden xl:inline">{day}</span>
<span className="xl:hidden inline">{day.slice(0, 3)}</span>
</div>
))}
</div>
{month.daysByWeek.map((week, weekIndex) => (
<CalendarWeek key={weekIndex} week={week} monthNumber={month.month} />
))}
</Expandable>
</>
);
};

View File

@@ -0,0 +1,36 @@
"use client";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import { getDateFromStringOrThrow } from "@/models/local/utils/timeUtils";
import { getWeekNumber } from "./calendarMonthUtils";
import Day from "./day/Day";
export function CalendarWeek({
week,
monthNumber,
}: {
week: string[]; //date strings
monthNumber: number;
}) {
const [settings]= useLocalCourseSettingsQuery();
const startDate = getDateFromStringOrThrow(
settings.startDate,
"week calculation start date"
);
const firstDateString = getDateFromStringOrThrow(
week[0],
"week calculation first day of week"
);
const weekNumber = getWeekNumber(startDate, firstDateString);
return (
<div className="flex flex-row">
<div className="my-auto text-gray-400 w-6 sm:block hidden">
{weekNumber.toString().padStart(2, "0")}
</div>
<div className="grid grid-cols-7 grow">
{week.map((day, dayIndex) => (
<Day key={dayIndex} day={day} month={monthNumber} />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import { getDateFromStringOrThrow } from "@/models/local/utils/timeUtils";
import { getMonthsBetweenDates } from "./calendarMonthUtils";
import { CalendarMonth } from "./CalendarMonth";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import { useMemo } from "react";
import CalendarItemsContextProvider from "../context/CalendarItemsContextProvider";
export default function CourseCalendar() {
const [settings] = useLocalCourseSettingsQuery();
const startDateTime = useMemo(
() => getDateFromStringOrThrow(settings.startDate, "course start date"),
[settings.startDate]
);
const endDateTime = useMemo(
() => getDateFromStringOrThrow(settings.endDate, "course end date"),
[settings.endDate]
);
const months = useMemo(
() => getMonthsBetweenDates(startDateTime, endDateTime),
[endDateTime, startDateTime]
);
return (
<div
className="
min-h-0
flex-grow
border-4
border-gray-900
rounded-lg
bg-slate-950
sm:p-1
"
>
<div className="h-full overflow-y-scroll sm:pe-1">
<CalendarItemsContextProvider>
{months.map((month) => (
<CalendarMonth key={month.month + "" + month.year} month={month} />
))}
</CalendarItemsContextProvider>
</div>
</div>
);
}

View File

@@ -0,0 +1,108 @@
import {
dateToMarkdownString,
getDateFromStringOrThrow,
} from "@/models/local/utils/timeUtils";
export interface CalendarMonthModel {
year: number;
month: number;
weeks: number[][];
daysByWeek: string[][]; //iso date is memo-izable
}
function weeksInMonth(year: number, month: number): number {
const firstDayOfMonth = new Date(year, month - 1, 1).getDay();
const daysInMonth = new Date(year, month, 0).getDate();
const longDaysInMonth = daysInMonth + firstDayOfMonth;
let weeks = Math.floor(longDaysInMonth / 7);
if (longDaysInMonth % 7 > 0) {
weeks += 1;
}
return weeks;
}
function createCalendarMonth(year: number, month: number): CalendarMonthModel {
const weeksNumber = weeksInMonth(year, month);
const daysInMonth = new Date(year, month, 0).getDate();
let currentDay = 1;
const firstDayOfMonth = new Date(year, month - 1, 1).getDay();
const daysByWeek = Array.from({ length: weeksNumber })
.map((_, weekIndex) =>
Array.from({ length: 7 }).map((_, dayIndex) => {
if (weekIndex === 0 && dayIndex < firstDayOfMonth) {
return dateToMarkdownString(
new Date(year, month - 1, dayIndex - firstDayOfMonth + 1, 12, 0, 0)
);
} else if (currentDay <= daysInMonth) {
return dateToMarkdownString(
new Date(year, month - 1, currentDay++, 12, 0, 0)
);
} else {
currentDay++;
return dateToMarkdownString(
new Date(year, month, currentDay - daysInMonth - 1, 12, 0, 0)
);
}
})
)
.filter((week) => {
const lastDate = getDateFromStringOrThrow(
week.at(-1)!,
"filtering out last week of month"
);
return lastDate.getMonth() <= month - 1;
});
const weeks = daysByWeek.map((week) =>
week.map((day) =>
getDateFromStringOrThrow(day, "calculating weeks").getDate()
)
);
return { year, month, weeks, daysByWeek };
}
export function getMonthsBetweenDates(
startDate: Date,
endDate: Date
): CalendarMonthModel[] {
const monthsInTerm =
1 +
(endDate.getFullYear() - startDate.getFullYear()) * 12 +
endDate.getMonth() -
startDate.getMonth();
return Array.from({ length: monthsInTerm }, (_, monthDiff) => {
const month = ((startDate.getMonth() + monthDiff) % 12) + 1;
const year =
startDate.getFullYear() +
Math.floor((startDate.getMonth() + monthDiff) / 12);
return createCalendarMonth(year, month);
});
}
export const getWeekNumber = (startDate: Date, currentDate: Date) => {
const sundayBeforeStartDate = getPreviousSunday(startDate);
const daysBetween = daysBetweenDates(sundayBeforeStartDate, currentDate);
const weeksDiff = Math.floor(daysBetween / 7);
if (weeksDiff >= 0) return weeksDiff + 1;
return weeksDiff;
};
const daysBetweenDates = (startDate: Date, endDate: Date) => {
const diffInTime = endDate.getTime() - startDate.getTime();
const diffInDays = diffInTime / (1000 * 3600 * 24);
return Math.floor(diffInDays);
};
const getPreviousSunday = (date: Date) => {
const result = new Date(date);
const dayOfWeek = result.getDay();
result.setDate(result.getDate() - dayOfWeek);
return result;
};

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { getWeekNumber } from "./calendarMonthUtils";
// months are 0 based, days are 1 based
describe("testing week numbers", () => {
it("can get before first day", () => {
const startDate = new Date(2024, 8, 3);
const firstDayOfFirstWeek = new Date(2024, 8, 1);
const weekNumber = getWeekNumber(startDate, firstDayOfFirstWeek);
expect(weekNumber).toBe(1);
});
it("can get end of first week", () => {
const startDate = new Date(2024, 8, 3);
const firstDayOfFirstWeek = new Date(2024, 8, 7);
const weekNumber = getWeekNumber(startDate, firstDayOfFirstWeek);
expect(weekNumber).toBe(1);
});
it("can get start of second week", () => {
const startDate = new Date(2024, 8, 3);
const firstDayOfFirstWeek = new Date(2024, 8, 8);
const weekNumber = getWeekNumber(startDate, firstDayOfFirstWeek);
expect(weekNumber).toBe(2);
});
it("can get start of third week", () => {
const startDate = new Date(2024, 8, 3);
const firstDayOfFirstWeek = new Date(2024, 8, 15);
const weekNumber = getWeekNumber(startDate, firstDayOfFirstWeek);
expect(weekNumber).toBe(3);
});
it("can get previous week", () => {
const startDate = new Date(2024, 8, 3);
const firstDayOfFirstWeek = new Date(2024, 7, 29);
const weekNumber = getWeekNumber(startDate, firstDayOfFirstWeek);
expect(weekNumber).toBe(-1);
});
});

View File

@@ -0,0 +1,126 @@
"use client";
import {
getDateFromStringOrThrow,
getDateOnlyMarkdownString,
} from "@/models/local/utils/timeUtils";
import { useDraggingContext } from "../../context/drag/draggingContext";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import { getDayOfWeek } from "@/models/local/localCourseSettings";
import { ItemInDay } from "./ItemInDay";
import { useTodaysItems } from "./useTodaysItems";
import { DayTitle } from "./DayTitle";
export default function Day({ day, month }: { day: string; month: number }) {
const dayAsDate = getDateFromStringOrThrow(
day,
"calculating same month in day"
);
const isToday =
getDateOnlyMarkdownString(new Date()) ===
getDateOnlyMarkdownString(dayAsDate);
const [settings] = useLocalCourseSettingsQuery();
const { itemDropOnDay } = useDraggingContext();
const { todaysAssignments, todaysQuizzes, todaysPages } = useTodaysItems(day);
const isInSameMonth = dayAsDate.getMonth() + 1 == month;
const classOnThisDay = settings.daysOfWeek.includes(getDayOfWeek(dayAsDate));
// maybe this is slow?
const holidayNameToday = settings.holidays.reduce(
(holidaysHappeningToday, holiday) => {
const holidayDates = holiday.days.map((d) =>
getDateOnlyMarkdownString(
getDateFromStringOrThrow(d, "holiday date in day component")
)
);
const today = getDateOnlyMarkdownString(dayAsDate);
if (holidayDates.includes(today))
return [...holidaysHappeningToday, holiday.name];
return holidaysHappeningToday;
},
[] as string[]
);
const semesterStart = getDateFromStringOrThrow(
settings.startDate,
"comparing start date in day"
);
const semesterEnd = getDateFromStringOrThrow(
settings.endDate,
"comparing end date in day"
);
const isInSemester = semesterStart < dayAsDate && semesterEnd > dayAsDate;
const meetingClasses =
classOnThisDay && isInSemester && holidayNameToday.length === 0
? " bg-slate-900 "
: " ";
const todayClasses = isToday
? " border border-blue-700 shadow-[0_0px_10px_0px] shadow-blue-500/50 "
: " ";
const monthClass =
isInSameMonth && !isToday ? " border border-slate-700 " : " ";
return (
<div
className={
" rounded-lg sm:m-1 m-0.5 min-h-10 " +
meetingClasses +
monthClass +
todayClasses
}
onDrop={(e) => itemDropOnDay(e, day)}
onDragOver={(e) => e.preventDefault()}
>
<div className="draggingDay flex flex-col">
<DayTitle day={day} dayAsDate={dayAsDate} />
<div className="flex-grow">
{todaysAssignments.map(
({ assignment, moduleName, status, message }) => (
<ItemInDay
key={assignment.name}
type={"assignment"}
moduleName={moduleName}
item={assignment}
status={status}
message={message}
/>
)
)}
{todaysQuizzes.map(({ quiz, moduleName, status, message }) => (
<ItemInDay
key={quiz.name}
type={"quiz"}
moduleName={moduleName}
item={quiz}
status={status}
message={message}
/>
))}
{todaysPages.map(({ page, moduleName, status, message }) => (
<ItemInDay
key={page.name}
type={"page"}
moduleName={moduleName}
item={page}
status={status}
message={message}
/>
))}
</div>
<div>
{holidayNameToday.map((n) => (
<div key={n} className="font-extrabold text-blue-100 text-center">
{n}
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import Modal, { useModal } from "@/components/Modal";
import { getLectureUrl } from "@/services/urlUtils";
import Link from "next/link";
import { useCourseContext } from "../../context/courseContext";
import NewItemForm from "../../modules/NewItemForm";
import { DraggableItem } from "../../context/drag/draggingContext";
import { useDragStyleContext } from "../../context/drag/dragStyleContext";
import { getLectureForDay } from "@/models/local/utils/lectureUtils";
import { useLecturesSuspenseQuery } from "@/hooks/localCourse/lectureHooks";
export function DayTitle({ day, dayAsDate }: { day: string; dayAsDate: Date }) {
const { courseName } = useCourseContext();
const [weeks] = useLecturesSuspenseQuery();
const { setIsDragging } = useDragStyleContext();
const todaysLecture = getLectureForDay(weeks, dayAsDate);
const modal = useModal();
const lectureName = todaysLecture && (todaysLecture.name || "lecture");
return (
<div className="flex justify-between">
<Link
className="ms-1 me-1 truncate text-nowrap transition-all hover:font-bold hover:text-slate-300"
href={getLectureUrl(courseName, day)}
prefetch={false}
draggable={true}
onDragStart={(e) => {
if (todaysLecture) {
const draggableItem: DraggableItem = {
type: "lecture",
item: { ...todaysLecture, dueAt: todaysLecture.date },
sourceModuleName: undefined,
};
e.dataTransfer.setData(
"draggableItem",
JSON.stringify(draggableItem)
);
setIsDragging(true);
}
}}
>
{dayAsDate.getDate()} {lectureName}
</Link>
<Modal
modalControl={modal}
buttonText="+"
buttonClass="unstyled hover:font-bold hover:scale-125 px-1 mb-auto mt-0 pt-0"
>
{({ closeModal }) => (
<div>
<NewItemForm creationDate={day} onCreate={closeModal} />
<br />
<button onClick={closeModal}>close</button>
</div>
)}
</Modal>
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { IModuleItem } from "@/models/local/IModuleItem";
import { getModuleItemUrl } from "@/services/urlUtils";
import Link from "next/link";
import { ReactNode, useEffect, useRef, useState } from "react";
import { useCourseContext } from "../../context/courseContext";
import {
useDraggingContext,
DraggableItem,
} from "../../context/drag/draggingContext";
import { createPortal } from "react-dom";
import ClientOnly from "@/components/ClientOnly";
import { useDragStyleContext } from "../../context/drag/dragStyleContext";
export function ItemInDay({
type,
moduleName,
status,
item,
message,
}: {
type: "assignment" | "page" | "quiz";
status: "localOnly" | "incomplete" | "published";
moduleName: string;
item: IModuleItem;
message: ReactNode;
}) {
const { courseName } = useCourseContext();
const { setIsDragging } = useDragStyleContext();
const linkRef = useRef<HTMLAnchorElement>(null);
const [tooltipVisible, setTooltipVisible] = useState(false);
return (
<div className={" relative group "}>
<Link
href={getModuleItemUrl(courseName, moduleName, type, item.name)}
shallow={true}
className={
" border rounded-sm sm:px-1 sm:mx-1 break-words mb-1 truncate sm:text-wrap text-nowrap " +
" bg-slate-800 " +
" block " +
(status === "localOnly" && " text-slate-500 border-slate-600 ") +
(status === "incomplete" && " border-rose-900 ") +
(status === "published" && " border-green-800 ")
}
role="button"
draggable="true"
onDragStart={(e) => {
const draggableItem: DraggableItem = {
type,
item,
sourceModuleName: moduleName,
};
e.dataTransfer.setData(
"draggableItem",
JSON.stringify(draggableItem)
);
setIsDragging(true)
}}
onMouseEnter={() => setTooltipVisible(true)}
onMouseLeave={() => setTooltipVisible(false)}
ref={linkRef}
>
{item.name}
</Link>
<ClientOnly>
<Tooltip
message={message}
targetRef={linkRef}
visible={tooltipVisible && status === "incomplete"}
/>
</ClientOnly>
</div>
);
}
const Tooltip: React.FC<{
message: ReactNode;
targetRef: React.RefObject<HTMLElement>;
visible: boolean;
}> = ({ message, targetRef, visible }) => {
const rect = targetRef.current?.getBoundingClientRect();
return createPortal(
<div
style={{
top: (rect?.bottom ?? 0) + window.scrollY + 10,
left: (rect?.left ?? 0) + window.scrollX + (rect?.width ?? 0) / 2,
}}
className={
" absolute -translate-x-1/2 " +
" bg-gray-800 text-white text-sm " +
" rounded py-1 px-2 " +
" transition-all duration-400 " +
" border border-slate-700 shadow-[0_0px_10px_0px] shadow-slate-500/50 " +
(visible ? " " : " hidden -z-50 ")
}
role="tooltip"
>
{message}
</div>,
document.body
);
};

View File

@@ -0,0 +1,119 @@
"use client";
import { CanvasAssignment } from "@/models/canvas/assignments/canvasAssignment";
import { CanvasPage } from "@/models/canvas/pages/canvasPageModel";
import { CanvasQuiz } from "@/models/canvas/quizzes/canvasQuizModel";
import { LocalAssignment } from "@/models/local/assignment/localAssignment";
import { LocalCourseSettings } from "@/models/local/localCourseSettings";
import { LocalCoursePage } from "@/models/local/page/localCoursePage";
import { LocalQuiz } from "@/models/local/quiz/localQuiz";
import {
dateToMarkdownString,
getDateFromStringOrThrow,
} from "@/models/local/utils/timeUtils";
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
import { htmlIsCloseEnough } from "@/services/utils/htmlIsCloseEnough";
import { ReactNode } from "react";
export const getStatus = ({
item,
canvasItem,
type,
settings,
}: {
item: LocalQuiz | LocalAssignment | LocalCoursePage;
canvasItem?: CanvasQuiz | CanvasAssignment | CanvasPage;
type: "assignment" | "page" | "quiz";
settings: LocalCourseSettings;
}): {
status: "localOnly" | "incomplete" | "published";
message: ReactNode;
} => {
if (!canvasItem) return { status: "localOnly", message: "not in canvas" };
if (!canvasItem.published)
return { status: "incomplete", message: "not published in canvas" };
if (type === "page") {
const canvasPage = canvasItem as CanvasPage;
const page = item as LocalCoursePage;
if (!canvasPage.published)
return { status: "incomplete", message: "canvas page not published" };
return { status: "published", message: "" };
} else if (type === "quiz") {
const quiz = item as LocalQuiz;
const canvasQuiz = canvasItem as CanvasQuiz;
if (!canvasQuiz.due_at)
return { status: "incomplete", message: "due date not in canvas" };
if (quiz.lockAt && !canvasQuiz.lock_at)
return { status: "incomplete", message: "lock date not in canvas" };
const localDueDate = dateToMarkdownString(
getDateFromStringOrThrow(quiz.dueAt, "comparing due dates for day")
);
const canvasDueDate = dateToMarkdownString(
getDateFromStringOrThrow(
canvasQuiz.due_at,
"comparing canvas due date for day"
)
);
if (localDueDate !== canvasDueDate) {
return {
status: "incomplete",
message: (
<div>
due date different
<div>{localDueDate}</div>
<div>{canvasDueDate}</div>
</div>
),
};
}
} else if (type === "assignment") {
const assignment = item as LocalAssignment;
const canvasAssignment = canvasItem as CanvasAssignment;
if (!canvasAssignment.due_at)
return { status: "incomplete", message: "due date not in canvas" };
if (assignment.lockAt && !canvasAssignment.lock_at)
return { status: "incomplete", message: "lock date not in canvas" };
const localDueDate = dateToMarkdownString(
getDateFromStringOrThrow(assignment.dueAt, "comparing due dates for day")
);
const canvasDueDate = dateToMarkdownString(
getDateFromStringOrThrow(
canvasAssignment.due_at,
"comparing canvas due date for day"
)
);
if (localDueDate !== canvasDueDate)
return {
status: "incomplete",
message: (
<div>
due date different
<div>{localDueDate}</div>
<div>{canvasDueDate}</div>
</div>
),
};
const htmlIsSame = htmlIsCloseEnough(
markdownToHTMLSafe(assignment.description, settings),
canvasAssignment.description
);
if (!htmlIsSame)
return {
status: "incomplete",
message: "Canvas description is different",
};
}
return { status: "published", message: "" };
};

View File

@@ -0,0 +1,101 @@
"use client";
import { useCanvasAssignmentsQuery } from "@/hooks/canvas/canvasAssignmentHooks";
import { useCanvasPagesQuery } from "@/hooks/canvas/canvasPageHooks";
import { useCanvasQuizzesQuery } from "@/hooks/canvas/canvasQuizHooks";
import { LocalAssignment } from "@/models/local/assignment/localAssignment";
import { LocalCoursePage } from "@/models/local/page/localCoursePage";
import { LocalQuiz } from "@/models/local/quiz/localQuiz";
import {
getDateFromStringOrThrow,
getDateOnlyMarkdownString,
} from "@/models/local/utils/timeUtils";
import { ReactNode } from "react";
import { useCalendarItemsContext } from "../../context/calendarItemsContext";
import { getStatus } from "./getStatus";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
export function useTodaysItems(day: string) {
const [settings] = useLocalCourseSettingsQuery();
const dayAsDate = getDateFromStringOrThrow(
day,
"calculating same month in day items"
);
const itemsContext = useCalendarItemsContext();
const dateKey = getDateOnlyMarkdownString(dayAsDate);
const todaysModules = itemsContext[dateKey];
const { data: canvasAssignments } = useCanvasAssignmentsQuery();
const { data: canvasQuizzes } = useCanvasQuizzesQuery();
const { data: canvasPages } = useCanvasPagesQuery();
const todaysAssignments: {
moduleName: string;
assignment: LocalAssignment;
status: "localOnly" | "incomplete" | "published";
message: ReactNode;
}[] = todaysModules
? Object.keys(todaysModules).flatMap((moduleName) =>
todaysModules[moduleName].assignments.map((assignment) => {
const canvasAssignment = canvasAssignments?.find(
(c) => c.name === assignment.name
);
return {
moduleName,
assignment,
...getStatus({
item: assignment,
canvasItem: canvasAssignment,
type: "assignment",
settings,
}),
};
})
)
: [];
const todaysQuizzes: {
moduleName: string;
quiz: LocalQuiz;
status: "localOnly" | "incomplete" | "published";
message: ReactNode;
}[] = todaysModules
? Object.keys(todaysModules).flatMap((moduleName) =>
todaysModules[moduleName].quizzes.map((quiz) => {
const canvasQuiz = canvasQuizzes?.find((q) => q.title === quiz.name);
return {
moduleName,
quiz,
...getStatus({
item: quiz,
canvasItem: canvasQuiz,
type: "quiz",
settings,
}),
};
})
)
: [];
const todaysPages: {
moduleName: string;
page: LocalCoursePage;
status: "localOnly" | "incomplete" | "published";
message: ReactNode;
}[] = todaysModules
? Object.keys(todaysModules).flatMap((moduleName) =>
todaysModules[moduleName].pages.map((page) => {
const canvasPage = canvasPages?.find((p) => p.title === page.name);
return {
moduleName,
page,
...getStatus({
item: page,
canvasItem: canvasPage,
type: "page",
settings,
}),
};
})
)
: [];
return { todaysAssignments, todaysQuizzes, todaysPages };
}

View File

@@ -0,0 +1,148 @@
import { ReactNode } from "react";
import {
CalendarItemsContext,
CalendarItemsInterface,
} from "./calendarItemsContext";
import {
getDateFromStringOrThrow,
getDateOnlyMarkdownString,
} from "@/models/local/utils/timeUtils";
import { useAllCourseDataQuery } from "@/hooks/localCourse/localCourseModuleHooks";
import { trpc } from "@/services/serverFunctions/trpcClient";
export default function CalendarItemsContextProvider({
children,
}: {
children: ReactNode;
}) {
const { assignmentsAndModules, quizzesAndModules, pagesAndModules } =
useAllCourseDataQuery();
const assignmentsByModuleByDate = assignmentsAndModules.reduce(
(previous, { assignment, moduleName }) => {
const dueDay = getDateOnlyMarkdownString(
getDateFromStringOrThrow(
assignment.dueAt,
"due at for assignment in items context"
)
);
const previousModules = previous[dueDay] ?? {};
const previousModule = previousModules[moduleName] ?? {
assignments: [],
};
const updatedModule = {
...previousModule,
assignments: [...previousModule.assignments, assignment],
};
return {
...previous,
[dueDay]: {
...previousModules,
[moduleName]: updatedModule,
},
};
},
{} as CalendarItemsInterface
);
const quizzesByModuleByDate = quizzesAndModules.reduce(
(previous, { quiz, moduleName }) => {
const dueDay = getDateOnlyMarkdownString(
getDateFromStringOrThrow(quiz.dueAt, "due at for quiz in items context")
);
const previousModules = previous[dueDay] ?? {};
const previousModule = previousModules[moduleName] ?? {
quizzes: [],
};
const updatedModule = {
...previousModule,
quizzes: [...previousModule.quizzes, quiz],
};
return {
...previous,
[dueDay]: {
...previousModules,
[moduleName]: updatedModule,
},
};
},
{} as CalendarItemsInterface
);
const pagesByModuleByDate = pagesAndModules.reduce(
(previous, { page, moduleName }) => {
const dueDay = getDateOnlyMarkdownString(
getDateFromStringOrThrow(page.dueAt, "due at for quiz in items context")
);
const previousModules = previous[dueDay] ?? {};
const previousModule = previousModules[moduleName] ?? {
pages: [],
};
const updatedModule = {
...previousModule,
pages: [...previousModule.pages, page],
};
return {
...previous,
[dueDay]: {
...previousModules,
[moduleName]: updatedModule,
},
};
},
{} as CalendarItemsInterface
);
const allDays = [
...new Set([
...Object.keys(assignmentsByModuleByDate),
...Object.keys(quizzesByModuleByDate),
...Object.keys(pagesByModuleByDate),
]),
];
const allItemsByModuleByDate = allDays.reduce((prev, day) => {
const assignmentModulesInDay = assignmentsByModuleByDate[day] ?? {};
const quizModulesInDay = quizzesByModuleByDate[day] ?? {};
const pageModulesInDay = pagesByModuleByDate[day] ?? {};
const allModules = [
...new Set([
...Object.keys(assignmentModulesInDay),
...Object.keys(quizModulesInDay),
...Object.keys(pageModulesInDay),
]),
];
const modulesInDate = allModules.reduce((prev, moduleName) => {
return {
...prev,
[moduleName]: {
assignments: assignmentModulesInDay[moduleName]
? assignmentModulesInDay[moduleName].assignments
: [],
quizzes: quizModulesInDay[moduleName]
? quizModulesInDay[moduleName].quizzes
: [],
pages: pageModulesInDay[moduleName]
? pageModulesInDay[moduleName].pages
: [],
},
};
}, {});
return { ...prev, [day]: modulesInDate };
}, {} as CalendarItemsInterface);
return (
<CalendarItemsContext.Provider value={allItemsByModuleByDate}>
{children}
</CalendarItemsContext.Provider>
);
}

View File

@@ -0,0 +1,21 @@
"use client"
import { ReactNode } from "react";
import { CourseContext } from "./courseContext";
export default function CourseContextProvider({
localCourseName,
children,
}: {
children: ReactNode;
localCourseName: string;
}) {
return (
<CourseContext.Provider
value={{
courseName: localCourseName,
}}
>
{children}
</CourseContext.Provider>
);
}

View File

@@ -0,0 +1,39 @@
"use client";
import Modal, { ModalControl } from "@/components/Modal";
import { Spinner } from "@/components/Spinner";
export function LectureReplaceModal({
modal, modalText, modalCallback, isLoading,
}: {
modal: ModalControl;
modalText: string;
modalCallback: () => void;
isLoading: boolean;
}) {
return (
<Modal modalControl={modal} buttonText={""} buttonClass="hidden">
{({ closeModal }) => (
<div>
<div className="text-center">{modalText}</div>
<br />
<div className="flex justify-around gap-3">
<button
onClick={() => {
console.log("deleting");
modalCallback();
}}
disabled={isLoading}
className="btn-danger"
>
Yes
</button>
<button onClick={closeModal} disabled={isLoading}>
No
</button>
</div>
{isLoading && <Spinner />}
</div>
)}
</Modal>
);
}

View File

@@ -0,0 +1,22 @@
import { LocalAssignment } from "@/models/local/assignment/localAssignment";
import { LocalCoursePage } from "@/models/local/page/localCoursePage";
import { LocalQuiz } from "@/models/local/quiz/localQuiz";
import { createContext, useContext } from "react";
export interface CalendarItemsInterface {
[
key: string // representing a date
]: {
[moduleName: string]: {
assignments: LocalAssignment[];
quizzes: LocalQuiz[];
pages: LocalCoursePage[];
};
};
}
export const CalendarItemsContext = createContext<CalendarItemsInterface>({});
export function useCalendarItemsContext() {
return useContext(CalendarItemsContext);
}

View File

@@ -0,0 +1,18 @@
"use client";
import { createContext, useContext } from "react";
export interface CourseContextInterface {
courseName: string;
}
const defaultValue: CourseContextInterface = {
courseName: "",
};
export const CourseContext =
createContext<CourseContextInterface>(defaultValue);
export function useCourseContext() {
return useContext(CourseContext);
}

View File

@@ -0,0 +1,65 @@
"use client";
import { ReactNode, useEffect, useState } from "react";
import { DraggingContext } from "./draggingContext";
import { useDragStyleContext } from "./dragStyleContext";
import { useModal } from "@/components/Modal";
import { LectureReplaceModal } from "../LectureReplaceModal";
import { useItemDropOnModule } from "./useItemDropOnModule";
import { useItemDropOnDay } from "./useItemDropOnDay";
export default function DraggingContextProvider({
children,
}: {
children: ReactNode;
}) {
const { setIsDragging } = useDragStyleContext();
const [isLoading, setIsLoading] = useState(false);
const [modalText, setModalText] = useState("");
const modal = useModal();
const [modalCallback, setModalCallback] = useState<() => void>(() => {});
useEffect(() => {
const handleDrop = () => {
console.log("drop on window");
setIsDragging(false);
};
const preventDefault = (e: globalThis.DragEvent) => e.preventDefault();
if (typeof window !== "undefined") {
window.addEventListener("drop", handleDrop);
window.addEventListener("dragover", preventDefault);
}
return () => {
window.removeEventListener("drop", handleDrop);
window.addEventListener("dragover", preventDefault);
};
}, [setIsDragging]);
const itemDropOnModule = useItemDropOnModule({
setIsDragging,
});
const itemDropOnDay = useItemDropOnDay({
setIsDragging,
setModalText,
setModalCallback,
setIsLoading,
modal,
});
return (
<DraggingContext.Provider
value={{
itemDropOnDay,
itemDropOnModule,
}}
>
<LectureReplaceModal
modal={modal}
modalText={modalText}
modalCallback={modalCallback}
isLoading={isLoading}
/>
{children}
</DraggingContext.Provider>
);
}

View File

@@ -0,0 +1,39 @@
"use client";
import {
createContext,
useContext,
ReactNode,
useState,
SetStateAction,
Dispatch,
} from "react";
export interface DraggingStyleContextInterface {
setIsDragging: Dispatch<SetStateAction<boolean>>;
}
const defaultDraggingValue: DraggingStyleContextInterface = {
setIsDragging: () => {},
};
export const DragStyleContext =
createContext<DraggingStyleContextInterface>(defaultDraggingValue);
export function useDragStyleContext() {
return useContext(DragStyleContext);
}
export function DragStyleContextProvider({
children,
}: {
children: ReactNode;
}) {
const [isDragging, setIsDragging] = useState(false);
return (
<DragStyleContext.Provider value={{ setIsDragging }}>
<div
className={"h-full flex flex-col " + (isDragging ? " dragging " : "")}
>
{children}
</div>
</DragStyleContext.Provider>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
import { IModuleItem } from "@/models/local/IModuleItem";
import { createContext, useContext, DragEvent } from "react";
export interface DraggableItem {
item: IModuleItem;
sourceModuleName: string | undefined; // undefined for lectures
type: "quiz" | "assignment" | "page" | "lecture";
}
export interface DraggingContextInterface {
itemDropOnDay: (e: DragEvent, droppedOnDay: string) => void;
itemDropOnModule: (e: DragEvent, moduleName: string) => void;
}
const defaultDraggingValue: DraggingContextInterface = {
itemDropOnDay: () => {},
itemDropOnModule: () => {},
};
export const DraggingContext =
createContext<DraggingContextInterface>(defaultDraggingValue);
export function useDraggingContext() {
return useContext(DraggingContext);
}

View File

@@ -0,0 +1,25 @@
"use client";
import { getDateFromStringOrThrow, dateToMarkdownString } from "@/models/local/utils/timeUtils";
export function getNewLockDate(
originalDueDate: string,
originalLockDate: string | undefined,
dayAsDate: Date
): string | undefined {
// todo: preserve previous due date / lock date offset
const dueDate = getDateFromStringOrThrow(originalDueDate, "dueAt date");
const lockDate = originalLockDate === undefined
? undefined
: getDateFromStringOrThrow(originalLockDate, "lockAt date");
const originalOffset = lockDate === undefined ? undefined : lockDate.getTime() - dueDate.getTime();
const newLockDate = originalOffset === undefined
? undefined
: new Date(dayAsDate.getTime() + originalOffset);
return newLockDate === undefined
? undefined
: dateToMarkdownString(newLockDate);
}

View File

@@ -0,0 +1,212 @@
"use client";
import { useUpdateAssignmentMutation } from "@/hooks/localCourse/assignmentHooks";
import {
useLecturesSuspenseQuery,
useLectureUpdateMutation,
} from "@/hooks/localCourse/lectureHooks";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import { useUpdatePageMutation } from "@/hooks/localCourse/pageHooks";
import { LocalAssignment } from "@/models/local/assignment/localAssignment";
import { Lecture } from "@/models/local/lecture";
import { getLectureForDay } from "@/models/local/utils/lectureUtils";
import { LocalCoursePage } from "@/models/local/page/localCoursePage";
import { LocalQuiz } from "@/models/local/quiz/localQuiz";
import {
getDateFromStringOrThrow,
getDateOnlyMarkdownString,
dateToMarkdownString,
} from "@/models/local/utils/timeUtils";
import { Dispatch, SetStateAction, useCallback, DragEvent } from "react";
import { DraggableItem } from "./draggingContext";
import { getNewLockDate } from "./getNewLockDate";
import { useUpdateQuizMutation } from "@/hooks/localCourse/quizHooks";
import { useCourseContext } from "../courseContext";
export function useItemDropOnDay({
setIsDragging,
setModalText,
setModalCallback,
setIsLoading,
modal,
}: {
setIsDragging: Dispatch<SetStateAction<boolean>>;
setModalText: Dispatch<SetStateAction<string>>;
setModalCallback: Dispatch<SetStateAction<() => void>>;
setIsLoading: Dispatch<SetStateAction<boolean>>;
modal: { isOpen: boolean; openModal: () => void; closeModal: () => void };
}) {
const [settings] = useLocalCourseSettingsQuery();
const { courseName } = useCourseContext();
// const { data: weeks } = useLecturesByWeekQuery();
const [weeks] = useLecturesSuspenseQuery();
const updateQuizMutation = useUpdateQuizMutation();
const updateLectureMutation = useLectureUpdateMutation();
const updateAssignmentMutation = useUpdateAssignmentMutation();
const updatePageMutation = useUpdatePageMutation();
return useCallback(
(e: DragEvent, day: string) => {
const rawData = e.dataTransfer.getData("draggableItem");
if (!rawData) return;
const itemBeingDragged: DraggableItem = JSON.parse(rawData);
if (itemBeingDragged) {
const dayAsDate = getDateWithDefaultDueTime();
if (itemBeingDragged.type === "quiz") {
updateQuiz(dayAsDate);
} else if (itemBeingDragged.type === "assignment") {
updateAssignment(dayAsDate);
} else if (itemBeingDragged.type === "page") {
updatePage(dayAsDate);
} else if (itemBeingDragged.type === "lecture") {
updateLecture(dayAsDate);
}
}
setIsDragging(false);
function getDateWithDefaultDueTime() {
const dayAsDate = getDateFromStringOrThrow(day, "in drop callback");
dayAsDate.setHours(settings.defaultDueTime.hour);
dayAsDate.setMinutes(settings.defaultDueTime.minute);
dayAsDate.setSeconds(0);
return dayAsDate;
}
function updateLecture(dayAsDate: Date) {
const { dueAt, ...lecture } = itemBeingDragged.item as Lecture & {
dueAt: string;
};
console.log("dropped lecture on day");
const existingLecture = getLectureForDay(weeks, dayAsDate);
if (existingLecture) {
console.log("attempting to drop on existing lecture");
setModalText(
`Are you sure you want to replace ${
existingLecture?.name || "Un-named Lecture"
} with ${lecture.name}? This will delete ${
existingLecture?.name || "Un-named Lecture"
}.`
);
setModalCallback(() => async () => {
// because sometimes setStates receive a function
console.log("running callback");
setIsLoading(true);
await updateLectureMutation.mutateAsync({
previousDay: lecture.date,
lecture: {
...lecture,
date: getDateOnlyMarkdownString(dayAsDate),
},
courseName,
settings,
});
setModalText("");
setModalCallback(() => {});
modal.closeModal();
setIsLoading(false);
});
modal.openModal();
} else {
console.log("updating lecture on unique day");
updateLectureMutation.mutate({
previousDay: lecture.date,
lecture: {
...lecture,
date: getDateOnlyMarkdownString(dayAsDate),
},
courseName,
settings,
});
}
}
function updateQuiz(dayAsDate: Date) {
const previousQuiz = itemBeingDragged.item as LocalQuiz;
if (!itemBeingDragged.sourceModuleName) {
console.error(
"error dropping quiz on day, sourceModuleName is undefined"
);
return;
}
const quiz: LocalQuiz = {
...previousQuiz,
dueAt: dateToMarkdownString(dayAsDate),
lockAt: getNewLockDate(
previousQuiz.dueAt,
previousQuiz.lockAt,
dayAsDate
),
};
updateQuizMutation.mutate({
quiz,
quizName: quiz.name,
moduleName: itemBeingDragged.sourceModuleName,
previousModuleName: itemBeingDragged.sourceModuleName,
previousQuizName: quiz.name,
courseName: settings.name,
});
}
function updatePage(dayAsDate: Date) {
const previousPage = itemBeingDragged.item as LocalCoursePage;
if (!itemBeingDragged.sourceModuleName) {
console.error(
"error dropping page on day, sourceModuleName is undefined"
);
return;
}
const page: LocalCoursePage = {
...previousPage,
dueAt: dateToMarkdownString(dayAsDate),
};
updatePageMutation.mutate({
page,
moduleName: itemBeingDragged.sourceModuleName,
pageName: page.name,
previousPageName: page.name,
previousModuleName: itemBeingDragged.sourceModuleName,
courseName: settings.name,
});
}
function updateAssignment(dayAsDate: Date) {
if (!itemBeingDragged.sourceModuleName) {
console.error(
"error dropping assignment on day, sourceModuleName is undefined"
);
return;
}
const previousAssignment = itemBeingDragged.item as LocalAssignment;
const assignment: LocalAssignment = {
...previousAssignment,
dueAt: dateToMarkdownString(dayAsDate),
lockAt: getNewLockDate(
previousAssignment.dueAt,
previousAssignment.lockAt,
dayAsDate
),
};
updateAssignmentMutation.mutate({
assignment,
previousModuleName: itemBeingDragged.sourceModuleName,
moduleName: itemBeingDragged.sourceModuleName,
assignmentName: assignment.name,
previousAssignmentName: assignment.name,
courseName: settings.name,
});
}
},
[
courseName,
modal,
setIsDragging,
setIsLoading,
setModalCallback,
setModalText,
settings,
updateAssignmentMutation,
updateLectureMutation,
updatePageMutation,
updateQuizMutation,
weeks,
]
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import { useUpdateAssignmentMutation } from "@/hooks/localCourse/assignmentHooks";
import { useUpdatePageMutation } from "@/hooks/localCourse/pageHooks";
import { LocalAssignment } from "@/models/local/assignment/localAssignment";
import { LocalCoursePage } from "@/models/local/page/localCoursePage";
import { LocalQuiz } from "@/models/local/quiz/localQuiz";
import { Dispatch, SetStateAction, useCallback, DragEvent } from "react";
import { DraggableItem } from "./draggingContext";
import { useCourseContext } from "../courseContext";
import { useUpdateQuizMutation } from "@/hooks/localCourse/quizHooks";
export function useItemDropOnModule({
setIsDragging,
}: {
setIsDragging: Dispatch<SetStateAction<boolean>>;
}) {
const updateQuizMutation = useUpdateQuizMutation();
const updateAssignmentMutation = useUpdateAssignmentMutation();
const updatePageMutation = useUpdatePageMutation();
const { courseName } = useCourseContext();
return useCallback(
(e: DragEvent, dropModuleName: string) => {
console.log("dropping on module");
const rawData = e.dataTransfer.getData("draggableItem");
if (!rawData) return;
const itemBeingDragged: DraggableItem = JSON.parse(rawData);
if (itemBeingDragged) {
if (itemBeingDragged.type === "quiz") {
updateQuiz();
} else if (itemBeingDragged.type === "assignment") {
updateAssignment();
} else if (itemBeingDragged.type === "page") {
updatePage();
} else if (itemBeingDragged.type === "lecture") {
console.log("cannot drop lecture on module, only on days");
}
}
setIsDragging(false);
function updateQuiz() {
const quiz = itemBeingDragged.item as LocalQuiz;
if (itemBeingDragged.sourceModuleName) {
updateQuizMutation.mutate({
quiz,
quizName: quiz.name,
moduleName: dropModuleName,
previousModuleName: itemBeingDragged.sourceModuleName,
previousQuizName: quiz.name,
courseName,
});
} else {
console.error(
`error dropping quiz, sourceModuleName is undefined `,
quiz
);
}
}
function updateAssignment() {
const assignment = itemBeingDragged.item as LocalAssignment;
if (itemBeingDragged.sourceModuleName) {
updateAssignmentMutation.mutate({
assignment,
previousModuleName: itemBeingDragged.sourceModuleName,
moduleName: dropModuleName,
assignmentName: assignment.name,
previousAssignmentName: assignment.name,
courseName,
});
} else {
console.error(
`error dropping assignment, sourceModuleName is undefined `,
assignment
);
}
}
function updatePage() {
const page = itemBeingDragged.item as LocalCoursePage;
if (itemBeingDragged.sourceModuleName) {
updatePageMutation.mutate({
page,
moduleName: dropModuleName,
pageName: page.name,
previousPageName: page.name,
previousModuleName: itemBeingDragged.sourceModuleName,
courseName,
});
} else {
console.error(
`error dropping page, sourceModuleName is undefined `,
page
);
}
}
},
[
courseName,
setIsDragging,
updateAssignmentMutation,
updatePageMutation,
updateQuizMutation,
]
);
}

View File

@@ -0,0 +1,24 @@
import { Suspense } from "react";
import CourseContextProvider from "./context/CourseContextProvider";
export default async function CourseLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ courseName: string }>;
}) {
const { courseName } = await params;
const decodedCourseName = decodeURIComponent(courseName);
if (courseName.includes(".js.map")) {
console.log("cannot load course that is .js.map " + decodedCourseName);
return <div></div>;
}
return (
<Suspense>
<CourseContextProvider localCourseName={decodedCourseName}>
{children}
</CourseContextProvider>
</Suspense>
);
}

View File

@@ -0,0 +1,112 @@
"use client";
import { MonacoEditor } from "@/components/editor/MonacoEditor";
import {
useLecturesSuspenseQuery,
useLectureUpdateMutation,
} from "@/hooks/localCourse/lectureHooks";
import {
lectureToString,
parseLecture,
} from "@/services/fileStorage/utils/lectureUtils";
import { useEffect, useState } from "react";
import LecturePreview from "./LecturePreview";
import EditLectureTitle from "./EditLectureTitle";
import LectureButtons from "./LectureButtons";
import { useCourseContext } from "../../context/courseContext";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import { Lecture } from "@/models/local/lecture";
import { useAuthoritativeUpdates } from "../../utils/useAuthoritativeUpdates";
export default function EditLecture({ lectureDay }: { lectureDay: string }) {
const { courseName } = useCourseContext();
const [settings] = useLocalCourseSettingsQuery();
const [weeks, { dataUpdatedAt: serverDataUpdatedAt, isFetching }] =
useLecturesSuspenseQuery();
const updateLecture = useLectureUpdateMutation();
const lecture = weeks
.flatMap(({ lectures }) => lectures.map((lecture) => lecture))
.find((l) => l.date === lectureDay);
const { clientIsAuthoritative, text, textUpdate, monacoKey } =
useAuthoritativeUpdates({
serverUpdatedAt: serverDataUpdatedAt,
startingText: getLectureTextOrDefault(lecture, lectureDay),
});
const [error, setError] = useState("");
useEffect(() => {
const delay = 500;
const handler = setTimeout(() => {
try {
if (isFetching || updateLecture.isPending) {
console.log("network requests in progress, not updating page");
return;
}
const parsed = parseLecture(text);
if (!lecture || lectureToString(parsed) !== lectureToString(lecture)) {
if (clientIsAuthoritative) {
console.log("updating lecture");
updateLecture.mutate({ lecture: parsed, settings, courseName });
} else {
if (lecture) {
console.log(
"client not authoritative, updating client with server lecture"
);
textUpdate(lectureToString(lecture), true);
} else {
console.log(
"client not authoritative, but no lecture on server, this is a bug"
);
}
}
}
setError("");
} catch (e: any) {
setError(e.toString());
}
}, delay);
return () => {
clearTimeout(handler);
};
}, [
clientIsAuthoritative,
courseName,
isFetching,
lecture,
settings,
text,
textUpdate,
updateLecture,
]);
return (
<div className="h-full flex flex-col">
<EditLectureTitle lectureDay={lectureDay} />
<div className="sm:columns-2 min-h-0 flex-1">
<div className="flex-1 h-full">
<MonacoEditor key={monacoKey} value={text} onChange={textUpdate} />
</div>
<div className="h-full sm:block none overflow-auto">
<div className="text-red-300">{error && error}</div>
{lecture && <LecturePreview lecture={lecture} />}
</div>
</div>
<LectureButtons lectureDay={lectureDay} />
</div>
);
}
function getLectureTextOrDefault(
lecture: Lecture | undefined,
lectureDay: string
) {
return lecture
? lectureToString(lecture)
: `Name:
Date: ${lectureDay}
---
`;
}

View File

@@ -0,0 +1,42 @@
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import { getDayOfWeek } from "@/models/local/localCourseSettings";
import { getDateFromString } from "@/models/local/utils/timeUtils";
import { getLectureWeekName } from "@/services/fileStorage/utils/lectureUtils";
import { getCourseUrl, getLecturePreviewUrl } from "@/services/urlUtils";
import { useCourseContext } from "../../context/courseContext";
import Link from "next/link";
export default function EditLectureTitle({
lectureDay,
}: {
lectureDay: string;
}) {
const [settings] = useLocalCourseSettingsQuery();
const { courseName } = useCourseContext();
const lectureDate = getDateFromString(lectureDay);
const lectureWeekName = getLectureWeekName(settings.startDate, lectureDay);
return (
<div className="flex justify-between sm:flex-row flex-col">
<div className="my-auto">
<Link className="btn hidden sm:inline" href={getCourseUrl(courseName)}>
{courseName}
</Link>
</div>
<div className="flex justify-center ">
<h3 className="mt-auto me-3 text-slate-500 ">Lecture</h3>
<h1 className="">
{lectureDate && getDayOfWeek(lectureDate)}{" "}
{lectureWeekName.toUpperCase()}
</h1>
</div>
<div className="text-end my-auto flex">
<Link
className="btn inline text-center flex-grow m-1"
href={getLecturePreviewUrl(courseName, lectureDay)}
>
preview
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
"use client";
import Modal, { useModal } from "@/components/Modal";
import { Spinner } from "@/components/Spinner";
import { getCourseUrl } from "@/services/urlUtils";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useCourseContext } from "../../context/courseContext";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import { useDeleteLectureMutation } from "@/hooks/localCourse/lectureHooks";
import Link from "next/link";
export default function LectureButtons({ lectureDay }: { lectureDay: string }) {
const { courseName } = useCourseContext();
const [settings] = useLocalCourseSettingsQuery();
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const modal = useModal();
const deleteLecture = useDeleteLectureMutation();
return (
<div className="p-5 flex flex-row justify-end gap-3">
<div>
<Modal
modalControl={modal}
buttonText="Delete Lecture"
buttonClass="btn-danger"
modalWidth="w-1/5"
>
{({ closeModal }) => (
<div>
<div className="text-center">
Are you sure you want to delete this lecture?
</div>
<br />
<div className="flex justify-around gap-3">
<button
onClick={async () => {
setIsLoading(true);
router.push(getCourseUrl(courseName));
await deleteLecture.mutateAsync({
courseName,
settings,
lectureDay,
});
}}
disabled={isLoading}
className="btn-danger"
>
Yes
</button>
<button onClick={closeModal} disabled={isLoading}>
No
</button>
</div>
{isLoading && <Spinner />}
</div>
)}
</Modal>
</div>
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
Go Back
</Link>
{isLoading && <Spinner />}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import { Lecture } from "@/models/local/lecture";
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
export default function LecturePreview({ lecture }: { lecture: Lecture }) {
const [settings] = useLocalCourseSettingsQuery();
return (
<>
<section className="border-b-slate-700 border-b-4">
<div className="text-center font-extrabold">{lecture.name}</div>
<div className="text-center font-bold text-slate-400">{lecture.date}</div>
</section>
<section>
<div
className="markdownPreview text-xl"
dangerouslySetInnerHTML={{
__html: markdownToHTMLSafe(lecture.content, settings),
}}
></div>
</section>
</>
);
}

View File

@@ -0,0 +1,24 @@
import { Suspense } from "react";
import CourseContextProvider from "../../context/CourseContextProvider";
export default async function LectureLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ courseName: string; lectureDay: string }>;
}) {
const { courseName, lectureDay } = await params;
const decodedCourseName = decodeURIComponent(courseName);
if (courseName.includes(".js.map")) {
console.log("cannot load course that is .js.map " + decodedCourseName);
return <div></div>;
}
return (
<Suspense>
<CourseContextProvider localCourseName={decodedCourseName}>
{children}
</CourseContextProvider>
</Suspense>
);
}

View File

@@ -0,0 +1,22 @@
import EditLecture from "./EditLecture";
import {
getDateFromStringOrThrow,
getDateOnlyMarkdownString,
} from "@/models/local/utils/timeUtils";
export const dynamic = "force-dynamic";
export default async function page({
params,
}: {
params: Promise<{ lectureDay: string }>;
}) {
const { lectureDay } = await params;
const decodedLectureDay = decodeURIComponent(lectureDay);
console.log(decodedLectureDay);
const lectureDate = getDateFromStringOrThrow(
decodedLectureDay,
"lecture day in lecture page"
);
const lectureDayOnly = getDateOnlyMarkdownString(lectureDate);
return <EditLecture lectureDay={lectureDayOnly} />;
}

View File

@@ -0,0 +1,55 @@
"use client";
import LecturePreview from "../LecturePreview";
import { getCourseUrl, getLectureUrl } from "@/services/urlUtils";
import { useCourseContext } from "../../../context/courseContext";
import Link from "next/link";
import { useLecturesSuspenseQuery } from "@/hooks/localCourse/lectureHooks";
export default function LecturePreviewPage({
lectureDay,
}: {
lectureDay: string;
}) {
const { courseName } = useCourseContext();
const [weeks] = useLecturesSuspenseQuery();
const lecture = weeks
.flatMap(({ lectures }) => lectures.map((lecture) => lecture))
.find((l) => l.date === lectureDay);
if (!lecture) {
return <div>lecture not found for day</div>;
}
return (
<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="">
<Link
className="btn"
href={getLectureUrl(courseName, lectureDay)}
shallow={true}
>
Edit Page
</Link>
</div>
<div className="">
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
Course Calendar
</Link>
</div>
</div>
<div className="flex justify-center min-h-0 px-2">
<div
className="
w-full max-w-screen-lg
border-slate-700 border-4 rounded-md
p-3 overflow-auto
"
>
<LecturePreview lecture={lecture} />
</div>
</div>
<div className="flex-shrink flex-1"></div>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import {
getDateFromStringOrThrow,
getDateOnlyMarkdownString,
} from "@/models/local/utils/timeUtils";
import LecturePreviewPage from "./LecturePreviewPage";
export const dynamic = "force-dynamic";
export default async function Page({
params,
}: {
params: Promise<{ lectureDay: string }>;
}) {
const { lectureDay } = await params;
const decodedLectureDay = decodeURIComponent(lectureDay);
const lectureDate = getDateFromStringOrThrow(
decodedLectureDay,
"lecture day in lecture page"
);
const lectureDayOnly = getDateOnlyMarkdownString(lectureDate);
console.log(lectureDayOnly);
return (
<>
<LecturePreviewPage lectureDay={lectureDayOnly} />
</>
);
}

View File

@@ -0,0 +1,10 @@
import { Spinner } from "@/components/Spinner";
import React from "react";
export default function Loading() {
return (
<div>
<Spinner />
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { Expandable } from "@/components/Expandable";
import TextInput from "@/components/form/TextInput";
import { useCreateModuleMutation } from "@/hooks/localCourse/localCourseModuleHooks";
import React, { useState } from "react";
import { useCourseContext } from "../context/courseContext";
export default function CreateModule() {
const { courseName } = useCourseContext();
const createModule = useCreateModuleMutation();
const [moduleName, setModuleName] = useState("");
return (
<>
<Expandable
ExpandableElement={({ setIsExpanded, isExpanded }) => (
<button onClick={() => setIsExpanded((v) => !v)}>
{isExpanded ? "Hide Form" : "Create Module"}
</button>
)}
>
<form
onSubmit={async (e) => {
e.preventDefault();
if (moduleName) {
await createModule.mutateAsync({ moduleName, courseName });
setModuleName("");
}
}}
className="p-1 border border-slate-500 rounded-md my-1 flex flex-row gap-3 justify-between"
>
<TextInput
className="flex-grow"
value={moduleName}
setValue={setModuleName}
label={"New Module Name"}
/>
<button className="mt-auto">Add</button>
</form>
</Expandable>
</>
);
}

View File

@@ -0,0 +1,174 @@
"use client";
import { usePagesQueries } from "@/hooks/localCourse/pageHooks";
import { IModuleItem } from "@/models/local/IModuleItem";
import {
getDateFromString,
getDateFromStringOrThrow,
getDateOnlyMarkdownString,
} from "@/models/local/utils/timeUtils";
import { Fragment } from "react";
import Modal, { useModal } from "../../../../components/Modal";
import NewItemForm from "./NewItemForm";
import { ModuleCanvasStatus } from "./ModuleCanvasStatus";
import ClientOnly from "@/components/ClientOnly";
import ExpandIcon from "../../../../components/icons/ExpandIcon";
import {
DraggableItem,
useDraggingContext,
} from "../context/drag/draggingContext";
import Link from "next/link";
import { getModuleItemUrl } from "@/services/urlUtils";
import { useCourseContext } from "../context/courseContext";
import { Expandable } from "../../../../components/Expandable";
import { useDragStyleContext } from "../context/drag/dragStyleContext";
import { useQuizzesQueries } from "@/hooks/localCourse/quizHooks";
import { useAssignmentNamesQuery } from "@/hooks/localCourse/assignmentHooks";
import { trpc } from "@/services/serverFunctions/trpcClient";
export default function ExpandableModule({
moduleName,
}: {
moduleName: string;
}) {
const { itemDropOnModule } = useDraggingContext();
const { courseName } = useCourseContext();
const [assignmentNames] = useAssignmentNamesQuery(moduleName);
const [assignments] = trpc.useSuspenseQueries((t) =>
assignmentNames.map((assignmentName) =>
t.assignment.getAssignment({ courseName, moduleName, assignmentName })
)
);
const [quizzes] = useQuizzesQueries(moduleName);
const [pages] = usePagesQueries(moduleName);
const modal = useModal();
const moduleItems: {
type: "assignment" | "quiz" | "page";
item: IModuleItem;
}[] = (assignments ?? [])
.map(
(
a
): {
type: "assignment" | "quiz" | "page";
item: IModuleItem;
} => ({
type: "assignment",
item: a,
})
)
.concat(quizzes.map((q) => ({ type: "quiz", item: q })))
.concat(pages.map((p) => ({ type: "page", item: p })))
.sort(
(a, b) =>
getDateFromStringOrThrow(
a.item.dueAt,
"item due date in expandable module"
).getTime() -
getDateFromStringOrThrow(
b.item.dueAt,
"item due date in expandable module"
).getTime()
);
return (
<div
className="bg-slate-800 rounded-lg border border-slate-600 mb-3"
onDrop={(e) => itemDropOnModule(e, moduleName)}
onDragOver={(e) => e.preventDefault()}
>
<div className="draggingModule ">
<div className=" p-3 ">
<Expandable
ExpandableElement={({ setIsExpanded, isExpanded }) => (
<div
className="font-bold flex flex-row justify-between "
role="button"
onClick={() => setIsExpanded((e) => !e)}
>
<div>{moduleName}</div>
<div className="flex flex-row">
<ClientOnly>
<ModuleCanvasStatus moduleName={moduleName} />
</ClientOnly>
<ExpandIcon
style={{
...(isExpanded ? { rotate: "-90deg" } : {}),
}}
/>
</div>
</div>
)}
>
<>
<Modal modalControl={modal} buttonText="New Item">
{({ closeModal }) => (
<div>
<NewItemForm
moduleName={moduleName}
onCreate={closeModal}
/>
<br />
<button onClick={closeModal}>close</button>
</div>
)}
</Modal>
<div className="grid grid-cols-[auto_1fr]">
{moduleItems.map(({ type, item }) => (
<ExpandableModuleItem
key={item.name + type}
type={type}
item={item}
moduleName={moduleName}
/>
))}
</div>
</>
</Expandable>
</div>
</div>
</div>
);
}
function ExpandableModuleItem({
type,
item,
moduleName,
}: {
type: "assignment" | "quiz" | "page";
item: IModuleItem;
moduleName: string;
}) {
const { courseName } = useCourseContext();
const date = getDateFromString(item.dueAt);
const { setIsDragging } = useDragStyleContext();
return (
<Fragment key={item.name + type}>
<div className="text-end text-slate-500 me-2">
{date && getDateOnlyMarkdownString(date)}
</div>
<Link
href={getModuleItemUrl(courseName, moduleName, type, item.name)}
className="transition-all hover:text-slate-50 hover:scale-105"
draggable="true"
onDragStart={(e) => {
const draggableItem: DraggableItem = {
type,
item,
sourceModuleName: moduleName,
};
e.dataTransfer.setData(
"draggableItem",
JSON.stringify(draggableItem)
);
setIsDragging(true);
}}
>
{item.name}
</Link>
</Fragment>
);
}

View File

@@ -0,0 +1,34 @@
"use client";
import CheckIcon from "@/components/icons/CheckIcon";
import { Spinner } from "@/components/Spinner";
import {
useAddCanvasModuleMutation,
useCanvasModulesQuery,
} from "@/hooks/canvas/canvasModuleHooks";
export function ModuleCanvasStatus({ moduleName }: { moduleName: string }) {
const { data: canvasModules } = useCanvasModulesQuery();
const addToCanvas = useAddCanvasModuleMutation();
const canvasModule = canvasModules?.find((c) => c.name === moduleName);
return (
<div className="text-slate-400 text-end">
{!canvasModule && <div>Not in Canvas</div>}
{!canvasModule && (
<button
disabled={addToCanvas.isPending}
onClick={() => addToCanvas.mutate(moduleName)}
>
{addToCanvas.isPending ? <Spinner /> : <div>Add</div>}
</button>
)}
{canvasModule && !canvasModule.published && <div>Not Published</div>}
{canvasModule && canvasModule.published && (
<div>
<CheckIcon />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,22 @@
"use client";
import { useModuleNamesQuery } from "@/hooks/localCourse/localCourseModuleHooks";
import ExpandableModule from "./ExpandableModule";
import CreateModule from "./CreateModule";
export default function ModuleList() {
const [moduleNames] = useModuleNamesQuery();
return (
<div>
{moduleNames.map((m) => (
<ExpandableModule key={m} moduleName={m} />
))}
<div className="flex flex-col justify-center">
<CreateModule />
</div>
<br />
<br />
<br />
<br />
</div>
);
}

View File

@@ -0,0 +1,189 @@
"use client";
import ButtonSelect from "@/components/ButtonSelect";
import SelectInput from "@/components/form/SelectInput";
import TextInput from "@/components/form/TextInput";
import { Spinner } from "@/components/Spinner";
import { useCreateAssignmentMutation } from "@/hooks/localCourse/assignmentHooks";
import { useModuleNamesQuery } from "@/hooks/localCourse/localCourseModuleHooks";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import { useCreatePageMutation } from "@/hooks/localCourse/pageHooks";
import { LocalAssignmentGroup } from "@/models/local/assignment/localAssignmentGroup";
import React, { useState } from "react";
import { useCourseContext } from "../context/courseContext";
import { useCreateQuizMutation } from "@/hooks/localCourse/quizHooks";
import {
getDateFromString,
dateToMarkdownString,
getDateFromStringOrThrow,
} from "@/models/local/utils/timeUtils";
export default function NewItemForm({
moduleName: defaultModuleName,
onCreate = () => {},
creationDate,
}: {
moduleName?: string;
creationDate?: string;
onCreate?: () => void;
}) {
const [settings] = useLocalCourseSettingsQuery();
const { courseName } = useCourseContext();
const [modules] = useModuleNamesQuery();
const [type, setType] = useState<"Assignment" | "Quiz" | "Page">(
"Assignment"
);
const [moduleName, setModuleName] = useState<string | undefined>(
defaultModuleName
);
const [name, setName] = useState("");
const defaultDate = getDateFromString(
creationDate ? creationDate : dateToMarkdownString(new Date())
);
defaultDate?.setMinutes(settings.defaultDueTime.minute);
defaultDate?.setHours(settings.defaultDueTime.hour);
defaultDate?.setSeconds(0);
const [dueDate, setDueDate] = useState(
dateToMarkdownString(defaultDate ?? new Date())
);
const [assignmentGroup, setAssignmentGroup] =
useState<LocalAssignmentGroup>();
const createPage = useCreatePageMutation();
const createQuiz = useCreateQuizMutation();
const createAssignment = useCreateAssignmentMutation();
const isPending =
createAssignment.isPending || createPage.isPending || createQuiz.isPending;
return (
<form
className="flex flex-col gap-3"
onSubmit={(e) => {
e.preventDefault();
const dueAt =
dueDate === ""
? dueDate
: dateToMarkdownString(defaultDate ?? new Date());
const lockAt =
settings.defaultLockHoursOffset === undefined
? undefined
: dateToMarkdownString(
addHoursToDate(
getDateFromStringOrThrow(
dueDate,
"getting default lock time"
),
settings.defaultLockHoursOffset
)
);
if (!moduleName) {
return;
}
if (type === "Assignment") {
createAssignment.mutate({
assignment: {
name,
description: "",
localAssignmentGroupName: assignmentGroup?.name ?? "",
dueAt,
lockAt,
submissionTypes: settings.defaultAssignmentSubmissionTypes,
allowedFileUploadExtensions: settings.defaultFileUploadTypes,
rubric: [],
},
moduleName: moduleName,
assignmentName: name,
courseName,
});
} else if (type === "Quiz") {
createQuiz.mutate({
quiz: {
name,
description: "",
localAssignmentGroupName: assignmentGroup?.name ?? "",
dueAt,
lockAt,
shuffleAnswers: true,
showCorrectAnswers: true,
oneQuestionAtATime: true,
allowedAttempts: -1,
questions: [],
},
moduleName: moduleName,
quizName: name,
courseName,
});
} else if (type === "Page") {
createPage.mutate({
page: {
name,
text: "",
dueAt,
},
moduleName: moduleName,
pageName: name,
courseName,
});
}
onCreate();
}}
>
<div>
<TextInput
label={type + " due date"}
value={dueDate ?? ""}
setValue={setDueDate}
/>
</div>
<div>
<SelectInput
value={moduleName}
setValue={(m) => setModuleName(m)}
label={"Module"}
options={modules}
getOptionName={(m) => m}
/>
</div>
<div>
<ButtonSelect<"Assignment" | "Quiz" | "Page">
options={["Assignment", "Quiz", "Page"]}
getName={(o) => o?.toString() ?? ""}
setSelectedOption={(t) => setType(t ?? "Assignment")}
selectedOption={type}
/>
</div>
<div>
<TextInput label={type + " Name"} value={name} setValue={setName} />
</div>
<div>
{type !== "Page" && (
<ButtonSelect
options={settings.assignmentGroups}
getName={(g) => g?.name ?? ""}
setSelectedOption={setAssignmentGroup}
selectedOption={assignmentGroup}
/>
)}
</div>
{settings.assignmentGroups.length === 0 && (
<div>
No assignment groups created, create them in the course settings page
</div>
)}
<button type="submit">Create</button>
{isPending && <Spinner />}
</form>
);
}
function addHoursToDate(date: Date, hours: number): Date {
const newDate = new Date(date.getTime());
newDate.setHours(newDate.getHours() + hours);
return newDate;
}

View File

@@ -0,0 +1,163 @@
import { useCourseContext } from "@/app/course/[courseName]/context/courseContext";
import Modal, { useModal } from "@/components/Modal";
import { Spinner } from "@/components/Spinner";
import {
useCanvasAssignmentsQuery,
useAddAssignmentToCanvasMutation,
useDeleteAssignmentFromCanvasMutation,
useUpdateAssignmentInCanvasMutation,
} from "@/hooks/canvas/canvasAssignmentHooks";
import {
useAssignmentQuery,
useDeleteAssignmentMutation,
} from "@/hooks/localCourse/assignmentHooks";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import { baseCanvasUrl } from "@/services/canvas/canvasServiceUtils";
import { getCourseUrl } from "@/services/urlUtils";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
export function AssignmentButtons({
moduleName,
assignmentName,
toggleHelp,
}: {
assignmentName: string;
moduleName: string;
toggleHelp: () => void;
}) {
const router = useRouter();
const { courseName } = useCourseContext();
const [settings] = useLocalCourseSettingsQuery();
const { data: canvasAssignments, isFetching: canvasIsFetching } =
useCanvasAssignmentsQuery();
const [assignment, { isFetching }] = useAssignmentQuery(
moduleName,
assignmentName
);
const addToCanvas = useAddAssignmentToCanvasMutation();
const deleteFromCanvas = useDeleteAssignmentFromCanvasMutation();
const updateAssignment = useUpdateAssignmentInCanvasMutation();
const deleteLocal = useDeleteAssignmentMutation();
const [isLoading, setIsLoading] = useState(false);
const modal = useModal();
const assignmentInCanvas = canvasAssignments?.find(
(a) => a.name === assignmentName
);
const anythingIsLoading =
addToCanvas.isPending ||
canvasIsFetching ||
isFetching ||
deleteFromCanvas.isPending ||
updateAssignment.isPending;
console.log("assignment pending", updateAssignment.isPending);
return (
<div className="p-5 flex flex-row justify-between gap-3">
<div>
<button onClick={toggleHelp}>Toggle Help</button>
</div>
<div className="flex flex-row gap-3 justify-end">
{anythingIsLoading && <Spinner />}
{assignmentInCanvas && !assignmentInCanvas?.published && (
<div className="text-rose-300 my-auto">Not Published</div>
)}
{!assignmentInCanvas && (
<button
disabled={addToCanvas.isPending}
onClick={() => addToCanvas.mutate({ assignment, moduleName })}
>
Add to canvas
</button>
)}
{assignmentInCanvas && (
<a
className="btn"
target="_blank"
href={`${baseCanvasUrl}/courses/${settings.canvasId}/assignments/${assignmentInCanvas.id}`}
>
View in Canvas
</a>
)}
{assignmentInCanvas && (
<button
className=""
disabled={deleteFromCanvas.isPending}
onClick={() =>
updateAssignment.mutate({
canvasAssignmentId: assignmentInCanvas.id,
assignment,
})
}
>
Update in Canvas
</button>
)}
{assignmentInCanvas && (
<button
className="btn-danger"
disabled={deleteFromCanvas.isPending}
onClick={() =>
deleteFromCanvas.mutate({
canvasAssignmentId: assignmentInCanvas.id,
assignmentName: assignment.name,
})
}
>
Delete from Canvas
</button>
)}
{!assignmentInCanvas && (
<Modal
modalControl={modal}
buttonText="Delete Localy"
buttonClass="btn-danger"
modalWidth="w-1/5"
>
{({ closeModal }) => (
<div>
<div className="text-center">
Are you sure you want to delete this assignment locally?
</div>
<br />
<div className="flex justify-around gap-3">
<button
onClick={async () => {
router.push(getCourseUrl(courseName));
setIsLoading(true);
await deleteLocal.mutateAsync({
moduleName,
assignmentName,
courseName,
});
router.refresh();
// setIsLoading(false); //refreshing the router will make spinner go away
}}
disabled={deleteLocal.isPending || isLoading}
className="btn-danger"
>
Yes
</button>
<button
onClick={closeModal}
disabled={deleteLocal.isPending || isLoading}
>
No
</button>
</div>
{(deleteLocal.isPending || isLoading) && <Spinner />}
</div>
)}
</Modal>
)}
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
Go Back
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
import ClientOnly from "@/components/ClientOnly";
import { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import { LocalAssignment } from "@/models/local/assignment/localAssignment";
import { rubricItemIsExtraCredit } from "@/models/local/assignment/rubricItem";
import { assignmentPoints } from "@/models/local/assignment/utils/assignmentPointsUtils";
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
import React, { Fragment } from "react";
export default function AssignmentPreview({
assignment,
}: {
assignment: LocalAssignment;
}) {
const [settings] = useLocalCourseSettingsQuery();
const totalPoints = assignmentPoints(assignment.rubric)
const extraPoints = assignment.rubric.reduce(
(sum, cur) => (rubricItemIsExtraCredit(cur) ? sum + cur.points : sum),
0
);
return (
<div className="h-full overflow-y-auto">
<section>
<div className="flex">
<div className="flex-1 text-end pe-3">Due Date</div>
<div className="flex-1">{assignment.dueAt}</div>
</div>
<div className="flex">
<div className="flex-1 text-end pe-3">Lock Date</div>
<div className="flex-1">{assignment.lockAt}</div>
</div>
<div className="flex">
<div className="flex-1 text-end pe-3">Assignment Group Name</div>
<div className="flex-1">{assignment.localAssignmentGroupName}</div>
</div>
<div className="flex">
<div className="flex-1 text-end pe-3">Submission Types</div>
<div className="flex-1">
<ul className="">
{assignment.submissionTypes.map((t) => (
<li key={t}>{t}</li>
))}
</ul>
</div>
</div>
<div className="flex">
<div className="flex-1 text-end pe-3">File Upload Types</div>
<div className="flex-1">
<ul className="">
{assignment.allowedFileUploadExtensions.map((t) => (
<li key={t}>{t}</li>
))}
</ul>
</div>
</div>
</section>
<br />
<hr />
<br />
<section>
<div
className="markdownPreview"
dangerouslySetInnerHTML={{
__html: markdownToHTMLSafe(assignment.description, settings),
}}
></div>
</section>
<hr />
<section>
<h2 className="text-center">Rubric - {totalPoints} Points</h2>
{extraPoints !== 0 && (
<h5 className="text-center">{extraPoints} Extra Credit Points</h5>
)}
<div className="grid grid-cols-3">
{assignment.rubric.map((rubricItem, i) => (
<Fragment key={rubricItem.label + i}>
<div className="text-end pe-3 col-span-2">{rubricItem.label}</div>
<div>
{rubricItem.points}
{rubricItemIsExtraCredit(rubricItem) ? " - Extra Credit" : ""}
</div>
</Fragment>
))}
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,184 @@
"use client";
import { MonacoEditor } from "@/components/editor/MonacoEditor";
import {
useAssignmentQuery,
useUpdateAssignmentMutation,
useUpdateImageSettingsForAssignment,
} from "@/hooks/localCourse/assignmentHooks";
import {
LocalAssignment,
localAssignmentMarkdown,
} from "@/models/local/assignment/localAssignment";
import { useEffect, useState } from "react";
import AssignmentPreview from "./AssignmentPreview";
import { getModuleItemUrl } from "@/services/urlUtils";
import { useCourseContext } from "@/app/course/[courseName]/context/courseContext";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import ClientOnly from "@/components/ClientOnly";
import { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling";
import { AssignmentSubmissionType } from "@/models/local/assignment/assignmentSubmissionType";
import { LocalCourseSettings } from "@/models/local/localCourseSettings";
import { useRouter } from "next/navigation";
import { AssignmentButtons } from "./AssignmentButtons";
import { useAuthoritativeUpdates } from "@/app/course/[courseName]/utils/useAuthoritativeUpdates";
export default function EditAssignment({
moduleName,
assignmentName,
}: {
assignmentName: string;
moduleName: string;
}) {
const router = useRouter();
const { courseName } = useCourseContext();
const [settings] = useLocalCourseSettingsQuery();
const [
assignment,
{ dataUpdatedAt: serverDataUpdatedAt, isFetching: assignmentIsFetching },
] = useAssignmentQuery(moduleName, assignmentName);
const updateAssignment = useUpdateAssignmentMutation();
useUpdateImageSettingsForAssignment({ moduleName, assignmentName });
const {
clientIsAuthoritative,
text,
textUpdate,
monacoKey,
serverUpdatedAt,
clientDataUpdatedAt,
} = useAuthoritativeUpdates({
serverUpdatedAt: serverDataUpdatedAt,
startingText: localAssignmentMarkdown.toMarkdown(assignment),
});
const [error, setError] = useState("");
const [showHelp, setShowHelp] = useState(false);
useEffect(() => {
const delay = 500;
const handler = setTimeout(() => {
try {
if (assignmentIsFetching || updateAssignment.isPending) {
console.log("network requests in progress, not updating assignments");
return;
}
const updatedAssignment: LocalAssignment =
localAssignmentMarkdown.parseMarkdown(text);
if (
localAssignmentMarkdown.toMarkdown(assignment) !==
localAssignmentMarkdown.toMarkdown(updatedAssignment)
) {
if (clientIsAuthoritative) {
console.log("updating assignment, client is authoritative");
updateAssignment
.mutateAsync({
assignment: updatedAssignment,
moduleName,
assignmentName: updatedAssignment.name,
previousModuleName: moduleName,
previousAssignmentName: assignmentName,
courseName,
})
.then(async () => {
// await new Promise(resolve => setTimeout(resolve, 1000));
if (updatedAssignment.name !== assignmentName)
router.replace(
getModuleItemUrl(
courseName,
moduleName,
"assignment",
updatedAssignment.name
), {
}
);
});
} else {
console.log(
"client not authoritative, updating client with server assignment",
"client updated",
clientDataUpdatedAt,
"server updated",
serverUpdatedAt
);
textUpdate(localAssignmentMarkdown.toMarkdown(assignment), true);
}
}
setError("");
} catch (e: any) {
setError(e.toString());
}
}, delay);
return () => {
clearTimeout(handler);
};
}, [
assignment,
assignmentName,
clientDataUpdatedAt,
clientIsAuthoritative,
courseName,
assignmentIsFetching,
moduleName,
router,
serverUpdatedAt,
text,
textUpdate,
updateAssignment,
]);
return (
<div className="h-full flex flex-col align-middle px-1">
<div className={"min-h-0 flex flex-row w-full flex-grow"}>
{showHelp && (
<pre className=" max-w-96">
<code>{getHelpString(settings)}</code>
</pre>
)}
<div className="flex-1 h-full">
<MonacoEditor key={monacoKey} value={text} onChange={textUpdate} />
</div>
<div className="flex-1 h-full">
<div className="text-red-300">{error && error}</div>
<div className="px-3 h-full">
<ClientOnly>
<SuspenseAndErrorHandling showToast={false}>
<AssignmentPreview assignment={assignment} />
</SuspenseAndErrorHandling>
</ClientOnly>
</div>
</div>
</div>
<ClientOnly>
<SuspenseAndErrorHandling>
<AssignmentButtons
moduleName={moduleName}
assignmentName={assignmentName}
toggleHelp={() => setShowHelp((h) => !h)}
/>
</SuspenseAndErrorHandling>
</ClientOnly>
</div>
);
}
function getHelpString(settings: LocalCourseSettings) {
const groupNames = settings.assignmentGroups.map((g) => g.name).join("\n- ");
const helpString = `SubmissionTypes:
- ${AssignmentSubmissionType.ONLINE_TEXT_ENTRY}
- ${AssignmentSubmissionType.ONLINE_UPLOAD}
- ${AssignmentSubmissionType.DISCUSSION_TOPIC}
AllowedFileUploadExtensions:
- pdf
- jpg
- jpeg
- png
Assignment Group Names:
- ${groupNames}`;
return helpString;
}

View File

@@ -0,0 +1,5 @@
import React, { ReactNode, Suspense } from "react";
export default function layout({ children }: { children: ReactNode }) {
return <Suspense>{children}</Suspense>;
}

View File

@@ -0,0 +1,10 @@
import { Spinner } from "@/components/Spinner";
import React from "react";
export default function Loading() {
return (
<div>
<Spinner />
</div>
);
}

View File

@@ -0,0 +1,18 @@
import React from "react";
import EditAssignment from "./EditAssignment";
export default async function Page({
params,
}: {
params: Promise<{ assignmentName: string; moduleName: string }>;
}) {
const { moduleName, assignmentName } = await params;
const decodedAssignmentName = decodeURIComponent(assignmentName);
const decodedModuleName = decodeURIComponent(moduleName);
return (
<EditAssignment
assignmentName={decodedAssignmentName}
moduleName={decodedModuleName}
/>
);
}

View File

@@ -0,0 +1,128 @@
"use client";
import { MonacoEditor } from "@/components/editor/MonacoEditor";
import {
usePageQuery,
useUpdatePageMutation,
} from "@/hooks/localCourse/pageHooks";
import { localPageMarkdownUtils } from "@/models/local/page/localCoursePage";
import { useEffect, useState } from "react";
import PagePreview from "./PagePreview";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import EditPageButtons from "./EditPageButtons";
import ClientOnly from "@/components/ClientOnly";
import { getModuleItemUrl } from "@/services/urlUtils";
import { useRouter } from "next/navigation";
import { useCourseContext } from "@/app/course/[courseName]/context/courseContext";
import { useAuthoritativeUpdates } from "@/app/course/[courseName]/utils/useAuthoritativeUpdates";
export default function EditPage({
moduleName,
pageName,
}: {
pageName: string;
moduleName: string;
}) {
const router = useRouter();
const { courseName } = useCourseContext();
const [page, { dataUpdatedAt, isFetching }] = usePageQuery(
moduleName,
pageName
);
const updatePage = useUpdatePageMutation();
const { clientIsAuthoritative, text, textUpdate, monacoKey } =
useAuthoritativeUpdates({
serverUpdatedAt: dataUpdatedAt,
startingText: localPageMarkdownUtils.toMarkdown(page),
});
const [error, setError] = useState("");
const [settings] = useLocalCourseSettingsQuery();
useEffect(() => {
const delay = 500;
const handler = setTimeout(() => {
if (isFetching || updatePage.isPending) {
console.log("network requests in progress, not updating page");
return;
}
try {
const updatedPage = localPageMarkdownUtils.parseMarkdown(text);
if (
localPageMarkdownUtils.toMarkdown(page) !==
localPageMarkdownUtils.toMarkdown(updatedPage)
) {
if (clientIsAuthoritative) {
console.log("updating page");
updatePage
.mutateAsync({
page: updatedPage,
moduleName,
pageName: updatedPage.name,
previousModuleName: moduleName,
previousPageName: pageName,
courseName,
})
.then(() => {
if (updatedPage.name !== pageName)
router.replace(
getModuleItemUrl(
courseName,
moduleName,
"page",
updatedPage.name
)
);
});
} else {
console.log(
"client not authoritative, updating client with server page"
);
textUpdate(localPageMarkdownUtils.toMarkdown(page), true);
}
}
setError("");
} catch (e: any) {
setError(e.toString());
}
}, delay);
return () => {
clearTimeout(handler);
};
}, [
clientIsAuthoritative,
courseName,
isFetching,
moduleName,
page,
pageName,
router,
text,
textUpdate,
updatePage,
]);
return (
<div className="h-full flex flex-col">
<div className="columns-2 min-h-0 flex-1">
<div className="flex-1 h-full">
<MonacoEditor key={monacoKey} value={text} onChange={textUpdate} />
</div>
<div className="h-full">
<div className="text-red-300">{error && error}</div>
<div className="h-full overflow-y-auto">
<br />
<PagePreview page={page} />
</div>
</div>
</div>
{settings.canvasId && (
<ClientOnly>
<EditPageButtons pageName={pageName} moduleName={moduleName} />
</ClientOnly>
)}
</div>
);
}

View File

@@ -0,0 +1,130 @@
import { useCourseContext } from "@/app/course/[courseName]/context/courseContext";
import Modal, { useModal } from "@/components/Modal";
import { Spinner } from "@/components/Spinner";
import {
useCanvasPagesQuery,
useCreateCanvasPageMutation,
useDeleteCanvasPageMutation,
useUpdateCanvasPageMutation,
} from "@/hooks/canvas/canvasPageHooks";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import {
useDeletePageMutation,
usePageQuery,
} from "@/hooks/localCourse/pageHooks";
import { baseCanvasUrl } from "@/services/canvas/canvasServiceUtils";
import { getCourseUrl } from "@/services/urlUtils";
import Link from "next/link";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
export default function EditPageButtons({
moduleName,
pageName,
}: {
pageName: string;
moduleName: string;
}) {
const router = useRouter();
const { courseName } = useCourseContext();
const [settings] = useLocalCourseSettingsQuery();
const [page] = usePageQuery(moduleName, pageName);
const { data: canvasPages } = useCanvasPagesQuery();
const createPageInCanvas = useCreateCanvasPageMutation();
const updatePageInCanvas = useUpdateCanvasPageMutation();
const deletePageInCanvas = useDeleteCanvasPageMutation();
const deletePageLocal = useDeletePageMutation();
const modal = useModal();
const [loading, setLoading] = useState(false);
const pageInCanvas = canvasPages?.find((p) => p.title === pageName);
const requestIsPending =
createPageInCanvas.isPending ||
updatePageInCanvas.isPending ||
deletePageInCanvas.isPending;
return (
<div className="p-5 flex justify-end flex-row gap-x-3">
{requestIsPending && <Spinner />}
{!pageInCanvas && (
<button
onClick={() => createPageInCanvas.mutate({ page, moduleName })}
disabled={requestIsPending}
>
Add to Canvas
</button>
)}
{pageInCanvas && (
<button
onClick={() =>
updatePageInCanvas.mutate({
page,
canvasPageId: pageInCanvas.page_id,
})
}
disabled={requestIsPending}
>
Update in Canvas
</button>
)}
{pageInCanvas && (
<a
className="btn"
target="_blank"
href={`${baseCanvasUrl}/courses/${settings.canvasId}/pages/${pageInCanvas.url}`}
>
View in Canvas
</a>
)}
{pageInCanvas && (
<button
className="btn-danger"
onClick={() => deletePageInCanvas.mutate(pageInCanvas.page_id)}
disabled={requestIsPending}
>
Delete from Canvas
</button>
)}
{!pageInCanvas && (
<Modal
modalControl={modal}
buttonText="Delete Localy"
buttonClass="btn-danger"
modalWidth="w-1/5"
>
{({ closeModal }) => (
<div>
<div className="text-center">
Are you sure you want to delete this page locally?
</div>
<br />
<div className="flex justify-around gap-3">
<button
onClick={async () => {
setLoading(true);
await deletePageLocal.mutateAsync({
moduleName,
pageName,
courseName,
});
router.push(getCourseUrl(courseName));
}}
className="btn-danger"
>
Yes
</button>
<button onClick={closeModal}>No</button>
</div>
{loading && <Spinner />}
</div>
)}
</Modal>
)}
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
Go Back
</Link>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import { LocalCoursePage } from "@/models/local/page/localCoursePage";
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
import React from "react";
export default function PagePreview({ page }: { page: LocalCoursePage }) {
const [settings] = useLocalCourseSettingsQuery();
return (
<div
className="markdownPreview"
dangerouslySetInnerHTML={{
__html: markdownToHTMLSafe(page.text, settings),
}}
></div>
);
}

View File

@@ -0,0 +1,5 @@
import React, { ReactNode, Suspense } from "react";
export default function layout({ children }: { children: ReactNode }) {
return <Suspense>{children}</Suspense>;
}

View File

@@ -0,0 +1,10 @@
import { Spinner } from "@/components/Spinner";
import React from "react";
export default function Loading() {
return (
<div>
<Spinner />
</div>
);
}

View File

@@ -0,0 +1,13 @@
import React from "react";
import EditPage from "./EditPage";
export default async function Page({
params,
}: {
params: Promise<{ pageName: string; moduleName: string }>;
}) {
const { moduleName, pageName } = await params;
const decodedPageName = decodeURIComponent(pageName);
const decodedModuleName = decodeURIComponent(moduleName);
return <EditPage pageName={decodedPageName} moduleName={decodedModuleName} />;
}

View File

@@ -0,0 +1,170 @@
"use client";
import { MonacoEditor } from "@/components/editor/MonacoEditor";
import { quizMarkdownUtils } from "@/models/local/quiz/utils/quizMarkdownUtils";
import { useEffect, useState } from "react";
import QuizPreview from "./QuizPreview";
import { QuizButtons } from "./QuizButton";
import ClientOnly from "@/components/ClientOnly";
import { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling";
import { useRouter } from "next/navigation";
import { getModuleItemUrl } from "@/services/urlUtils";
import { useCourseContext } from "@/app/course/[courseName]/context/courseContext";
import {
useQuizQuery,
useUpdateQuizMutation,
} from "@/hooks/localCourse/quizHooks";
import { useAuthoritativeUpdates } from "../../../../utils/useAuthoritativeUpdates";
const helpString = `QUESTION REFERENCE
---
Points: 2
this is a question?
*a) correct
b) not correct
---
points: 1
question goes here
[*] correct
[ ] not correct
[] not correct
---
the points default to 1?
*a) true
b) false
---
Markdown is supported
- like
- this
- list
[*] true
[ ] false
---
This is a one point essay question
essay
---
points: 4
this is a short answer question
short_answer
---
points: 4
the underscore is optional
short answer
---
this is a matching question
^ left answer - right dropdown
^ other thing - another option`;
export default function EditQuiz({
moduleName,
quizName,
}: {
quizName: string;
moduleName: string;
}) {
const router = useRouter();
const { courseName } = useCourseContext();
const [quiz, { dataUpdatedAt: serverDataUpdatedAt, isFetching }] =
useQuizQuery(moduleName, quizName);
const updateQuizMutation = useUpdateQuizMutation();
const { clientIsAuthoritative, text, textUpdate, monacoKey } =
useAuthoritativeUpdates({
serverUpdatedAt: serverDataUpdatedAt,
startingText: quizMarkdownUtils.toMarkdown(quiz),
});
const [error, setError] = useState("");
const [showHelp, setShowHelp] = useState(false);
useEffect(() => {
const delay = 1000;
const handler = setTimeout(async () => {
if (isFetching || updateQuizMutation.isPending) {
console.log("network requests in progress, not updating page");
return;
}
try {
if (
quizMarkdownUtils.toMarkdown(quiz) !==
quizMarkdownUtils.toMarkdown(quizMarkdownUtils.parseMarkdown(text))
) {
if (clientIsAuthoritative) {
const updatedQuiz = quizMarkdownUtils.parseMarkdown(text);
await updateQuizMutation
.mutateAsync({
quiz: updatedQuiz,
moduleName,
quizName: updatedQuiz.name,
previousModuleName: moduleName,
previousQuizName: quizName,
courseName,
})
.then(() => {
if (updatedQuiz.name !== quizName)
router.replace(
getModuleItemUrl(
courseName,
moduleName,
"quiz",
updatedQuiz.name
)
);
});
} else {
console.log(
"client not authoritative, updating client with server quiz"
);
textUpdate(quizMarkdownUtils.toMarkdown(quiz), true);
}
}
setError("");
} catch (e: any) {
setError(e.toString());
}
}, delay);
return () => {
clearTimeout(handler);
};
}, [
clientIsAuthoritative,
courseName,
isFetching,
moduleName,
quiz,
quizName,
router,
text,
textUpdate,
updateQuizMutation,
]);
return (
<div className="h-full flex flex-col align-middle px-1">
<div className={"min-h-96 h-full flex flex-row w-full"}>
{showHelp && (
<pre className=" max-w-96">
<code>{helpString}</code>
</pre>
)}
<div className="flex-1 h-full">
<MonacoEditor key={monacoKey} value={text} onChange={textUpdate} />
</div>
<div className="flex-1 h-full">
<div className="text-red-300">{error && error}</div>
<QuizPreview moduleName={moduleName} quizName={quizName} />
</div>
</div>
<ClientOnly>
<SuspenseAndErrorHandling>
<QuizButtons
moduleName={moduleName}
quizName={quizName}
toggleHelp={() => setShowHelp((h) => !h)}
/>
</SuspenseAndErrorHandling>
</ClientOnly>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import { useCourseContext } from "@/app/course/[courseName]/context/courseContext";
import Modal, { useModal } from "@/components/Modal";
import { Spinner } from "@/components/Spinner";
import {
useCanvasQuizzesQuery,
useAddQuizToCanvasMutation,
useDeleteQuizFromCanvasMutation,
} from "@/hooks/canvas/canvasQuizHooks";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import {
useDeleteQuizMutation,
useQuizQuery,
} from "@/hooks/localCourse/quizHooks";
import { baseCanvasUrl } from "@/services/canvas/canvasServiceUtils";
import { getCourseUrl } from "@/services/urlUtils";
import Link from "next/link";
import { useRouter } from "next/navigation";
export function QuizButtons({
moduleName,
quizName,
toggleHelp,
}: {
quizName: string;
moduleName: string;
toggleHelp: () => void;
}) {
const router = useRouter();
const { courseName } = useCourseContext();
const [settings] = useLocalCourseSettingsQuery();
const { data: canvasQuizzes } = useCanvasQuizzesQuery();
const [quiz] = useQuizQuery(moduleName, quizName);
const addToCanvas = useAddQuizToCanvasMutation();
const deleteFromCanvas = useDeleteQuizFromCanvasMutation();
const deleteLocal = useDeleteQuizMutation();
const modal = useModal();
const quizInCanvas = canvasQuizzes?.find((c) => c.title === quizName);
return (
<div className="p-5 flex flex-row justify-between">
<div>
<button onClick={toggleHelp}>Toggle Help</button>
</div>
<div className="flex flex-row gap-3 justify-end">
{(addToCanvas.isPending || deleteFromCanvas.isPending) && <Spinner />}
{quizInCanvas && !quizInCanvas.published && (
<div className="text-rose-300 my-auto">Not Published</div>
)}
{!quizInCanvas && (
<button
disabled={addToCanvas.isPending}
onClick={() => addToCanvas.mutate({ quiz, moduleName })}
>
Add to canvas
</button>
)}
{quizInCanvas && (
<a
className="btn"
target="_blank"
href={`${baseCanvasUrl}/courses/${settings.canvasId}/quizzes/${quizInCanvas.id}`}
>
View in Canvas
</a>
)}
{quizInCanvas && (
<button
className="btn-danger"
disabled={deleteFromCanvas.isPending}
onClick={() => deleteFromCanvas.mutate(quizInCanvas.id)}
>
Delete from Canvas
</button>
)}
{!quizInCanvas && (
<Modal
modalControl={modal}
buttonText="Delete Localy"
buttonClass="btn-danger"
modalWidth="w-1/5"
>
{({ closeModal }) => (
<div>
<div className="text-center">
Are you sure you want to delete this quiz locally?
</div>
<br />
<div className="flex justify-around gap-3">
<button
onClick={async () => {
await deleteLocal.mutateAsync({ moduleName, quizName, courseName });
router.push(getCourseUrl(courseName));
}}
className="btn-danger"
>
Yes
</button>
<button onClick={closeModal}>No</button>
</div>
</div>
)}
</Modal>
)}
<Link className="btn" href={getCourseUrl(courseName)} shallow={true}>
Go Back
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,143 @@
import CheckIcon from "@/components/icons/CheckIcon";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import { useQuizQuery } from "@/hooks/localCourse/quizHooks";
import {
LocalQuizQuestion,
QuestionType,
} from "@/models/local/quiz/localQuizQuestion";
import { markdownToHTMLSafe } from "@/services/htmlMarkdownUtils";
export default function QuizPreview({
moduleName,
quizName,
}: {
quizName: string;
moduleName: string;
}) {
const [quiz] = useQuizQuery(moduleName, quizName);
const [settings] = useLocalCourseSettingsQuery();
return (
<div style={{ overflow: "scroll", height: "100%" }}>
<div className="columns-2">
<div className="text-end">Name</div>
<div>{quiz.name}</div>
</div>
<div className="columns-2">
<div className="text-end">Points</div>
<div>
{quiz.questions.reduce((sum, question) => sum + question.points, 0)}
</div>
</div>
<div className="columns-2">
<div className="text-end">Due Date</div>
<div>{quiz.dueAt}</div>
</div>
<div className="columns-2">
<div className="text-end">Lock At</div>
<div>{quiz.lockAt}</div>
</div>
<div className="columns-2">
<div className="text-end">Shuffle Answers</div>
<div>{quiz.shuffleAnswers}</div>
</div>
<div className="columns-2">
<div className="text-end">Allowed Attempts</div>
<div>{quiz.allowedAttempts}</div>
</div>
<div className="columns-2">
<div className="text-end">One Question at a Time</div>
<div>{quiz.oneQuestionAtATime}</div>
</div>
<div className="columns-2">
<div className="text-end">Assignment Group Name</div>
<div>{quiz.localAssignmentGroupName}</div>
</div>
<div
className="p-3 markdownPreview"
dangerouslySetInnerHTML={{
__html: markdownToHTMLSafe(quiz.description, settings),
}}
></div>
<div className="p-3 rounded-md bg-slate-950 m-5 flex flex-col gap-3">
{quiz.questions.map((question, i) => (
<QuizQuestionPreview key={i} question={question} />
))}
</div>
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
</div>
);
}
function QuizQuestionPreview({ question }: { question: LocalQuizQuestion }) {
const [settings] = useLocalCourseSettingsQuery();
return (
<div className="rounded bg-slate-900 px-2">
<div className="flex flex-row justify-between text-slate-400">
<div>{question.questionType}</div>
<div className="">
{question.points} {question.points === 1 ? " Point" : " Points"}
</div>
</div>
<div
className="ms-4 mb-2 markdownPreview"
dangerouslySetInnerHTML={{ __html: markdownToHTMLSafe(question.text, settings) }}
></div>
{question.questionType === QuestionType.MATCHING && (
<div>
{question.answers.map((answer) => (
<div
key={JSON.stringify(answer)}
className="mx-3 mb-1 bg-dark rounded border border-slate-600 flex flex-row"
>
<div className="text-right my-auto">{answer.text} - </div>
<div className="">{answer.matchedText}</div>
</div>
))}
{question.matchDistractors.map((distractor) => (
<div
key={distractor}
className="mx-3 mb-1 bg-dark px-2 rounded border flex row"
>
DISTRACTOR: {distractor}
</div>
))}
</div>
)}
{question.questionType !== QuestionType.MATCHING && (
<div>
{question.answers.map((answer) => (
<div
key={JSON.stringify(answer)}
className="mx-3 mb-1 pt-1 border-t border-slate-700 flex flex-row"
>
<div className="w-8 flex flex-col justify-center">
{answer.correct ? (
<CheckIcon />
) : question.questionType === QuestionType.MULTIPLE_ANSWERS ? (
<span className="mx-auto">{"[ ]"}</span>
) : (
<div></div>
)}
</div>
<div
className="markdownQuizAnswerPreview"
dangerouslySetInnerHTML={{
__html: markdownToHTMLSafe(answer.text, settings),
}}
/>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,5 @@
import React, { ReactNode, Suspense } from "react";
export default function layout({ children }: { children: ReactNode }) {
return <Suspense>{children}</Suspense>;
}

View File

@@ -0,0 +1,10 @@
import { Spinner } from "@/components/Spinner";
import React from "react";
export default function Loading() {
return (
<div>
<Spinner />
</div>
);
}

View File

@@ -0,0 +1,13 @@
import React from "react";
import EditQuiz from "./EditQuiz";
export default async function Page({
params,
}: {
params: Promise<{ quizName: string; moduleName: string }>;
}) {
const { moduleName, quizName } = await params;
const decodedQuizName = decodeURIComponent(quizName)
const decodedModuleName = decodeURIComponent(moduleName)
return <EditQuiz quizName={decodedQuizName} moduleName={decodedModuleName} />;
}

View File

@@ -0,0 +1,31 @@
import CourseCalendar from "./calendar/CourseCalendar";
import CourseSettingsLink from "./CourseSettingsLink";
import ModuleList from "./modules/ModuleList";
import DraggingContextProvider from "./context/drag/DraggingContextProvider";
import CourseTitle from "./CourseTitle";
import { CourseNavigation } from "./CourseNavigation";
import { DragStyleContextProvider } from "./context/drag/dragStyleContext";
export default async function CoursePage({}: {}) {
return (
<>
<CourseTitle />
<div className="h-full flex flex-col">
<DragStyleContextProvider>
<DraggingContextProvider>
<div className="flex sm:flex-row h-full flex-col">
<div className="flex-1 h-full flex flex-col">
<CourseNavigation />
<CourseCalendar />
</div>
<div className="w-96 sm:p-3 h-full overflow-y-auto">
<CourseSettingsLink />
<ModuleList />
</div>
</div>
</DraggingContextProvider>
</DragStyleContextProvider>
</div>
</>
);
}

View File

@@ -0,0 +1,25 @@
"use client"
import AssignmentGroupManagement from "./AssignmentGroupManagement";
import DaysOfWeekSettings from "./DaysOfWeekSettings";
import DefaultDueTime from "./DefaultDueTime";
import DefaultFileUploadTypes from "./DefaultFileUploadTypes";
import HolidayConfig from "./HolidayConfig";
import SettingsHeader from "./SettingsHeader";
import StartAndEndDate from "./StartAndEndDate";
import SubmissionDefaults from "./SubmissionDefaults";
export default function AllSettings() {
return (
<>
<SettingsHeader />
<DaysOfWeekSettings />
<StartAndEndDate />
<SubmissionDefaults />
<DefaultFileUploadTypes />
<DefaultDueTime />
<AssignmentGroupManagement />
<HolidayConfig />
</>
);
}

View File

@@ -0,0 +1,167 @@
"use client";
import {
useLocalCourseSettingsQuery,
useUpdateLocalCourseSettingsMutation,
} from "@/hooks/localCourse/localCoursesHooks";
import { LocalAssignmentGroup } from "@/models/local/assignment/localAssignmentGroup";
import { useEffect, useState } from "react";
import TextInput from "../../../../components/form/TextInput";
import { useSetAssignmentGroupsMutation } from "@/hooks/canvas/canvasCourseHooks";
import { settingsBox } from "./sharedSettings";
import { Spinner } from "@/components/Spinner";
export default function AssignmentGroupManagement() {
const [settings, { isPending }] = useLocalCourseSettingsQuery();
const updateSettings = useUpdateLocalCourseSettingsMutation();
const applyInCanvas = useSetAssignmentGroupsMutation(settings.canvasId);
const [assignmentGroups, setAssignmentGroups] = useState<
LocalAssignmentGroup[]
>(settings.assignmentGroups);
useEffect(() => {
const delay = 1000;
const handler = setTimeout(() => {
if (
!areAssignmentGroupsEqual(assignmentGroups, settings.assignmentGroups)
) {
console.log(
"updating",
assignmentGroups,
updateSettings.isPending,
isPending
);
updateSettings.mutate({
settings: {
...settings,
assignmentGroups,
},
});
}
}, delay);
return () => {
clearTimeout(handler);
};
}, [assignmentGroups, isPending, settings, updateSettings]);
return (
<div className={settingsBox}>
{assignmentGroups.map((group) => (
<div key={group.id} className="flex flex-row gap-3">
<TextInput
value={group.name}
setValue={(newValue) =>
setAssignmentGroups((oldGroups) =>
oldGroups.map((g) =>
g.id === group.id ? { ...g, name: newValue } : g
)
)
}
label={"Group Name"}
/>
<TextInput
value={group.weight.toString()}
setValue={(newValue) =>
setAssignmentGroups((oldGroups) =>
oldGroups.map((g) =>
g.id === group.id
? { ...g, weight: parseInt(newValue || "0") }
: g
)
)
}
label={"Weight"}
/>
</div>
))}
<div className="flex gap-3 mt-3">
<button
className="btn-danger"
onClick={() => {
setAssignmentGroups((oldGroups) => oldGroups.slice(0, -1));
}}
>
Remove Assignment Group
</button>
<button
onClick={() => {
setAssignmentGroups((oldGroups) => [
...oldGroups,
{
id: Date.now().toString(),
name: "",
weight: 0,
},
]);
}}
>
Add Assignment Group
</button>
</div>
<br />
<div className="flex justify-end">
<button
onClick={async () => {
const newSettings = await applyInCanvas.mutateAsync(settings);
// prevent debounce from resetting
if (newSettings) setAssignmentGroups(newSettings.assignmentGroups);
}}
disabled={applyInCanvas.isPending}
>
Update Assignment Groups In Canvas
</button>
</div>
{applyInCanvas.isPending && <Spinner />}
{applyInCanvas.isSuccess && (
<div>
{
"You will need to go to your course assignments page > settings > Assignment Group Weights"
}
<br />
{"and check the 'Weight final grade based on assignment groups' box"}
</div>
)}
</div>
);
}
function areAssignmentGroupsEqual(
list1: LocalAssignmentGroup[],
list2: LocalAssignmentGroup[]
): boolean {
// Check if lists have the same length
if (list1.length !== list2.length) return false;
// Sort both lists by the unique 'id' or 'canvasId' as a fallback
const sortedList1 = [...list1].sort((a, b) => {
if (a.id !== b.id) return a.id > b.id ? 1 : -1;
if (a.canvasId !== b.canvasId) return (a.canvasId || 0) - (b.canvasId || 0);
return 0;
});
const sortedList2 = [...list2].sort((a, b) => {
if (a.id !== b.id) return a.id > b.id ? 1 : -1;
if (a.canvasId !== b.canvasId) return (a.canvasId || 0) - (b.canvasId || 0);
return 0;
});
// Deep compare each object in the sorted lists
for (let i = 0; i < sortedList1.length; i++) {
const group1 = sortedList1[i];
const group2 = sortedList2[i];
if (
group1.id !== group2.id ||
group1.name !== group2.name ||
group1.weight !== group2.weight ||
group1.canvasId !== group2.canvasId
) {
return false;
}
}
return true;
}

View File

@@ -0,0 +1,34 @@
"use client";
import { DayOfWeekInput } from "@/components/form/DayOfWeekInput";
import { Spinner } from "@/components/Spinner";
import {
useLocalCourseSettingsQuery,
useUpdateLocalCourseSettingsMutation,
} from "@/hooks/localCourse/localCoursesHooks";
import React from "react";
export default function DaysOfWeekSettings() {
const [settings] = useLocalCourseSettingsQuery();
const updateSettings = useUpdateLocalCourseSettingsMutation();
return (
<>
<DayOfWeekInput
selectedDays={settings.daysOfWeek}
updateSettings={(day) => {
const hasDay = settings.daysOfWeek.includes(day);
updateSettings.mutate({
settings: {
...settings,
daysOfWeek: hasDay
? settings.daysOfWeek.filter((d) => d !== day)
: [day, ...settings.daysOfWeek],
},
});
}}
/>
{updateSettings.isPending && <Spinner />}
</>
);
}

View File

@@ -0,0 +1,71 @@
"use client";
import {
useLocalCourseSettingsQuery,
useUpdateLocalCourseSettingsMutation,
} from "@/hooks/localCourse/localCoursesHooks";
import { TimePicker } from "../../../../components/TimePicker";
import { useState } from "react";
import DefaultLockOffset from "./DefaultLockOffset";
import { settingsBox } from "./sharedSettings";
export default function DefaultDueTime() {
const [settings] = useLocalCourseSettingsQuery();
const updateSettings = useUpdateLocalCourseSettingsMutation();
const [haveLockOffset, setHaveLockOffset] = useState(
typeof settings.defaultLockHoursOffset !== "undefined"
);
return (
<div className={settingsBox}>
<div className="text-center">Default Assignment Due Time</div>
<hr className="m-1 p-0" />
<TimePicker
time={settings.defaultDueTime}
setChosenTime={(simpleTime) => {
console.log(simpleTime);
updateSettings.mutate({
settings: {
...settings,
defaultDueTime: simpleTime,
},
});
}}
/>
<hr />
{!haveLockOffset && (
<button
onClick={async () => {
await updateSettings.mutateAsync({
settings: {
...settings,
defaultLockHoursOffset: 0,
},
});
setHaveLockOffset(true);
}}
>
have a default Lock Offset?
</button>
)}
{haveLockOffset && <DefaultLockOffset />}
<br />
{haveLockOffset && (
<button
className="btn-danger"
onClick={async () => {
await updateSettings.mutateAsync({
settings: {
...settings,
defaultLockHoursOffset: undefined,
},
});
setHaveLockOffset(false);
}}
>
remove default Lock Offset?
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,68 @@
"use client";
import TextInput from "@/components/form/TextInput";
import {
useLocalCourseSettingsQuery,
useUpdateLocalCourseSettingsMutation,
} from "@/hooks/localCourse/localCoursesHooks";
import { useState, useEffect } from "react";
import { settingsBox } from "./sharedSettings";
export default function DefaultFileUploadTypes() {
const [settings] = useLocalCourseSettingsQuery();
const [defaultFileUploadTypes, setDefaultFileUploadTypes] = useState<
string[]
>(settings.defaultFileUploadTypes);
const updateSettings = useUpdateLocalCourseSettingsMutation();
useEffect(() => {
const id = setTimeout(() => {
if (
JSON.stringify(settings.defaultFileUploadTypes) !==
JSON.stringify(defaultFileUploadTypes)
) {
updateSettings.mutate({
settings: {
...settings,
defaultFileUploadTypes: defaultFileUploadTypes,
},
});
}
}, 500);
return () => clearTimeout(id);
}, [defaultFileUploadTypes, settings, updateSettings]);
return (
<div className={settingsBox}>
<div className="text-center">Default File Upload Types</div>
{defaultFileUploadTypes.map((type, index) => (
<div key={index} className="flex flex-row gap-3">
<TextInput
value={type}
setValue={(newValue) =>
setDefaultFileUploadTypes((oldTypes) =>
oldTypes.map((t, i) => (i === index ? newValue : t))
)
}
label={"Default Type " + index}
/>
</div>
))}
<div className="flex gap-3 mt-3">
<button
className="btn-danger"
onClick={() => {
setDefaultFileUploadTypes((old) => old.slice(0, -1));
}}
>
Remove Default File Upload Type
</button>
<button
onClick={() => setDefaultFileUploadTypes((old) => [...old, ""])}
>
Add Default File Upload Type
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
"use client";
import TextInput from "@/components/form/TextInput";
import {
useLocalCourseSettingsQuery,
useUpdateLocalCourseSettingsMutation,
} from "@/hooks/localCourse/localCoursesHooks";
import { useEffect, useState } from "react";
export default function DefaultLockOffset() {
const [settings] = useLocalCourseSettingsQuery();
const updateSettings = useUpdateLocalCourseSettingsMutation();
const [hoursOffset, setHoursOffset] = useState(
settings.defaultLockHoursOffset?.toString() ?? "0"
);
useEffect(() => {
const id = setTimeout(() => {
try {
const hoursNumber = parseInt(hoursOffset);
if (
!Number.isNaN(hoursNumber) &&
hoursNumber !== settings.defaultLockHoursOffset
) {
updateSettings.mutate({
settings: {
...settings,
defaultLockHoursOffset: hoursNumber,
},
});
}
} catch {}
}, 500);
return () => clearTimeout(id);
}, [hoursOffset, settings, settings.defaultLockHoursOffset, updateSettings]);
return (
<div>
<div className="text-center">Default Assignment Due Time</div>
<hr className="m-1 p-0" />
<TextInput
value={hoursOffset}
setValue={(n) => setHoursOffset(n)}
label={"Hours Offset"}
/>
</div>
);
}

View File

@@ -0,0 +1,160 @@
"use client";
import TextInput from "@/components/form/TextInput";
import { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling";
import {
useLocalCourseSettingsQuery,
useUpdateLocalCourseSettingsMutation,
} from "@/hooks/localCourse/localCoursesHooks";
import { getDateFromString } from "@/models/local/utils/timeUtils";
import { useEffect, useState } from "react";
import {
holidaysToString,
parseHolidays,
} from "../../../../models/local/utils/settingsUtils";
import { settingsBox } from "./sharedSettings";
const exampleString = `springBreak:
- 10/12/2024
- 10/13/2024
- 10/14/2024
laborDay:
- 9/1/2024`;
export const holidaysAreEqual = (
holidays1: {
name: string;
days: string[];
}[],
holidays2: {
name: string;
days: string[];
}[]
): boolean => {
if (holidays1.length !== holidays2.length) return false;
const sortedObj1 = [...holidays1].sort((a, b) => a.name.localeCompare(b.name));
const sortedObj2 = [...holidays2].sort((a, b) => a.name.localeCompare(b.name));
for (let i = 0; i < sortedObj1.length; i++) {
const holiday1 = sortedObj1[i];
const holiday2 = sortedObj2[i];
if (holiday1.name !== holiday2.name) return false;
const sortedDays1 = [...holiday1.days].sort();
const sortedDays2 = [...holiday2.days].sort();
if (sortedDays1.length !== sortedDays2.length) return false;
for (let j = 0; j < sortedDays1.length; j++) {
if (sortedDays1[j] !== sortedDays2[j]) return false;
}
}
return true;
};
export default function HolidayConfig() {
return (
<SuspenseAndErrorHandling>
<InnerHolidayConfig />
</SuspenseAndErrorHandling>
);
}
function InnerHolidayConfig() {
const [settings] = useLocalCourseSettingsQuery();
const updateSettings = useUpdateLocalCourseSettingsMutation();
const [rawText, setRawText] = useState(holidaysToString(settings.holidays));
useEffect(() => {
const id = setTimeout(() => {
try {
const parsed = parseHolidays(rawText);
if (!holidaysAreEqual(settings.holidays, parsed)) {
console.log("different holiday configs", settings.holidays, parsed);
updateSettings.mutate({
settings: {
...settings,
holidays: parsed,
},
});
}
} catch (error: any) {}
}, 500);
return () => clearTimeout(id);
}, [rawText, settings.holidays, settings, updateSettings]);
return (
<div className={settingsBox}>
<div className="flex flex-row gap-3">
<TextInput
value={rawText}
setValue={setRawText}
label={"Holiday Days"}
isTextArea={true}
/>
<div>
Format your holidays like so:
<pre>
<code>{exampleString}</code>
</pre>
</div>
</div>
<div>
<SuspenseAndErrorHandling>
<ParsedHolidaysDisplay value={rawText} />
</SuspenseAndErrorHandling>
</div>
</div>
);
}
function ParsedHolidaysDisplay({ value }: { value: string }) {
const [parsedHolidays, setParsedHolidays] = useState<
{
name: string;
days: string[];
}[]
>([]);
const [error, setError] = useState("");
useEffect(() => {
try {
const parsed = parseHolidays(value);
setParsedHolidays(parsed);
setError("");
} catch (error: any) {
setError(error + "");
}
}, [value]);
return (
<div>
<div className="text-rose-500">{error}</div>
{parsedHolidays.map((holiday) => (
<div key={holiday.name}>
<div>{holiday.name}</div>
<div>
{holiday.days.map((day) => {
const date = getDateFromString(day);
return (
<div key={day}>
{date?.toLocaleDateString("en-us", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
})}
</div>
);
})}
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,28 @@
"use client";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import { getCourseUrl } from "@/services/urlUtils";
import Link from "next/link";
import React from "react";
import { useCourseContext } from "../context/courseContext";
export default function SettingsHeader() {
const { courseName } = useCourseContext();
const [settings] = useLocalCourseSettingsQuery();
return (
<>
<div className="flex flex-row justify-between">
<div className="my-auto">
<Link className="btn" href={getCourseUrl(courseName)}>
Back To Course
</Link>
</div>
<h3 className="text-center mb-3">
{settings.name}{" "}
<span className="text-slate-500 text-xl"> settings</span>
</h3>
<div></div>
</div>
<hr />
</>
);
}

View File

@@ -0,0 +1,17 @@
"use client";
import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import { getDateOnlyMarkdownString } from "@/models/local/utils/timeUtils";
import React from "react";
import { settingsBox } from "./sharedSettings";
export default function StartAndEndDate() {
const [settings] = useLocalCourseSettingsQuery();
const startDate = new Date(settings.startDate);
const endDate = new Date(settings.endDate);
return (
<div className={settingsBox}>
<div>Semester Start: {getDateOnlyMarkdownString(startDate)}</div>
<div>Semester End: {getDateOnlyMarkdownString(endDate)}</div>
</div>
);
}

View File

@@ -0,0 +1,77 @@
"use client";
import SelectInput from "@/components/form/SelectInput";
import {
useLocalCourseSettingsQuery,
useUpdateLocalCourseSettingsMutation,
} from "@/hooks/localCourse/localCoursesHooks";
import {
AssignmentSubmissionType,
AssignmentSubmissionTypeList,
} from "@/models/local/assignment/assignmentSubmissionType";
import React, { useEffect, useState } from "react";
import { settingsBox } from "./sharedSettings";
export default function SubmissionDefaults() {
const [settings] = useLocalCourseSettingsQuery();
const [defaultSubmissionTypes, setDefaultSubmissionTypes] = useState<
AssignmentSubmissionType[]
>(settings.defaultAssignmentSubmissionTypes);
const updateSettings = useUpdateLocalCourseSettingsMutation();
useEffect(() => {
if (
JSON.stringify(settings.defaultAssignmentSubmissionTypes) !==
JSON.stringify(defaultSubmissionTypes)
) {
updateSettings.mutate({
settings: {
...settings,
defaultAssignmentSubmissionTypes: defaultSubmissionTypes,
},
});
}
}, [defaultSubmissionTypes, settings, updateSettings]);
return (
<div className={settingsBox}>
<div className="text-center">Default Assignment Submission Type</div>
{defaultSubmissionTypes.map((type, index) => (
<div key={index} className="flex flex-row gap-3">
<SelectInput
value={type}
setValue={(newType) => {
if (newType)
setDefaultSubmissionTypes((oldTypes) =>
oldTypes.map((t, i) => (i === index ? newType : t))
);
}}
label={""}
options={AssignmentSubmissionTypeList}
getOptionName={(t) => t}
/>
</div>
))}
<div className="flex gap-3 mt-3">
<button
className="btn-danger"
onClick={() => {
setDefaultSubmissionTypes((old) => old.slice(0, -1));
}}
>
Remove Default Type
</button>
<button
onClick={() =>
setDefaultSubmissionTypes((old) => [
...old,
AssignmentSubmissionType.NONE,
])
}
>
Add Default Type
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import AllSettings from "./AllSettings";
export default function page() {
return (
<div className="flex justify-center h-full overflow-auto pt-5 ">
<div className=" w-fit ">
<AllSettings />
<br />
<br />
<br />
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export const settingsBox = "border w-full p-3 m-3 rounded-md border-slate-500"

View File

@@ -0,0 +1,48 @@
"use client";
import { useState, useMemo, useCallback } from "react";
export function useAuthoritativeUpdates({
serverUpdatedAt,
startingText,
}: {
serverUpdatedAt: number;
startingText: string;
}) {
const [text, setText] = useState(startingText);
const [clientDataUpdatedAt, setClientDataUpdatedAt] =
useState(serverUpdatedAt);
const [updateMonacoKey, setUpdateMonacoKey] = useState(1);
const clientIsAuthoritative = useMemo(() => {
// const authority = serverUpdatedAt <= clientDataUpdatedAt;
const estimatedNetworkRoundTrip = 500; // network latency means client is still authoritative for a slight delay
const authority =
serverUpdatedAt <= clientDataUpdatedAt + estimatedNetworkRoundTrip;
// console.log("client is authoritative", authority);
return authority;
}, [clientDataUpdatedAt, serverUpdatedAt]);
const textUpdate = useCallback((t: string, updateMonaco: boolean = false) => {
setText(t);
setClientDataUpdatedAt(Date.now());
if (updateMonaco) setUpdateMonacoKey((t) => t + 1);
}, []);
return useMemo(
() => ({
clientIsAuthoritative,
serverUpdatedAt,
clientDataUpdatedAt,
textUpdate,
text,
monacoKey: updateMonacoKey,
}),
[
clientDataUpdatedAt,
clientIsAuthoritative,
serverUpdatedAt,
text,
textUpdate,
updateMonacoKey,
]
);
}