mirror of
https://github.com/alexmickelson/canvasManagement.git
synced 2026-03-25 23:28:33 -06:00
can add existing courses
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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"}
|
||||||
/>
|
/>
|
||||||
|
{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>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client"
|
"use client";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
CalendarItemsContext,
|
CalendarItemsContext,
|
||||||
|
|||||||
@@ -125,5 +125,5 @@ export default function EditPage({
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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=" ">
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
28
src/hooks/localCourse/globalSettingsHooks.ts
Normal file
28
src/hooks/localCourse/globalSettingsHooks.ts
Normal 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(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
23
src/services/serverFunctions/router/globalSettingsRouter.ts
Normal file
23
src/services/serverFunctions/router/globalSettingsRouter.ts
Normal 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);
|
||||||
|
}),
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user