diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 9dd7e60..e7329cc 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -17,16 +17,17 @@ services: volumes: - ./globalSettings.yml:/app/globalSettings.yml - .:/app - - ~/projects/faculty/1810/2025-spring-alex/in-person:/app/storage/intro_to_web_old - - ~/projects/faculty/1810/2025-fall-alex/modules:/app/storage/intro_to_web - - ~/projects/faculty/4850_AdvancedFE/2025-fall-alex/modules:/app/storage/advanced_frontend - - ~/projects/faculty/4850_AdvancedFE/2024-fall-alex/modules:/app/storage/advanced_frontend_old - - ~/projects/faculty/1430/2024-fall-alex/modules:/app/storage/ux_old - - ~/projects/faculty/1430/2025-fall-alex/modules:/app/storage/ux - - ~/projects/faculty/1420/2024-fall/Modules:/app/storage/1420_old - - ~/projects/faculty/1420/2025-fall-alex/modules:/app/storage/1420 - - ~/projects/faculty/1425/2024-fall/Modules:/app/storage/1425_old - - ~/projects/faculty/1425/2025-fall-alex/modules:/app/storage/1425 + - ~/projects/faculty:/app/storage + # - ~/projects/faculty/1810/2025-spring-alex/in-person:/app/storage/intro_to_web_old + # - ~/projects/faculty/1810/2025-fall-alex/modules:/app/storage/intro_to_web + # - ~/projects/faculty/4850_AdvancedFE/2025-fall-alex/modules:/app/storage/advanced_frontend + # - ~/projects/faculty/4850_AdvancedFE/2024-fall-alex/modules:/app/storage/advanced_frontend_old + # - ~/projects/faculty/1430/2024-fall-alex/modules:/app/storage/ux_old + # - ~/projects/faculty/1430/2025-fall-alex/modules:/app/storage/ux + # - ~/projects/faculty/1420/2024-fall/Modules:/app/storage/1420_old + # - ~/projects/faculty/1420/2025-fall-alex/modules:/app/storage/1420 + # - ~/projects/faculty/1425/2024-fall/Modules:/app/storage/1425_old + # - ~/projects/faculty/1425/2025-fall-alex/modules:/app/storage/1425 - ~/projects/facultyFiles:/app/public/images/facultyFiles redis: diff --git a/globalSettings.yml b/globalSettings.yml index dd2a690..2344fb2 100644 --- a/globalSettings.yml +++ b/globalSettings.yml @@ -1,3 +1 @@ -courses: - - path: "./intro_to_web_old" - name: "Intro to Web (Old)" +courses: [] diff --git a/src/app/newCourse/NewCourseForm.tsx b/src/app/addCourse/AddCourseToGlobalSettingsForm.tsx similarity index 96% rename from src/app/newCourse/NewCourseForm.tsx rename to src/app/addCourse/AddCourseToGlobalSettingsForm.tsx index d776ab7..154bde7 100644 --- a/src/app/newCourse/NewCourseForm.tsx +++ b/src/app/addCourse/AddCourseToGlobalSettingsForm.tsx @@ -1,4 +1,5 @@ "use client"; +import ButtonSelect from "@/components/ButtonSelect"; import { DayOfWeekInput } from "@/components/form/DayOfWeekInput"; import SelectInput from "@/components/form/SelectInput"; import { Spinner } from "@/components/Spinner"; @@ -37,7 +38,7 @@ const sampleCompose = `services: - ~/projects/faculty/4850_AdvancedFE/2024-fall-alex/modules:/app/storage/advanced_frontend `; -export default function NewCourseForm() { +export default function AddNewCourseToGlobalSettingsForm() { const router = useRouter(); const today = useMemo(() => new Date(), []); const { data: canvasTerms } = useCanvasTermsQuery(today); @@ -61,12 +62,13 @@ export default function NewCourseForm() { return (
- t.name} + getOptionName={(t) => t?.name ?? ""} + setValue={setSelectedTerm} + value={selectedTerm} + label={"Canvas Term"} + center={true} /> {selectedTerm && ( @@ -184,12 +186,13 @@ function OtherSettings({ return ( <> - c.name} + getOptionName={(c) => c?.name ?? ""} + center={true} /> { + const [showForm, setShowForm] = useState(false); + return ( +
+
+ +
+ +
+
+ + {showForm && } + +
+
+
+ ); +}; + +const ExistingCourseForm: FC<{}> = () => { + const [path, setPath] = useState("./"); + return ( +
+

Add Existing Course

+ +
+ ); +}; diff --git a/src/app/newCourse/AddNewCourse.tsx b/src/app/addCourse/AddNewCourse.tsx similarity index 65% rename from src/app/newCourse/AddNewCourse.tsx rename to src/app/addCourse/AddNewCourse.tsx index 95d0375..de1c9a6 100644 --- a/src/app/newCourse/AddNewCourse.tsx +++ b/src/app/addCourse/AddNewCourse.tsx @@ -1,16 +1,16 @@ "use client"; import React, { useState } from "react"; import { SuspenseAndErrorHandling } from "@/components/SuspenseAndErrorHandling"; -import NewCourseForm from "./NewCourseForm"; +import AddNewCourseToGlobalSettingsForm from "./AddCourseToGlobalSettingsForm"; import ClientOnly from "@/components/ClientOnly"; -export default function AddNewCourse() { +export default function AddCourseToGlobalSettings() { const [showForm, setShowForm] = useState(false); return (
-
@@ -18,7 +18,9 @@ export default function AddNewCourse() {
- {showForm && } + + {showForm && } +
diff --git a/src/app/course/[courseName]/modules/NewItemForm.tsx b/src/app/course/[courseName]/modules/NewItemForm.tsx index 247c768..6b46946 100644 --- a/src/app/course/[courseName]/modules/NewItemForm.tsx +++ b/src/app/course/[courseName]/modules/NewItemForm.tsx @@ -153,9 +153,9 @@ export default function NewItemForm({
options={["Assignment", "Quiz", "Page"]} - getName={(o) => o?.toString() ?? ""} - setSelectedOption={(t) => setType(t ?? "Assignment")} - selectedOption={type} + getOptionName={(o) => o?.toString() ?? ""} + setValue={(t) => setType(t ?? "Assignment")} + value={type} label="Type" />
@@ -166,9 +166,9 @@ export default function NewItemForm({ {type !== "Page" && ( g?.name ?? ""} - setSelectedOption={setAssignmentGroup} - selectedOption={assignmentGroup} + getOptionName={(g) => g?.name ?? ""} + setValue={setAssignmentGroup} + value={assignmentGroup} label="Assignment Group" /> )} diff --git a/src/app/page.tsx b/src/app/page.tsx index db686e1..e0b1926 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,6 @@ import CourseList from "./CourseList"; -import AddNewCourse from "./newCourse/AddNewCourse"; +import { AddExistingCourseToGlobalSettings } from "./addCourse/AddExistingCourseToGlobalSettings"; +import AddCourseToGlobalSettings from "./addCourse/AddNewCourse"; import TodaysLectures from "./todaysLectures/TodaysLectures"; export default async function Home() { @@ -18,7 +19,36 @@ export default async function Home() {

- + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
); diff --git a/src/components/ButtonSelect.tsx b/src/components/ButtonSelect.tsx index 7180d32..9137cc4 100644 --- a/src/components/ButtonSelect.tsx +++ b/src/components/ButtonSelect.tsx @@ -2,34 +2,41 @@ import React from "react"; export default function ButtonSelect({ options, - getName, - setSelectedOption, - selectedOption, - label + getOptionName, + setValue, + value, + label, + center = false, }: { options: T[]; - getName: (value: T | undefined) => string; - setSelectedOption: (value: T | undefined) => void; - selectedOption: T | undefined; + getOptionName: (value: T | undefined) => string; + setValue: (value: T | undefined) => void; + value: T | undefined; label: string; + center?: boolean; }) { return ( -
+
-
- - {options.map((o) => ( - - ))} + > + {options.map((o) => ( + + ))}
); diff --git a/src/components/form/StoragePathSelector.tsx b/src/components/form/StoragePathSelector.tsx new file mode 100644 index 0000000..5fa6a11 --- /dev/null +++ b/src/components/form/StoragePathSelector.tsx @@ -0,0 +1,165 @@ +import { useDirectoryContentsQuery } from "@/hooks/localCourse/storageDirectoryHooks"; +import { useState, useRef, useEffect } from "react"; +import { createPortal } from "react-dom"; + +export function StoragePathSelector({ + startingValue, + submitValue, + label, + className, +}: { + startingValue: string; + submitValue: (newValue: string) => void; + label: string; + className?: string; +}) { + const [path, setPath] = useState(startingValue); + const [lastCorrectPath, setLastCorrectPath] = useState(startingValue); + const { data: directoryContents } = + useDirectoryContentsQuery(lastCorrectPath); + const [isFocused, setIsFocused] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const [arrowUsed, setArrowUsed] = useState(false); + const inputRef = useRef(null); + const dropdownRef = useRef(null); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isFocused || filteredFolders.length === 0) return; + if (e.key === "ArrowDown") { + setHighlightedIndex((prev) => (prev + 1) % filteredFolders.length); + setArrowUsed(true); + e.preventDefault(); + } else if (e.key === "ArrowUp") { + setHighlightedIndex( + (prev) => (prev - 1 + filteredFolders.length) % filteredFolders.length + ); + setArrowUsed(true); + e.preventDefault(); + } else if (e.key === "Tab") { + if (highlightedIndex >= 0) { + handleSelectFolder(filteredFolders[highlightedIndex], arrowUsed); + e.preventDefault(); + } else { + handleSelectFolder(filteredFolders[1], arrowUsed); + e.preventDefault(); + } + } else if (e.key === "Enter") { + if (highlightedIndex >= 0) { + handleSelectFolder(filteredFolders[highlightedIndex], arrowUsed); + e.preventDefault(); + } else { + setIsFocused(false); + inputRef.current?.blur(); + e.preventDefault(); + } + } else if (e.key === "Escape") { + setIsFocused(false); + inputRef.current?.blur(); + e.preventDefault(); + } + }; + + // Calculate dropdown position style + const dropdownPositionStyle = (() => { + if (inputRef.current) { + const rect = inputRef.current.getBoundingClientRect(); + return { + top: rect.bottom + window.scrollY, + left: rect.left + window.scrollX, + width: rect.width, + }; + } + return {}; + })(); + + // Get last part of the path + const lastPart = path.split("/")[path.split("/").length - 1] || ""; + // Filter options to those whose name matches the last part of the path + const filteredFolders = (directoryContents?.folders ?? []).filter((option) => + option.toLowerCase().includes(lastPart.toLowerCase()) + ); + + // Handle folder selection + const handleSelectFolder = (option: string, shouldFocus: boolean = false) => { + let newPath = path.endsWith("/") + ? path + option + : path.replace(/[^/]*$/, option); + if (!newPath.endsWith("/")) { + newPath += "/"; + } + setPath(newPath); + setLastCorrectPath(newPath); + setArrowUsed(false); + setHighlightedIndex(-1); + if (shouldFocus) { + setTimeout(() => inputRef.current?.focus(), 0); + } + submitValue(newPath); + }; + + // Scroll highlighted option into view when it changes + useEffect(() => { + if (dropdownRef.current && highlightedIndex >= 0) { + const optionElements = + dropdownRef.current.querySelectorAll(".dropdown-option"); + if (optionElements[highlightedIndex]) { + (optionElements[highlightedIndex] as HTMLElement).scrollIntoView({ + block: "nearest", + }); + } + } + }, [highlightedIndex]); + + return ( + + ); +} diff --git a/src/hooks/localCourse/storageDirectoryHooks.ts b/src/hooks/localCourse/storageDirectoryHooks.ts index 8168a3e..2e5a833 100644 --- a/src/hooks/localCourse/storageDirectoryHooks.ts +++ b/src/hooks/localCourse/storageDirectoryHooks.ts @@ -1,5 +1,5 @@ import { useTRPC } from "@/services/serverFunctions/trpcClient"; -import { useSuspenseQuery } from "@tanstack/react-query"; +import { useQuery, useSuspenseQuery } from "@tanstack/react-query"; export const directoryKeys = { emptyFolders: ["empty folders"] as const, @@ -9,3 +9,10 @@ export const useEmptyDirectoriesQuery = () => { const trpc = useTRPC(); return useSuspenseQuery(trpc.directories.getEmptyDirectories.queryOptions()); }; + +export const useDirectoryContentsQuery = (relativePath: string) => { + const trpc = useTRPC(); + return useQuery( + trpc.directories.getDirectoryContents.queryOptions({ relativePath }) + ); +}; diff --git a/src/services/fileStorage/fileStorageService.ts b/src/services/fileStorage/fileStorageService.ts index 12516e7..72f8576 100644 --- a/src/services/fileStorage/fileStorageService.ts +++ b/src/services/fileStorage/fileStorageService.ts @@ -52,4 +52,25 @@ export const fileStorageService = { await fs.mkdir(courseDirectory, { recursive: true }); }, + + async getDirectoryContents( + relativePath: string + ): Promise<{ files: string[]; folders: string[] }> { + const fullPath = path.join(basePath, relativePath); + if (!(await directoryOrFileExists(fullPath))) { + throw new Error(`Directory ${fullPath} does not exist`); + } + + const contents = await fs.readdir(fullPath, { withFileTypes: true }); + const files: string[] = []; + const folders: string[] = []; + for (const dirent of contents) { + if (dirent.isDirectory()) { + folders.push(dirent.name); + } else if (dirent.isFile()) { + files.push(dirent.name); + } + } + return { files, folders }; + }, }; diff --git a/src/services/serverFunctions/router/directoriesRouter.ts b/src/services/serverFunctions/router/directoriesRouter.ts index 0950387..a24e838 100644 --- a/src/services/serverFunctions/router/directoriesRouter.ts +++ b/src/services/serverFunctions/router/directoriesRouter.ts @@ -1,3 +1,4 @@ +import z from "zod"; import publicProcedure from "../procedures/public"; import { router } from "../trpcSetup"; import { fileStorageService } from "@/services/fileStorage/fileStorageService"; @@ -6,4 +7,13 @@ export const directoriesRouter = router({ getEmptyDirectories: publicProcedure.query(async () => { return await fileStorageService.getEmptyDirectories(); }), + getDirectoryContents: publicProcedure + .input( + z.object({ + relativePath: z.string(), + }) + ) + .query(async ({ input: { relativePath } }) => { + return await fileStorageService.getDirectoryContents(relativePath); + }), });