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";
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 Link from "next/link";
@@ -38,4 +42,4 @@ export default function CourseList() {
))}
</div>
);
}
}

View File

@@ -1,8 +1,14 @@
"use client";
import ClientOnly from "@/components/ClientOnly";
import { StoragePathSelector } from "@/components/form/StoragePathSelector";
import TextInput from "@/components/form/TextInput";
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 = () => {
const [showForm, setShowForm] = useState(false);
@@ -27,14 +33,82 @@ export const AddExistingCourseToGlobalSettings = () => {
const ExistingCourseForm: FC<{}> = () => {
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 (
<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>
<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
startingValue={path}
submitValue={setPath}
value={path}
setValue={setPath}
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 {
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 pastWeekNumber = getWeekNumber(
startDate,
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)
);
const shouldCollapse = (pastWeekNumber >= startOfMonthWeekNumber) && !isPastSemester;
const shouldCollapse =
pastWeekNumber >= startOfMonthWeekNumber && !isPastSemester;
const monthName = new Date(month.year, month.month - 1, 1).toLocaleString(
"default",

View File

@@ -1,4 +1,4 @@
"use client"
"use client";
import { ReactNode } from "react";
import {
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() {
const { courseName } = useCourseContext();
const {data: weeks} = useLecturesQuery();
const { data: weeks } = useLecturesQuery();
const dayAsDate = new Date();
const dayAsString = getDateOnlyMarkdownString(dayAsDate);

View File

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

View File

@@ -6,12 +6,14 @@ export default function TextInput({
label,
className,
isTextArea = false,
inputRef = undefined,
}: {
value: string;
setValue: (newValue: string) => void;
label: string;
className?: string;
isTextArea?: boolean;
inputRef?: React.RefObject<HTMLInputElement | null>;
}) {
return (
<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"
value={value}
onChange={(e) => setValue(e.target.value)}
ref={inputRef}
/>
)}
{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 })
);
};
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 });
},
async createModuleFolderForTesting(courseName: string, moduleName: string) {
const courseDirectory = path.join(basePath, courseName, moduleName);
@@ -57,6 +58,12 @@ export const fileStorageService = {
relativePath: string
): Promise<{ files: string[]; folders: string[] }> {
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))) {
throw new Error(`Directory ${fullPath} does not exist`);
}

View File

@@ -1,5 +1,8 @@
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 path from "path";
import { basePath } from "./utils/fileSystemUtils";
@@ -34,3 +37,8 @@ export const getCoursePathByName = async (courseName: string) => {
}
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}`);
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 { canvasFileRouter } from "./canvasFileRouter";
import { directoriesRouter } from "./directoriesRouter";
import { globalSettingsRouter } from "./globalSettingsRouter";
import { lectureRouter } from "./lectureRouter";
import { moduleRouter } from "./moduleRouter";
import { pageRouter } from "./pageRouter";
@@ -18,6 +19,7 @@ export const trpcAppRouter = router({
module: moduleRouter,
directories: directoriesRouter,
canvasFile: canvasFileRouter,
globalSettings: globalSettingsRouter,
});
export const createCaller = createCallerFactory(trpcAppRouter);

View File

@@ -16,4 +16,13 @@ export const directoriesRouter = router({
.query(async ({ input: { 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);
}),
});