diff --git a/nextjs/next.config.mjs b/nextjs/next.config.mjs index 4cb30e9..63626cf 100644 --- a/nextjs/next.config.mjs +++ b/nextjs/next.config.mjs @@ -1,6 +1,13 @@ /** @type {import('next').NextConfig} */ const nextConfig = { - + async rewrites() { + return [ + { + source: '/api/canvas/:rest*', + destination: 'https://snow.instructure.com/api/v1/:rest*', + }, + ] + }, }; export default nextConfig; diff --git a/nextjs/src/app/course/[courseName]/modules/[moduleName]/assignment/[assignmentName]/EditAssignment.tsx b/nextjs/src/app/course/[courseName]/modules/[moduleName]/assignment/[assignmentName]/EditAssignment.tsx index 05cb016..c9db901 100644 --- a/nextjs/src/app/course/[courseName]/modules/[moduleName]/assignment/[assignmentName]/EditAssignment.tsx +++ b/nextjs/src/app/course/[courseName]/modules/[moduleName]/assignment/[assignmentName]/EditAssignment.tsx @@ -21,7 +21,6 @@ export default function EditAssignment({ const [assignmentText, setAssignmentText] = useState( localAssignmentMarkdown.toMarkdown(assignment) ); - console.log(assignmentText); const [error, setError] = useState(""); useEffect(() => { @@ -69,7 +68,7 @@ export default function EditAssignment({
-
diff --git a/nextjs/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPage.tsx b/nextjs/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPage.tsx index 136b9a1..97e1eed 100644 --- a/nextjs/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPage.tsx +++ b/nextjs/src/app/course/[courseName]/modules/[moduleName]/page/[pageName]/EditPage.tsx @@ -1,10 +1,15 @@ "use client"; import { MonacoEditor } from "@/components/editor/MonacoEditor"; -import { usePageQuery } from "@/hooks/localCourse/pageHooks"; +import { + usePageQuery, + useUpdatePageMutation, +} from "@/hooks/localCourse/pageHooks"; import { localPageMarkdownUtils } from "@/models/local/page/localCoursePage"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import PagePreview from "./PagePreview"; +import { useLocalCourseSettingsQuery } from "@/hooks/localCourse/localCoursesHooks"; +import { useCanvasPagesQuery } from "@/hooks/canvas/canvasPageHooks"; export default function EditPage({ moduleName, @@ -14,11 +19,43 @@ export default function EditPage({ moduleName: string; }) { const { data: page } = usePageQuery(moduleName, pageName); + const updatePage = useUpdatePageMutation(); const [pageText, setPageText] = useState( localPageMarkdownUtils.toMarkdown(page) ); const [error, setError] = useState(""); + const { data: settings } = useLocalCourseSettingsQuery(); + const { data: canvasPages } = useCanvasPagesQuery(settings.canvasId ?? 0); + console.log("canvas pages", canvasPages); + const pageInCanvas = canvasPages?.find((p) => p.title === pageName); + + useEffect(() => { + const delay = 500; + const handler = setTimeout(() => { + const updatedPage = localPageMarkdownUtils.parseMarkdown(pageText); + if ( + localPageMarkdownUtils.toMarkdown(page) !== + localPageMarkdownUtils.toMarkdown(updatedPage) + ) { + console.log("updating assignment"); + try { + updatePage.mutate({ + page: updatedPage, + moduleName, + pageName, + }); + } catch (e: any) { + setError(e.toString()); + } + } + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [moduleName, page, pageName, pageText, updatePage]); + return (
@@ -27,13 +64,22 @@ export default function EditPage({
{error && error}
- +
+
+ +
-
- +
+ {pageInCanvas && ( + + View Page In Canvas + + )} +
); diff --git a/nextjs/src/app/globals.css b/nextjs/src/app/globals.css index d0976a0..2d0c127 100644 --- a/nextjs/src/app/globals.css +++ b/nextjs/src/app/globals.css @@ -1,4 +1,4 @@ -@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap'); +@import url("https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap"); @tailwind base; @tailwind components; @@ -12,36 +12,37 @@ :root { font-family: "DM Sans", sans-serif; + @apply text-lg; } /* monaco editor */ .monaco-editor-background, .monaco-editor .margin { - background-color: black !important; + background-color: #18181b !important; } h1 { - @apply text-4xl font-bold; + @apply text-4xl font-bold my-1; } h2 { - @apply text-3xl font-semibold; + @apply text-3xl font-semibold my-1; } h3 { - @apply text-2xl font-semibold; + @apply text-2xl font-semibold my-1; } h4 { - @apply text-xl font-medium; + @apply text-xl font-medium my-1; } h5 { - @apply text-lg font-medium; + @apply text-lg font-medium my-1; } h6 { - @apply text-base font-medium; + @apply text-base font-medium my-1; } strong { @@ -62,5 +63,16 @@ hr { } blockquote { - @apply border-l-4 border-gray-300 pl-4 italic text-gray-700; + @apply border-l-4 border-gray-300 pl-4 italic text-gray-400 m-5 pe-3; +} + +code { + @apply font-mono text-sm bg-gray-800 px-1; +} +p { + @apply mb-3; +} + +button { + @apply bg-blue-900 hover:bg-blue-700 text-blue-50 font-bold py-1 px-3 rounded transition-all duration-200; } diff --git a/nextjs/src/app/layout.tsx b/nextjs/src/app/layout.tsx index fa58ffd..47210a5 100644 --- a/nextjs/src/app/layout.tsx +++ b/nextjs/src/app/layout.tsx @@ -26,16 +26,6 @@ export default async function RootLayout({ - {/* */} diff --git a/nextjs/src/app/providers.tsx b/nextjs/src/app/providers.tsx index 01f17c9..98d46dd 100644 --- a/nextjs/src/app/providers.tsx +++ b/nextjs/src/app/providers.tsx @@ -17,7 +17,7 @@ export default function Providers({ children }: { children: ReactNode }) { return ( - + {/* */} {children} ); diff --git a/nextjs/src/hooks/canvasCourseKeys.ts b/nextjs/src/hooks/canvas/canvasCourseHooks.ts similarity index 79% rename from nextjs/src/hooks/canvasCourseKeys.ts rename to nextjs/src/hooks/canvas/canvasCourseHooks.ts index a9f3e12..bc2e781 100644 --- a/nextjs/src/hooks/canvasCourseKeys.ts +++ b/nextjs/src/hooks/canvas/canvasCourseHooks.ts @@ -2,7 +2,8 @@ import { canvasService } from "@/services/canvas/canvasService"; import { useSuspenseQuery } from "@tanstack/react-query"; export const canvasCourseKeys = { - courseDetails: (canavasId: number) => ["canvas course", canavasId] as const, + courseDetails: (canavasId: number) => + ["canvas", canavasId, "course details"] as const, }; export const useCanvasCourseQuery = (canvasId: number) => diff --git a/nextjs/src/hooks/canvas/canvasPageHooks.ts b/nextjs/src/hooks/canvas/canvasPageHooks.ts new file mode 100644 index 0000000..ecab28f --- /dev/null +++ b/nextjs/src/hooks/canvas/canvasPageHooks.ts @@ -0,0 +1,16 @@ +import { canvasPageService } from "@/services/canvas/canvasPageService"; +import { useQuery } from "@tanstack/react-query"; + +export const canvasPageKeys = { + pagesInCourse: (canvasCourseId: number) => [ + "canvas", + canvasCourseId, + "pages", + ], +}; + +export const useCanvasPagesQuery = (canvasCourseId: number) => + useQuery({ + queryKey: canvasPageKeys.pagesInCourse(canvasCourseId), + queryFn: async () => await canvasPageService.getAll(canvasCourseId), + }); diff --git a/nextjs/src/services/axiosUtils.ts b/nextjs/src/services/axiosUtils.ts index 7e364d1..21a3d3a 100644 --- a/nextjs/src/services/axiosUtils.ts +++ b/nextjs/src/services/axiosUtils.ts @@ -1,8 +1,26 @@ +import { isServer } from "@tanstack/react-query"; import axios, { AxiosInstance, AxiosError } from "axios"; import toast from "react-hot-toast"; export const axiosClient: AxiosInstance = axios.create(); +if (!isServer) { + console.log("not on the server, setting up interceptor"); + axiosClient.interceptors.request.use((config) => { + if ( + config.url && + config.url.startsWith("https://snow.instructure.com/api/v1/") + ) { + const newUrl = config.url.replace( + "https://snow.instructure.com/api/v1/", + "/api/canvas/" + ); + config.url = newUrl; + } + return config; + }); +} + axiosClient.interceptors.response.use( (response) => response, (error: AxiosError) => { diff --git a/nextjs/src/services/canvas/canvasPageService.ts b/nextjs/src/services/canvas/canvasPageService.ts new file mode 100644 index 0000000..cb38ec0 --- /dev/null +++ b/nextjs/src/services/canvas/canvasPageService.ts @@ -0,0 +1,65 @@ +import { CanvasPage } from "@/models/canvas/pages/canvasPageModel"; +import { LocalCoursePage } from "@/models/local/page/localCoursePage"; +import { canvasServiceUtils } from "./canvasServiceUtils"; +import { webRequestor } from "./webRequestor"; + +const baseCanvasUrl = "https://snow.instructure.com/api/v1"; + +export const canvasPageService = { + async getAll(courseId: number): Promise { + const url = `${baseCanvasUrl}/courses/${courseId}/pages`; + const pages = await canvasServiceUtils.paginatedRequest({ + url, + }); + return pages.flatMap((pageList) => pageList); + }, + + // async create(canvasCourseId: number, localCourse: LocalCoursePage): Promise { + // console.log(`Creating course page: ${localCourse.name}`); + // const url = `courses/${canvasCourseId}/pages`; + // const body = { + // wiki_page: { + // title: localCourse.name, + // body: localCourse.getBodyHtml(), + // }, + // }; + + // const { canvasPage, response } = await webRequestor.post({ + // url, + // body, + // }); + + // if (!canvasPage) { + // throw new Error("Created canvas course page was null"); + // } + + // return canvasPage; + // }, + + // async update(courseId: number, canvasPageId: number, localCoursePage: LocalCoursePage): Promise { + // console.log(`Updating course page: ${localCoursePage.name}`); + // const url = `courses/${courseId}/pages/${canvasPageId}`; + // const body = { + // wiki_page: { + // title: localCoursePage.name, + // body: localCoursePage.getBodyHtml(), + // }, + // }; + + // await webRequestor.put({ + // url, + // body, + // }); + // }, + + // async delete(courseId: number, canvasPageId: number): Promise { + // console.log(`Deleting page from canvas ${canvasPageId}`); + // const url = `courses/${courseId}/pages/${canvasPageId}`; + // const response = await webRequestor.delete({ url }); + + // if (!response.isSuccessful) { + // console.error(url); + // throw new Error("Failed to delete canvas course page"); + // } + // }, +}; diff --git a/nextjs/src/services/canvas/canvasService.ts b/nextjs/src/services/canvas/canvasService.ts index c972664..4c53447 100644 --- a/nextjs/src/services/canvas/canvasService.ts +++ b/nextjs/src/services/canvas/canvasService.ts @@ -29,13 +29,6 @@ export const canvasService = { async getCourse(courseId: number): Promise { const url = `courses/${courseId}`; const { data, response } = await webRequestor.get(url); - - if (!data) { - console.error((await response?.text()) ?? ""); - console.error(response?.url ?? ""); - throw new Error("Error getting course from Canvas"); - } - return data; }, diff --git a/nextjs/src/services/canvas/canvasServiceUtils.ts b/nextjs/src/services/canvas/canvasServiceUtils.ts index f0444d1..c54adf6 100644 --- a/nextjs/src/services/canvas/canvasServiceUtils.ts +++ b/nextjs/src/services/canvas/canvasServiceUtils.ts @@ -1,11 +1,16 @@ // services/canvasServiceUtils.ts +import { AxiosResponseHeaders, RawAxiosResponseHeaders } from "axios"; import { webRequestor } from "./webRequestor"; -const BASE_URL = "https://snow.instructure.com/api/v1/"; +const getNextUrl = ( + headers: AxiosResponseHeaders | RawAxiosResponseHeaders +): string | undefined => { + const linkHeader: string | undefined = + typeof headers.get === "function" + ? (headers.get("link") as string) + : ((headers as RawAxiosResponseHeaders)["link"] as string); -const getNextUrl = (headers: Headers): string | undefined => { - const linkHeader = headers.get("Link"); if (!linkHeader) return undefined; const links = linkHeader.split(",").map((link) => link.trim()); @@ -14,33 +19,24 @@ const getNextUrl = (headers: Headers): string | undefined => { if (!nextLink) return undefined; const nextUrl = nextLink.split(";")[0].trim().slice(1, -1); - return nextUrl.replace(BASE_URL, "").trim(); + return nextUrl; }; export const canvasServiceUtils = { async paginatedRequest(request: { url: string }): Promise { var requestCount = 1; - const url = new URL(request.url!, BASE_URL); + const url = new URL(request.url); url.searchParams.set("per_page", "100"); const { data: firstData, response: firstResponse } = await webRequestor.get(url.toString()); - if (!firstResponse.ok) { - console.error( - "error in response", - firstResponse.statusText, - firstResponse.body - ); - throw new Error("error in response"); - } - var returnData: T[] = firstData ? [firstData] : []; var nextUrl = getNextUrl(firstResponse.headers); while (nextUrl) { requestCount += 1; - const {data, response} = await webRequestor.get(nextUrl); + const { data, response } = await webRequestor.get(nextUrl); if (data) { returnData = [...returnData, data]; } diff --git a/nextjs/src/services/canvas/webRequestor.ts b/nextjs/src/services/canvas/webRequestor.ts index 0df88aa..b20559a 100644 --- a/nextjs/src/services/canvas/webRequestor.ts +++ b/nextjs/src/services/canvas/webRequestor.ts @@ -1,11 +1,12 @@ +import { axiosClient } from "../axiosUtils"; + type FetchOptions = Omit; -const token = process.env.CANVAS_TOKEN; +const token = process.env.NEXT_PUBLIC_CANVAS_TOKEN; if (!token) { throw new Error("CANVAS_TOKEN not in environment"); } -const baseUrl = `${process.env.CANVAS_URL}/api/v1/`; const rateLimitRetryCount = 6; const rateLimitSleepInterval = 1000; @@ -112,27 +113,21 @@ const recursiveDelete = async ( }; export const webRequestor = { getMany: async (url: string, options: FetchOptions = {}) => { - const response = await fetch(url, { - ...options, - method: "GET", + const response = await axiosClient.get(url, { headers: { - ...options.headers, Authorization: `Bearer ${token}`, }, }); - return { data: await deserialize(response), response }; + return { data: response.data, response }; }, - get: async (url: string, options: FetchOptions = {}) => { - const response = await fetch(url, { - ...options, - method: "GET", + get: async (url: string) => { + const response = await axiosClient.get(url, { headers: { - ...options.headers, Authorization: `Bearer ${token}`, }, }); - return { data: await deserialize(response), response }; + return { data: response.data, response }; }, post: async (url: string, body: any) => {