mirror of
https://github.com/alexmickelson/canvasManagement.git
synced 2026-03-26 07:38:33 -06:00
moving v2 to top level
This commit is contained in:
81
src/app/course/[courseName]/CourseNavigation.tsx
Normal file
81
src/app/course/[courseName]/CourseNavigation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/app/course/[courseName]/CourseSettingsLink.tsx
Normal file
24
src/app/course/[courseName]/CourseSettingsLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
src/app/course/[courseName]/CourseTitle.tsx
Normal file
10
src/app/course/[courseName]/CourseTitle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
src/app/course/[courseName]/calendar/CalendarMonth.tsx
Normal file
69
src/app/course/[courseName]/calendar/CalendarMonth.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
36
src/app/course/[courseName]/calendar/CalendarWeek.tsx
Normal file
36
src/app/course/[courseName]/calendar/CalendarWeek.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/app/course/[courseName]/calendar/CourseCalendar.tsx
Normal file
46
src/app/course/[courseName]/calendar/CourseCalendar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
src/app/course/[courseName]/calendar/calendarMonthUtils.ts
Normal file
108
src/app/course/[courseName]/calendar/calendarMonthUtils.ts
Normal 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;
|
||||
};
|
||||
47
src/app/course/[courseName]/calendar/calendarUtils.test.ts
Normal file
47
src/app/course/[courseName]/calendar/calendarUtils.test.ts
Normal 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);
|
||||
});
|
||||
|
||||
});
|
||||
126
src/app/course/[courseName]/calendar/day/Day.tsx
Normal file
126
src/app/course/[courseName]/calendar/day/Day.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
src/app/course/[courseName]/calendar/day/DayTitle.tsx
Normal file
59
src/app/course/[courseName]/calendar/day/DayTitle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
src/app/course/[courseName]/calendar/day/ItemInDay.tsx
Normal file
103
src/app/course/[courseName]/calendar/day/ItemInDay.tsx
Normal 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
|
||||
);
|
||||
};
|
||||
119
src/app/course/[courseName]/calendar/day/getStatus.tsx
Normal file
119
src/app/course/[courseName]/calendar/day/getStatus.tsx
Normal 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: "" };
|
||||
};
|
||||
101
src/app/course/[courseName]/calendar/day/useTodaysItems.tsx
Normal file
101
src/app/course/[courseName]/calendar/day/useTodaysItems.tsx
Normal 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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
39
src/app/course/[courseName]/context/LectureReplaceModal.tsx
Normal file
39
src/app/course/[courseName]/context/LectureReplaceModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/app/course/[courseName]/context/calendarItemsContext.ts
Normal file
22
src/app/course/[courseName]/context/calendarItemsContext.ts
Normal 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);
|
||||
}
|
||||
18
src/app/course/[courseName]/context/courseContext.ts
Normal file
18
src/app/course/[courseName]/context/courseContext.ts
Normal 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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
24
src/app/course/[courseName]/context/drag/draggingContext.tsx
Normal file
24
src/app/course/[courseName]/context/drag/draggingContext.tsx
Normal 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);
|
||||
}
|
||||
25
src/app/course/[courseName]/context/drag/getNewLockDate.ts
Normal file
25
src/app/course/[courseName]/context/drag/getNewLockDate.ts
Normal 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);
|
||||
}
|
||||
212
src/app/course/[courseName]/context/drag/useItemDropOnDay.ts
Normal file
212
src/app/course/[courseName]/context/drag/useItemDropOnDay.ts
Normal 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,
|
||||
]
|
||||
);
|
||||
}
|
||||
105
src/app/course/[courseName]/context/drag/useItemDropOnModule.ts
Normal file
105
src/app/course/[courseName]/context/drag/useItemDropOnModule.ts
Normal 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,
|
||||
]
|
||||
);
|
||||
}
|
||||
24
src/app/course/[courseName]/layout.tsx
Normal file
24
src/app/course/[courseName]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/app/course/[courseName]/lecture/[lectureDay]/EditLecture.tsx
Normal file
112
src/app/course/[courseName]/lecture/[lectureDay]/EditLecture.tsx
Normal 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}
|
||||
---
|
||||
`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
24
src/app/course/[courseName]/lecture/[lectureDay]/layout.tsx
Normal file
24
src/app/course/[courseName]/lecture/[lectureDay]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/app/course/[courseName]/lecture/[lectureDay]/page.tsx
Normal file
22
src/app/course/[courseName]/lecture/[lectureDay]/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
src/app/course/[courseName]/loading.tsx
Normal file
10
src/app/course/[courseName]/loading.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import React from "react";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/app/course/[courseName]/modules/CreateModule.tsx
Normal file
41
src/app/course/[courseName]/modules/CreateModule.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
174
src/app/course/[courseName]/modules/ExpandableModule.tsx
Normal file
174
src/app/course/[courseName]/modules/ExpandableModule.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/app/course/[courseName]/modules/ModuleCanvasStatus.tsx
Normal file
34
src/app/course/[courseName]/modules/ModuleCanvasStatus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/app/course/[courseName]/modules/ModuleList.tsx
Normal file
22
src/app/course/[courseName]/modules/ModuleList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
189
src/app/course/[courseName]/modules/NewItemForm.tsx
Normal file
189
src/app/course/[courseName]/modules/NewItemForm.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import React, { ReactNode, Suspense } from "react";
|
||||
|
||||
export default function layout({ children }: { children: ReactNode }) {
|
||||
return <Suspense>{children}</Suspense>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import React from "react";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import React, { ReactNode, Suspense } from "react";
|
||||
|
||||
export default function layout({ children }: { children: ReactNode }) {
|
||||
return <Suspense>{children}</Suspense>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import React from "react";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import React, { ReactNode, Suspense } from "react";
|
||||
|
||||
export default function layout({ children }: { children: ReactNode }) {
|
||||
return <Suspense>{children}</Suspense>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import React from "react";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
31
src/app/course/[courseName]/page.tsx
Normal file
31
src/app/course/[courseName]/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
25
src/app/course/[courseName]/settings/AllSettings.tsx
Normal file
25
src/app/course/[courseName]/settings/AllSettings.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
34
src/app/course/[courseName]/settings/DaysOfWeekSettings.tsx
Normal file
34
src/app/course/[courseName]/settings/DaysOfWeekSettings.tsx
Normal 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 />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
71
src/app/course/[courseName]/settings/DefaultDueTime.tsx
Normal file
71
src/app/course/[courseName]/settings/DefaultDueTime.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
49
src/app/course/[courseName]/settings/DefaultLockOffset.tsx
Normal file
49
src/app/course/[courseName]/settings/DefaultLockOffset.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
src/app/course/[courseName]/settings/HolidayConfig.tsx
Normal file
160
src/app/course/[courseName]/settings/HolidayConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/app/course/[courseName]/settings/SettingsHeader.tsx
Normal file
28
src/app/course/[courseName]/settings/SettingsHeader.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
17
src/app/course/[courseName]/settings/StartAndEndDate.tsx
Normal file
17
src/app/course/[courseName]/settings/StartAndEndDate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
src/app/course/[courseName]/settings/SubmissionDefaults.tsx
Normal file
77
src/app/course/[courseName]/settings/SubmissionDefaults.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
src/app/course/[courseName]/settings/page.tsx
Normal file
14
src/app/course/[courseName]/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/app/course/[courseName]/settings/sharedSettings.ts
Normal file
1
src/app/course/[courseName]/settings/sharedSettings.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const settingsBox = "border w-full p-3 m-3 rounded-md border-slate-500"
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user