can add existing courses

This commit is contained in:
2025-07-22 14:23:40 -06:00
parent 67b67100c1
commit 704a5ae404
18 changed files with 209 additions and 30 deletions

View File

@@ -1 +1,9 @@
courses: [] courses:
- path: ./4850_AdvancedFE/2025-fall-alex/modules/
name: Distributed 2025
- path: ./1420/2025-fall-alex/modules/
name: "1420"
- path: ./1810/2025-fall-alex/modules/
name: Web Intro
- path: ./1430/2025-fall-alex/modules/
name: UX

View File

@@ -1,6 +1,10 @@
"use client"; "use client";
import { useLocalCoursesSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; import { useLocalCoursesSettingsQuery } from "@/hooks/localCourse/localCoursesHooks";
import { getDateKey, getTermName, groupByStartDate } from "@/models/local/utils/timeUtils"; import {
getDateKey,
getTermName,
groupByStartDate,
} from "@/models/local/utils/timeUtils";
import { getCourseUrl } from "@/services/urlUtils"; import { getCourseUrl } from "@/services/urlUtils";
import Link from "next/link"; import Link from "next/link";
@@ -38,4 +42,4 @@ export default function CourseList() {
))} ))}
</div> </div>
); );
} }

View File

@@ -1,8 +1,14 @@
"use client"; "use client";
import ClientOnly from "@/components/ClientOnly"; import ClientOnly from "@/components/ClientOnly";
import { StoragePathSelector } from "@/components/form/StoragePathSelector"; import { StoragePathSelector } from "@/components/form/StoragePathSelector";
import TextInput from "@/components/form/TextInput";
import { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling"; import { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling";
import { FC, useState } from "react"; import {
useGlobalSettingsQuery,
useUpdateGlobalSettingsMutation,
} from "@/hooks/localCourse/globalSettingsHooks";
import { useDirectoryIsCourseQuery } from "@/hooks/localCourse/storageDirectoryHooks";
import { FC, useEffect, useRef, useState } from "react";
export const AddExistingCourseToGlobalSettings = () => { export const AddExistingCourseToGlobalSettings = () => {
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
@@ -27,14 +33,82 @@ export const AddExistingCourseToGlobalSettings = () => {
const ExistingCourseForm: FC<{}> = () => { const ExistingCourseForm: FC<{}> = () => {
const [path, setPath] = useState("./"); const [path, setPath] = useState("./");
const [name, setName] = useState("");
const nameInputRef = useRef<HTMLInputElement>(null);
const directoryIsCourseQuery = useDirectoryIsCourseQuery(path);
const { data: globalSettings } = useGlobalSettingsQuery();
const updateSettingsMutation = useUpdateGlobalSettingsMutation();
// Focus name input when directory becomes a valid course
useEffect(() => {
console.log("Checking directory:", directoryIsCourseQuery.data);
if (directoryIsCourseQuery.data) {
console.log("Focusing name input");
nameInputRef.current?.focus();
}
}, [directoryIsCourseQuery.data]);
return ( return (
<div> <form
onSubmit={async (e) => {
e.preventDefault();
console.log(path);
await updateSettingsMutation.mutateAsync({
globalSettings: {
...globalSettings,
courses: [
...globalSettings.courses,
{
name,
path,
},
],
},
});
setName("");
setPath("./");
}}
className="min-w-3xl"
>
<h2>Add Existing Course</h2> <h2>Add Existing Course</h2>
<div className="flex items-center mt-2 text-slate-500">
{directoryIsCourseQuery.isLoading ? (
<>
<span className="animate-spin mr-2"></span>
<span>Checking directory...</span>
</>
) : directoryIsCourseQuery.data ? (
<>
<span className="text-green-600 mr-2"></span>
<span>This is a valid course directory.</span>
</>
) : (
<>
<span className="text-red-600 mr-2"></span>
<span>Not a course directory.</span>
</>
)}
</div>
<StoragePathSelector <StoragePathSelector
startingValue={path} value={path}
submitValue={setPath} setValue={setPath}
label={"Course Directory Path"} label={"Course Directory Path"}
/> />
</div> {directoryIsCourseQuery.data && (
<>
<TextInput
value={name}
setValue={setName}
label={"Display Name"}
inputRef={nameInputRef}
/>
<div className="text-center">
<button className="text-center mt-3">Save</button>
</div>
</>
)}
</form>
); );
}; };

View File

@@ -96,4 +96,4 @@ function getSemesterName(startDate: string) {
} else { } else {
return `Fall ${year}`; return `Fall ${year}`;
} }
} }

View File

@@ -18,7 +18,7 @@ export const CalendarMonth = ({ month }: { month: CalendarMonthModel }) => {
); );
const isPastSemester = Date.now() > new Date(settings.endDate).getTime(); const isPastSemester = Date.now() > new Date(settings.endDate).getTime();
const pastWeekNumber = getWeekNumber( const pastWeekNumber = getWeekNumber(
startDate, startDate,
new Date(Date.now() - four_days_in_milliseconds) new Date(Date.now() - four_days_in_milliseconds)
@@ -29,7 +29,8 @@ export const CalendarMonth = ({ month }: { month: CalendarMonthModel }) => {
new Date(month.year, month.month, 1) new Date(month.year, month.month, 1)
); );
const shouldCollapse = (pastWeekNumber >= startOfMonthWeekNumber) && !isPastSemester; const shouldCollapse =
pastWeekNumber >= startOfMonthWeekNumber && !isPastSemester;
const monthName = new Date(month.year, month.month - 1, 1).toLocaleString( const monthName = new Date(month.year, month.month - 1, 1).toLocaleString(
"default", "default",

View File

@@ -1,4 +1,4 @@
"use client" "use client";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { import {
CalendarItemsContext, CalendarItemsContext,

View File

@@ -125,5 +125,5 @@ export default function EditPage({
</> </>
} }
/> />
) );
} }

View File

@@ -9,7 +9,7 @@ import { getDateOnlyMarkdownString } from "@/models/local/utils/timeUtils";
export default function OneCourseLectures() { export default function OneCourseLectures() {
const { courseName } = useCourseContext(); const { courseName } = useCourseContext();
const {data: weeks} = useLecturesQuery(); const { data: weeks } = useLecturesQuery();
const dayAsDate = new Date(); const dayAsDate = new Date();
const dayAsString = getDateOnlyMarkdownString(dayAsDate); const dayAsString = getDateOnlyMarkdownString(dayAsDate);

View File

@@ -3,26 +3,28 @@ import { useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
export function StoragePathSelector({ export function StoragePathSelector({
startingValue, value,
submitValue, setValue,
label, label,
className, className,
}: { }: {
startingValue: string; value: string;
submitValue: (newValue: string) => void; setValue: (newValue: string) => void;
label: string; label: string;
className?: string; className?: string;
}) { }) {
const [path, setPath] = useState(startingValue); const [path, setPath] = useState(value);
const [lastCorrectPath, setLastCorrectPath] = useState(startingValue); const { data: directoryContents } = useDirectoryContentsQuery(value);
const { data: directoryContents } =
useDirectoryContentsQuery(lastCorrectPath);
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1); const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
const [arrowUsed, setArrowUsed] = useState(false); const [arrowUsed, setArrowUsed] = useState(false);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setPath(value);
}, [value]);
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") {
@@ -88,13 +90,12 @@ export function StoragePathSelector({
newPath += "/"; newPath += "/";
} }
setPath(newPath); setPath(newPath);
setLastCorrectPath(newPath); setValue(newPath);
setArrowUsed(false); setArrowUsed(false);
setHighlightedIndex(-1); setHighlightedIndex(-1);
if (shouldFocus) { if (shouldFocus) {
setTimeout(() => inputRef.current?.focus(), 0); setTimeout(() => inputRef.current?.focus(), 0);
} }
submitValue(newPath);
}; };
// Scroll highlighted option into view when it changes // Scroll highlighted option into view when it changes
@@ -116,12 +117,12 @@ export function StoragePathSelector({
<br /> <br />
<input <input
ref={inputRef} ref={inputRef}
className="bg-slate-800 border border-slate-500 rounded-md w-full px-1" className="bg-slate-800 w-full px-1"
value={path} value={path}
onChange={(e) => { onChange={(e) => {
setPath(e.target.value); setPath(e.target.value);
if (e.target.value.endsWith("/")) { if (e.target.value.endsWith("/")) {
setLastCorrectPath(e.target.value); setValue(e.target.value);
setTimeout(() => inputRef.current?.focus(), 0); setTimeout(() => inputRef.current?.focus(), 0);
} }
}} }}
@@ -130,9 +131,6 @@ export function StoragePathSelector({
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
autoComplete="off" autoComplete="off"
/> />
<div className="text-xs text-slate-400 mt-1">
Last valid path: {lastCorrectPath}
</div>
{isFocused && {isFocused &&
createPortal( createPortal(
<div className=" "> <div className=" ">

View File

@@ -6,12 +6,14 @@ export default function TextInput({
label, label,
className, className,
isTextArea = false, isTextArea = false,
inputRef = undefined,
}: { }: {
value: string; value: string;
setValue: (newValue: string) => void; setValue: (newValue: string) => void;
label: string; label: string;
className?: string; className?: string;
isTextArea?: boolean; isTextArea?: boolean;
inputRef?: React.RefObject<HTMLInputElement | null>;
}) { }) {
return ( return (
<label className={"flex flex-col " + className}> <label className={"flex flex-col " + className}>
@@ -22,6 +24,7 @@ export default function TextInput({
className="bg-slate-800 border border-slate-500 rounded-md w-full px-1" className="bg-slate-800 border border-slate-500 rounded-md w-full px-1"
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
ref={inputRef}
/> />
)} )}
{isTextArea && ( {isTextArea && (

View File

@@ -0,0 +1,28 @@
import { useTRPC } from "@/services/serverFunctions/trpcClient";
import {
useMutation,
useQueryClient,
useSuspenseQuery,
} from "@tanstack/react-query";
export const useGlobalSettingsQuery = () => {
const trpc = useTRPC();
return useSuspenseQuery(trpc.globalSettings.getGlobalSettings.queryOptions());
};
export const useUpdateGlobalSettingsMutation = () => {
const trpc = useTRPC();
const queryClient = useQueryClient();
return useMutation(
trpc.globalSettings.updateGlobalSettings.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: trpc.globalSettings.getGlobalSettings.queryKey(),
});
queryClient.invalidateQueries({
queryKey: trpc.settings.allCoursesSettings.queryKey(),
});
},
})
);
};

View File

@@ -16,3 +16,10 @@ export const useDirectoryContentsQuery = (relativePath: string) => {
trpc.directories.getDirectoryContents.queryOptions({ relativePath }) trpc.directories.getDirectoryContents.queryOptions({ relativePath })
); );
}; };
export const useDirectoryIsCourseQuery = (folderPath: string) => {
const trpc = useTRPC();
return useQuery(
trpc.directories.directoryIsCourse.queryOptions({ folderPath })
);
};

View File

@@ -47,6 +47,7 @@ export const fileStorageService = {
await fs.mkdir(courseDirectory, { recursive: true }); await fs.mkdir(courseDirectory, { recursive: true });
}, },
async createModuleFolderForTesting(courseName: string, moduleName: string) { async createModuleFolderForTesting(courseName: string, moduleName: string) {
const courseDirectory = path.join(basePath, courseName, moduleName); const courseDirectory = path.join(basePath, courseName, moduleName);
@@ -57,6 +58,12 @@ export const fileStorageService = {
relativePath: string relativePath: string
): Promise<{ files: string[]; folders: string[] }> { ): Promise<{ files: string[]; folders: string[] }> {
const fullPath = path.join(basePath, relativePath); const fullPath = path.join(basePath, relativePath);
// Security: ensure fullPath is inside basePath
const resolvedBase = path.resolve(basePath);
const resolvedFull = path.resolve(fullPath);
if (!resolvedFull.startsWith(resolvedBase)) {
return { files: [], folders: [] };
}
if (!(await directoryOrFileExists(fullPath))) { if (!(await directoryOrFileExists(fullPath))) {
throw new Error(`Directory ${fullPath} does not exist`); throw new Error(`Directory ${fullPath} does not exist`);
} }

View File

@@ -1,5 +1,8 @@
import { GlobalSettings } from "@/models/local/globalSettings"; import { GlobalSettings } from "@/models/local/globalSettings";
import { parseGlobalSettingsYaml } from "@/models/local/globalSettingsUtils"; import {
globalSettingsToYaml,
parseGlobalSettingsYaml,
} from "@/models/local/globalSettingsUtils";
import { promises as fs } from "fs"; import { promises as fs } from "fs";
import path from "path"; import path from "path";
import { basePath } from "./utils/fileSystemUtils"; import { basePath } from "./utils/fileSystemUtils";
@@ -34,3 +37,8 @@ export const getCoursePathByName = async (courseName: string) => {
} }
return path.join(basePath, course.path); return path.join(basePath, course.path);
}; };
export const updateGlobalSettings = async (globalSettings: GlobalSettings) => {
const globalSettingsString = globalSettingsToYaml(globalSettings);
await fs.writeFile(SETTINGS_FILE_PATH, globalSettingsString, "utf-8");
};

View File

@@ -86,4 +86,11 @@ export const settingsFileStorageService = {
console.log(`Saving settings ${settingsPath}`); console.log(`Saving settings ${settingsPath}`);
await fs.writeFile(settingsPath, settingsMarkdown); await fs.writeFile(settingsPath, settingsMarkdown);
}, },
async folderIsCourse(folderPath: string) {
const settingsPath = path.join(basePath, folderPath, "settings.yml");
if (!(await directoryOrFileExists(settingsPath))) {
return false;
}
return true;
},
}; };

View File

@@ -3,6 +3,7 @@ import { createCallerFactory, router } from "../trpcSetup";
import { assignmentRouter } from "./assignmentRouter"; import { assignmentRouter } from "./assignmentRouter";
import { canvasFileRouter } from "./canvasFileRouter"; import { canvasFileRouter } from "./canvasFileRouter";
import { directoriesRouter } from "./directoriesRouter"; import { directoriesRouter } from "./directoriesRouter";
import { globalSettingsRouter } from "./globalSettingsRouter";
import { lectureRouter } from "./lectureRouter"; import { lectureRouter } from "./lectureRouter";
import { moduleRouter } from "./moduleRouter"; import { moduleRouter } from "./moduleRouter";
import { pageRouter } from "./pageRouter"; import { pageRouter } from "./pageRouter";
@@ -18,6 +19,7 @@ export const trpcAppRouter = router({
module: moduleRouter, module: moduleRouter,
directories: directoriesRouter, directories: directoriesRouter,
canvasFile: canvasFileRouter, canvasFile: canvasFileRouter,
globalSettings: globalSettingsRouter,
}); });
export const createCaller = createCallerFactory(trpcAppRouter); export const createCaller = createCallerFactory(trpcAppRouter);

View File

@@ -16,4 +16,13 @@ export const directoriesRouter = router({
.query(async ({ input: { relativePath } }) => { .query(async ({ input: { relativePath } }) => {
return await fileStorageService.getDirectoryContents(relativePath); return await fileStorageService.getDirectoryContents(relativePath);
}), }),
directoryIsCourse: publicProcedure
.input(
z.object({
folderPath: z.string(),
})
)
.query(async ({ input: { folderPath } }) => {
return await fileStorageService.settings.folderIsCourse(folderPath);
}),
}); });

View File

@@ -0,0 +1,23 @@
import { zodGlobalSettings } from "@/models/local/globalSettings";
import { router } from "../trpcSetup";
import z from "zod";
import publicProcedure from "../procedures/public";
import {
getGlobalSettings,
updateGlobalSettings,
} from "@/services/fileStorage/globalSettingsFileStorageService";
export const globalSettingsRouter = router({
getGlobalSettings: publicProcedure.query(async () => {
return await getGlobalSettings();
}),
updateGlobalSettings: publicProcedure
.input(
z.object({
globalSettings: zodGlobalSettings,
})
)
.mutation(async ({ input: { globalSettings } }) => {
return await updateGlobalSettings(globalSettings);
}),
});