can add new courses, kinda janky

This commit is contained in:
2025-07-22 15:09:10 -06:00
parent 704a5ae404
commit 46e0c36916
7 changed files with 197 additions and 109 deletions

View File

@@ -7,3 +7,5 @@ courses:
name: Web Intro name: Web Intro
- path: ./1430/2025-fall-alex/modules/ - path: ./1430/2025-fall-alex/modules/
name: UX name: UX
- path: ./1420/2025-fall-alex/labModules
name: "1425"

View File

@@ -2,15 +2,24 @@
import ButtonSelect from "@/components/ButtonSelect"; import ButtonSelect from "@/components/ButtonSelect";
import { DayOfWeekInput } from "@/components/form/DayOfWeekInput"; import { DayOfWeekInput } from "@/components/form/DayOfWeekInput";
import SelectInput from "@/components/form/SelectInput"; import SelectInput from "@/components/form/SelectInput";
import { StoragePathSelector } from "@/components/form/StoragePathSelector";
import TextInput from "@/components/form/TextInput";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
import { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling"; import { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling";
import { useCourseListInTermQuery } from "@/hooks/canvas/canvasCourseHooks"; import { useCourseListInTermQuery } from "@/hooks/canvas/canvasCourseHooks";
import { useCanvasTermsQuery } from "@/hooks/canvas/canvasHooks"; import { useCanvasTermsQuery } from "@/hooks/canvas/canvasHooks";
import {
useGlobalSettingsQuery,
useUpdateGlobalSettingsMutation,
} from "@/hooks/localCourse/globalSettingsHooks";
import { import {
useCreateLocalCourseMutation, useCreateLocalCourseMutation,
useLocalCoursesSettingsQuery, useLocalCoursesSettingsQuery,
} from "@/hooks/localCourse/localCoursesHooks"; } from "@/hooks/localCourse/localCoursesHooks";
import { useEmptyDirectoriesQuery } from "@/hooks/localCourse/storageDirectoryHooks"; import {
useDirectoryIsCourseQuery,
useEmptyDirectoriesQuery,
} from "@/hooks/localCourse/storageDirectoryHooks";
import { CanvasCourseModel } from "@/models/canvas/courses/canvasCourseModel"; import { CanvasCourseModel } from "@/models/canvas/courses/canvasCourseModel";
import { CanvasEnrollmentTermModel } from "@/models/canvas/enrollmentTerms/canvasEnrollmentTermModel"; import { CanvasEnrollmentTermModel } from "@/models/canvas/enrollmentTerms/canvasEnrollmentTermModel";
import { AssignmentSubmissionType } from "@/models/local/assignment/assignmentSubmissionType"; import { AssignmentSubmissionType } from "@/models/local/assignment/assignmentSubmissionType";
@@ -20,7 +29,7 @@ import {
} from "@/models/local/localCourseSettings"; } from "@/models/local/localCourseSettings";
import { getCourseUrl } from "@/services/urlUtils"; import { getCourseUrl } from "@/services/urlUtils";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import React, { useMemo, useState } from "react"; import React, { Dispatch, SetStateAction, useMemo, useState } from "react";
const sampleCompose = `services: const sampleCompose = `services:
canvas_manager: canvas_manager:
@@ -55,6 +64,7 @@ export default function AddNewCourseToGlobalSettingsForm() {
const [courseToImport, setCourseToImport] = useState< const [courseToImport, setCourseToImport] = useState<
LocalCourseSettings | undefined LocalCourseSettings | undefined
>(); >();
const [name, setName] = useState("");
const createCourse = useCreateLocalCourseMutation(); const createCourse = useCreateLocalCourseMutation();
const formIsComplete = const formIsComplete =
@@ -82,6 +92,8 @@ export default function AddNewCourseToGlobalSettingsForm() {
setSelectedDaysOfWeek={setSelectedDaysOfWeek} setSelectedDaysOfWeek={setSelectedDaysOfWeek}
courseToImport={courseToImport} courseToImport={courseToImport}
setCourseToImport={setCourseToImport} setCourseToImport={setCourseToImport}
name={name}
setName={setName}
/> />
)} )}
</SuspenseAndErrorHandling> </SuspenseAndErrorHandling>
@@ -90,6 +102,7 @@ export default function AddNewCourseToGlobalSettingsForm() {
disabled={!formIsComplete || createCourse.isPending} disabled={!formIsComplete || createCourse.isPending}
onClick={async () => { onClick={async () => {
if (formIsComplete) { if (formIsComplete) {
console.log("Creating course with settings:", selectedDirectory);
const newSettings: LocalCourseSettings = courseToImport const newSettings: LocalCourseSettings = courseToImport
? { ? {
...courseToImport, ...courseToImport,
@@ -128,8 +141,10 @@ export default function AddNewCourseToGlobalSettingsForm() {
await createCourse.mutateAsync({ await createCourse.mutateAsync({
settings: newSettings, settings: newSettings,
settingsFromCourseToImport: courseToImport, settingsFromCourseToImport: courseToImport,
name,
directory: selectedDirectory,
}); });
router.push(getCourseUrl(selectedDirectory)); router.push(getCourseUrl(name));
} }
}} }}
> >
@@ -156,26 +171,29 @@ function OtherSettings({
setSelectedDaysOfWeek, setSelectedDaysOfWeek,
courseToImport, courseToImport,
setCourseToImport, setCourseToImport,
name,
setName,
}: { }: {
selectedTerm: CanvasEnrollmentTermModel; selectedTerm: CanvasEnrollmentTermModel;
selectedCanvasCourse: CanvasCourseModel | undefined; selectedCanvasCourse: CanvasCourseModel | undefined;
setSelectedCanvasCourse: React.Dispatch< setSelectedCanvasCourse: Dispatch<
React.SetStateAction<CanvasCourseModel | undefined> SetStateAction<CanvasCourseModel | undefined>
>; >;
selectedDirectory: string | undefined; selectedDirectory: string | undefined;
setSelectedDirectory: React.Dispatch< setSelectedDirectory: Dispatch<SetStateAction<string | undefined>>;
React.SetStateAction<string | undefined>
>;
selectedDaysOfWeek: DayOfWeek[]; selectedDaysOfWeek: DayOfWeek[];
setSelectedDaysOfWeek: React.Dispatch<React.SetStateAction<DayOfWeek[]>>; setSelectedDaysOfWeek: Dispatch<SetStateAction<DayOfWeek[]>>;
courseToImport: LocalCourseSettings | undefined; courseToImport: LocalCourseSettings | undefined;
setCourseToImport: React.Dispatch< setCourseToImport: Dispatch<SetStateAction<LocalCourseSettings | undefined>>;
React.SetStateAction<LocalCourseSettings | undefined> name: string;
>; setName: Dispatch<SetStateAction<string>>;
}) { }) {
const { data: canvasCourses } = useCourseListInTermQuery(selectedTerm.id); const { data: canvasCourses } = useCourseListInTermQuery(selectedTerm.id);
const { data: allSettings } = useLocalCoursesSettingsQuery(); const { data: allSettings } = useLocalCoursesSettingsQuery();
const { data: emptyDirectories } = useEmptyDirectoriesQuery(); const [directory, setDirectory] = useState("./");
// const directoryIsCourseQuery = useDirectoryIsCourseQuery(
// selectedDirectory ?? "./"
// );
const populatedCanvasCourseIds = allSettings?.map((s) => s.canvasId) ?? []; const populatedCanvasCourseIds = allSettings?.map((s) => s.canvasId) ?? [];
const availableCourses = const availableCourses =
@@ -194,18 +212,13 @@ function OtherSettings({
getOptionName={(c) => c?.name ?? ""} getOptionName={(c) => c?.name ?? ""}
center={true} center={true}
/> />
<SelectInput
value={selectedDirectory} <StoragePathSelector
setValue={setSelectedDirectory} value={directory}
setValue={setDirectory}
setLastTypedValue={setSelectedDirectory}
label={"Storage Folder"} label={"Storage Folder"}
options={emptyDirectories ?? []}
getOptionName={(d) => d}
emptyOptionText="--- add a new folder to your docker compose to add more folders ---"
/> />
<div className="px-5">
New folders will not be created automatically, you are expected to mount
a docker volume for each courses.
</div>
<br /> <br />
<div className="flex justify-center"> <div className="flex justify-center">
<DayOfWeekInput <DayOfWeekInput
@@ -228,6 +241,7 @@ function OtherSettings({
options={allSettings} options={allSettings}
getOptionName={(c) => c.name} getOptionName={(c) => c.name}
/> />
<TextInput value={name} setValue={setName} label={"Display Name"} />
<div className="px-5"> <div className="px-5">
Assignments, Quizzes, Pages, and Lectures will have their due dates Assignments, Quizzes, Pages, and Lectures will have their due dates
moved based on how far they are from the start of the semester. moved based on how far they are from the start of the semester.

View File

@@ -7,11 +7,13 @@ export function StoragePathSelector({
setValue, setValue,
label, label,
className, className,
setLastTypedValue,
}: { }: {
value: string; value: string;
setValue: (newValue: string) => void; setValue: (newValue: string) => void;
label: string; label: string;
className?: string; className?: string;
setLastTypedValue?: (value: string) => void;
}) { }) {
const [path, setPath] = useState(value); const [path, setPath] = useState(value);
const { data: directoryContents } = useDirectoryContentsQuery(value); const { data: directoryContents } = useDirectoryContentsQuery(value);
@@ -25,6 +27,10 @@ export function StoragePathSelector({
setPath(value); setPath(value);
}, [value]); }, [value]);
useEffect(() => {
if (setLastTypedValue) setLastTypedValue(path);
}, [path, setLastTypedValue]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!isFocused || filteredFolders.length === 0) return; if (!isFocused || filteredFolders.length === 0) return;
if (e.key === "ArrowDown") { if (e.key === "ArrowDown") {

View File

@@ -6,6 +6,7 @@ import {
useMutation, useMutation,
useQueryClient, useQueryClient,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { useGlobalSettingsQuery } from "./globalSettingsHooks";
export const useLocalCoursesSettingsQuery = () => { export const useLocalCoursesSettingsQuery = () => {
const trpc = useTRPC(); const trpc = useTRPC();
@@ -23,6 +24,7 @@ export const useLocalCourseSettingsQuery = () => {
export const useCreateLocalCourseMutation = () => { export const useCreateLocalCourseMutation = () => {
const trpc = useTRPC(); const trpc = useTRPC();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation( return useMutation(
trpc.settings.createCourse.mutationOptions({ trpc.settings.createCourse.mutationOptions({
onSuccess: () => { onSuccess: () => {
@@ -32,6 +34,9 @@ export const useCreateLocalCourseMutation = () => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: trpc.directories.getEmptyDirectories.queryKey(), queryKey: trpc.directories.getEmptyDirectories.queryKey(),
}); });
queryClient.invalidateQueries({
queryKey: trpc.globalSettings.getGlobalSettings.queryKey(),
});
}, },
}) })
); );

View File

@@ -1,4 +1,7 @@
import { GlobalSettings } from "@/models/local/globalSettings"; import {
GlobalSettings,
zodGlobalSettings,
} from "@/models/local/globalSettings";
import { import {
globalSettingsToYaml, globalSettingsToYaml,
parseGlobalSettingsYaml, parseGlobalSettingsYaml,
@@ -39,6 +42,15 @@ export const getCoursePathByName = async (courseName: string) => {
}; };
export const updateGlobalSettings = async (globalSettings: GlobalSettings) => { export const updateGlobalSettings = async (globalSettings: GlobalSettings) => {
const globalSettingsString = globalSettingsToYaml(globalSettings); const globalSettingsString = globalSettingsToYaml(
zodGlobalSettings.parse(globalSettings)
);
await fs.writeFile(SETTINGS_FILE_PATH, globalSettingsString, "utf-8"); await fs.writeFile(SETTINGS_FILE_PATH, globalSettingsString, "utf-8");
// await Promise.all(
// globalSettings.courses.map(async (course) => {
// const coursePath = await getCoursePathByName(course.name);
// await fs.mkdir(coursePath, { recursive: true });
// })
// );
}; };

View File

@@ -86,6 +86,26 @@ export const settingsFileStorageService = {
console.log(`Saving settings ${settingsPath}`); console.log(`Saving settings ${settingsPath}`);
await fs.writeFile(settingsPath, settingsMarkdown); await fs.writeFile(settingsPath, settingsMarkdown);
}, },
async createCourseSettings(settings: LocalCourseSettings, directory: string) {
const courseDirectory = path.join(basePath, directory);
if (await directoryOrFileExists(courseDirectory)) {
throw new Error(
`Course path "${courseDirectory}" already exists. Create course in a new folder.`
);
}
await fs.mkdir(courseDirectory, { recursive: true });
const settingsPath = path.join(courseDirectory, "settings.yml");
const { name: _, ...settingsWithoutName } = settings;
const settingsMarkdown =
localCourseYamlUtils.settingsToYaml(settingsWithoutName);
console.log(`Saving settings ${settingsPath}`);
await fs.writeFile(settingsPath, settingsMarkdown);
},
async folderIsCourse(folderPath: string) { async folderIsCourse(folderPath: string) {
const settingsPath = path.join(basePath, folderPath, "settings.yml"); const settingsPath = path.join(basePath, folderPath, "settings.yml");
if (!(await directoryOrFileExists(settingsPath))) { if (!(await directoryOrFileExists(settingsPath))) {

View File

@@ -13,6 +13,13 @@ import {
prepPageForNewSemester, prepPageForNewSemester,
prepQuizForNewSemester, prepQuizForNewSemester,
} from "@/models/local/utils/semesterTransferUtils"; } from "@/models/local/utils/semesterTransferUtils";
import {
getGlobalSettings,
updateGlobalSettings,
} from "@/services/fileStorage/globalSettingsFileStorageService";
import { promises as fs } from "fs";
import { basePath } from "@/services/fileStorage/utils/fileSystemUtils";
import path from "path";
export const settingsRouter = router({ export const settingsRouter = router({
allCoursesSettings: publicProcedure.query(async () => { allCoursesSettings: publicProcedure.query(async () => {
@@ -37,101 +44,123 @@ export const settingsRouter = router({
createCourse: publicProcedure createCourse: publicProcedure
.input( .input(
z.object({ z.object({
name: z.string(),
directory: z.string(),
settings: zodLocalCourseSettings, settings: zodLocalCourseSettings,
settingsFromCourseToImport: zodLocalCourseSettings.optional(), settingsFromCourseToImport: zodLocalCourseSettings.optional(),
}) })
) )
.mutation(async ({ input: { settings, settingsFromCourseToImport } }) => { .mutation(
await fileStorageService.settings.updateCourseSettings( async ({
settings.name, input: { settings, settingsFromCourseToImport, name, directory },
settings }) => {
); console.log("creating in directory", directory);
await fileStorageService.settings.createCourseSettings(
if (settingsFromCourseToImport) { settings,
const oldCourseName = settingsFromCourseToImport.name; directory
const newCourseName = settings.name;
const oldModules = await fileStorageService.modules.getModuleNames(
oldCourseName
); );
await Promise.all(
oldModules.map(async (moduleName) => {
await fileStorageService.modules.createModule(
newCourseName,
moduleName
);
const [oldAssignments, oldQuizzes, oldPages, oldLecturesByWeek] = const globalSettings = await getGlobalSettings();
await updateGlobalSettings({
...globalSettings,
courses: [
...globalSettings.courses,
{
name,
path: directory,
},
],
});
if (settingsFromCourseToImport) {
const oldCourseName = settingsFromCourseToImport.name;
const newCourseName = settings.name;
const oldModules = await fileStorageService.modules.getModuleNames(
oldCourseName
);
await Promise.all(
oldModules.map(async (moduleName) => {
await fileStorageService.modules.createModule(
newCourseName,
moduleName
);
const [oldAssignments, oldQuizzes, oldPages, oldLecturesByWeek] =
await Promise.all([
fileStorageService.assignments.getAssignments(
oldCourseName,
moduleName
),
await fileStorageService.quizzes.getQuizzes(
oldCourseName,
moduleName
),
await fileStorageService.pages.getPages(
oldCourseName,
moduleName
),
await getLectures(oldCourseName),
]);
await Promise.all([ await Promise.all([
fileStorageService.assignments.getAssignments( ...oldAssignments.map(async (oldAssignment) => {
oldCourseName, const newAssignment = prepAssignmentForNewSemester(
moduleName oldAssignment,
),
await fileStorageService.quizzes.getQuizzes(
oldCourseName,
moduleName
),
await fileStorageService.pages.getPages(
oldCourseName,
moduleName
),
await getLectures(oldCourseName),
]);
await Promise.all([
...oldAssignments.map(async (oldAssignment) => {
const newAssignment = prepAssignmentForNewSemester(
oldAssignment,
settingsFromCourseToImport.startDate,
settings.startDate,
);
await fileStorageService.assignments.updateOrCreateAssignment({
courseName: newCourseName,
moduleName,
assignmentName: newAssignment.name,
assignment: newAssignment,
});
}),
...oldQuizzes.map(async (oldQuiz) => {
const newQuiz = prepQuizForNewSemester(
oldQuiz,
settingsFromCourseToImport.startDate,
settings.startDate
);
await fileStorageService.quizzes.updateQuiz({
courseName: newCourseName,
moduleName,
quizName: newQuiz.name,
quiz: newQuiz,
});
}),
...oldPages.map(async (oldPage) => {
const newPage = prepPageForNewSemester(
oldPage,
settingsFromCourseToImport.startDate,
settings.startDate
);
await fileStorageService.pages.updatePage({
courseName: newCourseName,
moduleName,
pageName: newPage.name,
page: newPage,
});
}),
...oldLecturesByWeek.flatMap(async (oldLectureByWeek) =>
oldLectureByWeek.lectures.map(async (oldLecture) => {
const newLecture = prepLectureForNewSemester(
oldLecture,
settingsFromCourseToImport.startDate, settingsFromCourseToImport.startDate,
settings.startDate settings.startDate
); );
await updateLecture(newCourseName, settings, newLecture); await fileStorageService.assignments.updateOrCreateAssignment(
}) {
), courseName: newCourseName,
]); moduleName,
}) assignmentName: newAssignment.name,
); assignment: newAssignment,
}
);
}),
...oldQuizzes.map(async (oldQuiz) => {
const newQuiz = prepQuizForNewSemester(
oldQuiz,
settingsFromCourseToImport.startDate,
settings.startDate
);
await fileStorageService.quizzes.updateQuiz({
courseName: newCourseName,
moduleName,
quizName: newQuiz.name,
quiz: newQuiz,
});
}),
...oldPages.map(async (oldPage) => {
const newPage = prepPageForNewSemester(
oldPage,
settingsFromCourseToImport.startDate,
settings.startDate
);
await fileStorageService.pages.updatePage({
courseName: newCourseName,
moduleName,
pageName: newPage.name,
page: newPage,
});
}),
...oldLecturesByWeek.flatMap(async (oldLectureByWeek) =>
oldLectureByWeek.lectures.map(async (oldLecture) => {
const newLecture = prepLectureForNewSemester(
oldLecture,
settingsFromCourseToImport.startDate,
settings.startDate
);
await updateLecture(newCourseName, settings, newLecture);
})
),
]);
})
);
}
} }
}), ),
updateSettings: publicProcedure updateSettings: publicProcedure
.input( .input(
z.object({ z.object({