diff --git a/backend/assignment-service/src/controllers/assignment-controller.ts b/backend/assignment-service/src/controllers/assignment-controller.ts index 24d3fafb..5cb02a4e 100644 --- a/backend/assignment-service/src/controllers/assignment-controller.ts +++ b/backend/assignment-service/src/controllers/assignment-controller.ts @@ -24,12 +24,12 @@ const getAssignmentsByUserId = async (request: Request, response: Response) => { const { userId, includePast, isPublished } = GetAssignmentsQueryValidator.parse(request.query); - - const assignments = await GetHandler.getAssignmentsByUserId( - userId, - includePast, - isPublished - ); + + const assignments = await GetHandler.getAssignmentsByUserId( + userId, + includePast, + isPublished + ); if (!assignments) { response.status(HttpStatusCode.NOT_FOUND).json({ diff --git a/backend/assignment-service/src/services/assignments/get-handler.ts b/backend/assignment-service/src/services/assignments/get-handler.ts index bdb21513..00dae374 100644 --- a/backend/assignment-service/src/services/assignments/get-handler.ts +++ b/backend/assignment-service/src/services/assignments/get-handler.ts @@ -29,6 +29,8 @@ const getAssignmentsByUserId = async ( }, }); + // TODO: search user under courses, what assignments are there + const assignmentsDto: Assignment[] = assignments.map((assignment) => { return { id: assignment.id, diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index 3ce828fb..84f4e806 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -17,7 +17,7 @@ "warn", { "selector": "default", - "format": ["camelCase"], + "format": ["camelCase", "UPPER_CASE", "PascalCase"], "leadingUnderscore": "allow" }, { @@ -62,7 +62,7 @@ "react/no-typos": "warn", "react/display-name": "warn", "react/self-closing-comp": "warn", - "react/jsx-max-depth": ["warn", { "max": 5 }] + "react/jsx-max-depth": ["warn", { "max": 7 }] }, "overrides": [ { diff --git a/frontend/config.ts b/frontend/config.ts new file mode 100644 index 00000000..e69de29b diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 4678774e..9f0d3ce1 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -1,4 +1,15 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {}; +const nextConfig = { + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "utfs.io", + port: "", + pathname: "/f/**", + }, + ], + }, +}; export default nextConfig; diff --git a/frontend/package.json b/frontend/package.json index 2b079b49..09d419b9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "@nextui-org/react": "^2.2.10", "@radix-ui/react-toast": "^1.1.5", "@tanstack/react-query": "^5.24.1", + "@uploadthing/react": "^6.4.2", "add": "^2.0.6", "axios": "^1.6.7", "class-variance-authority": "^0.7.0", @@ -41,7 +42,8 @@ "tailwind": "^4.0.0", "tailwind-merge": "^2.2.2", "tailwindcss-animate": "^1.0.7", - "yarn": "^1.22.22" + "yarn": "^1.22.22", + "uploadthing": "^6.8.0" }, "devDependencies": { "@testing-library/jest-dom": "^6.4.2", diff --git a/frontend/src/app/api/uploadthing/core.ts b/frontend/src/app/api/uploadthing/core.ts new file mode 100644 index 00000000..36a3715a --- /dev/null +++ b/frontend/src/app/api/uploadthing/core.ts @@ -0,0 +1,35 @@ +import { cookies } from "next/headers"; +import { createUploadthing, type FileRouter } from "uploadthing/next"; +import { UploadThingError } from "uploadthing/server"; + +const f = createUploadthing(); + +const auth = (_req: Request) => { + const userCookie = cookies().get("token"); + return userCookie; +}; + +// FileRouter for your app, can contain multiple FileRoutes +export const ourFileRouter = { + // Define as many FileRoutes as you like, each with a unique routeSlug + imageUploader: f({ image: { maxFileSize: "4MB" } }) + // Set permissions and file types for this FileRoute + .middleware(({ req }) => { + // Delete the comment below when we have actual auth + // eslint-disable-next-line @typescript-eslint/await-thenable + const user = auth(req); + if (!user) throw new UploadThingError("Unauthorized"); + return { userCookie: user }; + }) + .onUploadComplete(({ metadata, file }) => { + // This code RUNS ON YOUR SERVER after upload + console.log("Upload complete for userId:", metadata.userCookie); + + console.log("file url", file.url); + + // !!! Whatever is returned here is sent to the clientside `onClientUploadComplete` callback + return { uploadedBy: metadata.userCookie, url: file.url }; + }), +} satisfies FileRouter; + +export type OurFileRouter = typeof ourFileRouter; diff --git a/frontend/src/app/api/uploadthing/route.ts b/frontend/src/app/api/uploadthing/route.ts new file mode 100644 index 00000000..6d43d981 --- /dev/null +++ b/frontend/src/app/api/uploadthing/route.ts @@ -0,0 +1,9 @@ +import { createRouteHandler } from "uploadthing/next"; + +import { ourFileRouter } from "./core"; + +// Export routes for Next App Router, POST is keyword +// eslint-disable-next-line @typescript-eslint/naming-convention +export const { POST } = createRouteHandler({ + router: ourFileRouter, +}); diff --git a/frontend/src/app/assignments/[id]/edit/page.tsx b/frontend/src/app/assignments/[id]/edit/page.tsx index 9c33c5c1..4d5ab41b 100644 --- a/frontend/src/app/assignments/[id]/edit/page.tsx +++ b/frontend/src/app/assignments/[id]/edit/page.tsx @@ -115,6 +115,8 @@ function Page({ params }: Props) { return () => { disableEditing(); }; + // Run once on page load + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const handleAddQuestion = () => { diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 7806acff..c4aa493d 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -35,7 +35,10 @@ export default function DashBoard() { {isLoading ? ( ) : ( - + )} ); diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx index f78216fd..1f549f19 100644 --- a/frontend/src/app/login/page.tsx +++ b/frontend/src/app/login/page.tsx @@ -106,3 +106,4 @@ export default function Home() { ); } +/* eslint-enable @typescript-eslint/no-misused-promises */ diff --git a/frontend/src/app/user/page.tsx b/frontend/src/app/user/page.tsx index 7b34e6e3..32cebe88 100644 --- a/frontend/src/app/user/page.tsx +++ b/frontend/src/app/user/page.tsx @@ -58,6 +58,8 @@ export default function Page() { setIsLoading(true); router.push("/"); } + // only fetch once, we do not need to update + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( diff --git a/frontend/src/components/assignment/create/AssignmentEditor.tsx b/frontend/src/components/assignment/create/AssignmentEditor.tsx index fbf992a0..25e3b292 100644 --- a/frontend/src/components/assignment/create/AssignmentEditor.tsx +++ b/frontend/src/components/assignment/create/AssignmentEditor.tsx @@ -49,6 +49,8 @@ export default function AssignmentEditor({ isEditing = false }: Props) { setDescription(assignment.description ?? ""); setIsPublished(assignment.isPublished); } + // Run once on page load + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const { toast } = useToast(); const { user } = useUserContext(); @@ -69,6 +71,8 @@ export default function AssignmentEditor({ isEditing = false }: Props) { break; } }, + // Run once on page load + // eslint-disable-next-line react-hooks/exhaustive-deps [title, deadline, description] ); diff --git a/frontend/src/components/assignment/create/DescriptionField.tsx b/frontend/src/components/assignment/create/DescriptionField.tsx index 6bdc972e..b31906bb 100644 --- a/frontend/src/components/assignment/create/DescriptionField.tsx +++ b/frontend/src/components/assignment/create/DescriptionField.tsx @@ -57,6 +57,7 @@ function DescriptionField({ placeholder, className, }: Props) { + // eslint-disable-next-line @typescript-eslint/naming-convention const ReactQuill = useMemo( () => dynamic(() => import("react-quill"), { ssr: false }), [] diff --git a/frontend/src/components/assignment/create/QuestionEditor.tsx b/frontend/src/components/assignment/create/QuestionEditor.tsx index 1b61cb97..97265163 100644 --- a/frontend/src/components/assignment/create/QuestionEditor.tsx +++ b/frontend/src/components/assignment/create/QuestionEditor.tsx @@ -50,6 +50,8 @@ function QuestionEditor({ setLanguage(initialQuestion.referenceSolution?.language ?? "python"); setReferenceSolutionCode(initialQuestion.referenceSolution?.code ?? ""); setTestCases(initialQuestion.testCases ?? []); + // runs once when loaded, does not change + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { @@ -71,6 +73,8 @@ function QuestionEditor({ ) { onQuestionChange(newQuestion); } + // onQuestionChange and initialQuestion do not change + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ title, description, diff --git a/frontend/src/components/common/ReadOnlyUserCard.tsx b/frontend/src/components/common/ReadOnlyUserCard.tsx index f7aec882..ccd81d02 100644 --- a/frontend/src/components/common/ReadOnlyUserCard.tsx +++ b/frontend/src/components/common/ReadOnlyUserCard.tsx @@ -11,7 +11,7 @@ export function ReadOnlyFullUserCard({ userInfo }: { userInfo: UserInfo }) {
@@ -28,7 +28,7 @@ export function ReadOnlyUserCard({ userInfo }: { userInfo: UserInfo }) {
diff --git a/frontend/src/components/common/SideBar.tsx b/frontend/src/components/common/SideBar.tsx index dde60237..a5c9f8c3 100644 --- a/frontend/src/components/common/SideBar.tsx +++ b/frontend/src/components/common/SideBar.tsx @@ -82,6 +82,8 @@ export default function SideBar() { return; }); } + // router does not change + // eslint-disable-next-line react-hooks/exhaustive-deps }, [user]); // obtain current path, if is login/sign up, don't render SideBar diff --git a/frontend/src/components/forms/ProfileEditor.tsx b/frontend/src/components/forms/ProfileEditor.tsx index 8d1d1ecc..3cec1f83 100644 --- a/frontend/src/components/forms/ProfileEditor.tsx +++ b/frontend/src/components/forms/ProfileEditor.tsx @@ -15,6 +15,7 @@ import { import FileInput from "./FileInput"; import userService from "@/helpers/user-service/api-wrapper"; import { useUserContext } from "@/contexts/user-context"; +import { uploadFiles } from "@/utils/uploadthing"; export default function ProfileEditor({ userInfo }: { userInfo: UserInfo }) { const { user, setUserContext } = useUserContext(); @@ -24,14 +25,16 @@ export default function ProfileEditor({ userInfo }: { userInfo: UserInfo }) { return name == ""; }, [name]); const [bio, setBio] = useState(info.bio); - const [photo, setPhoto] = useState(info.photo); + const [photo, setPhoto] = useState(info.avatarUrl); const [newPhoto, setNewPhoto] = useState(); - // userInfo is constant, do not change for now const hasChanged = useMemo(() => { if (name != info.name) return true; if (bio != info.bio) return true; - if (photo != info.photo && !(photo == "" && info.photo == undefined)) + if ( + photo != info.avatarUrl && + !(photo == "" && info.avatarUrl == undefined) + ) return true; return false; }, [name, bio, photo, info]); @@ -41,14 +44,14 @@ export default function ProfileEditor({ userInfo }: { userInfo: UserInfo }) { setPhoto(URL.createObjectURL(newPhoto)); } else { setNewPhoto(undefined); - setPhoto(info.photo); + setPhoto(info.avatarUrl); } - }, [newPhoto, info.photo]); + }, [newPhoto, info.avatarUrl]); const handleDiscard = () => { setName(info.name); setBio(info.bio); - setPhoto(info.photo); + setPhoto(info.avatarUrl); setNewPhoto(undefined); }; @@ -59,22 +62,33 @@ export default function ProfileEditor({ userInfo }: { userInfo: UserInfo }) { return; } - if (name == info.name && bio == info.bio && photo == info.photo) { + if (name == info.name && bio == info.bio && photo == info.avatarUrl) { setMessage("Profile saved!"); return; } + const dataUpdated: Record = { + name: name, + bio: bio, + }; + try { - await userService.updateUserInfo(user?.uid ?? 0, { - name: name, - bio: bio, - }); + let photoUrl = photo; + if (newPhoto) { + const fileResponse = await uploadFiles("imageUploader", { + files: [newPhoto], + }); + photoUrl = fileResponse[0].url; + dataUpdated['"avatarUrl"'] = photoUrl; + setPhoto(photoUrl); + } + await userService.updateUserInfo(user?.uid ?? 0, dataUpdated); setMessage("Profile saved!"); setInfo({ name: name, email: info.email, bio: bio, - photo: photo!, + avatarUrl: photoUrl, }); setUserContext({ diff --git a/frontend/src/contexts/user-context.tsx b/frontend/src/contexts/user-context.tsx index 3257a831..b79de54b 100644 --- a/frontend/src/contexts/user-context.tsx +++ b/frontend/src/contexts/user-context.tsx @@ -14,6 +14,8 @@ interface UserContextType { } const initialUser: User | null = null; +// React component generated by createContext +// eslint-disable-next-line @typescript-eslint/naming-convention const UserContext = createContext({ user: null, setUserContext: () => { diff --git a/frontend/src/helpers/user-service/api-wrapper.ts b/frontend/src/helpers/user-service/api-wrapper.ts index 8403fbf6..4a4bb84b 100644 --- a/frontend/src/helpers/user-service/api-wrapper.ts +++ b/frontend/src/helpers/user-service/api-wrapper.ts @@ -72,7 +72,7 @@ const getUserInfo = async (uid: number): Promise => { name: responseData.name, email: responseData.email, bio: responseData.bio || "This person doesn't have bio", - photo: responseData.photo, + avatarUrl: responseData.avatarUrl, }; return userInfo; } else { diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index 1b5df4cd..54fa8da3 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -4,8 +4,14 @@ export const config = { matchers: "/:path*", }; -export default function middleware(request: NextRequest) { - const publicRoutes = ["/_next", "/public", "/login", "/sign-up"]; +const middleware = (request: NextRequest) => { + const publicRoutes = [ + "/_next", + "/public", + "/login", + "/sign-up", + "/api/uploadthing", + ]; const redirectRoutes = ["/"]; const path = request.nextUrl.pathname; @@ -29,4 +35,6 @@ export default function middleware(request: NextRequest) { } return NextResponse.next(); -} +}; + +export default middleware; diff --git a/frontend/src/types/user-service.d.ts b/frontend/src/types/user-service.d.ts index 96362a3a..7f7f90c2 100644 --- a/frontend/src/types/user-service.d.ts +++ b/frontend/src/types/user-service.d.ts @@ -7,7 +7,7 @@ interface UserInfo { name: string; email: string; bio: string; - photo?: string; + avatarUrl?: string; } interface ErrorResponse { diff --git a/frontend/src/utils/classMergeUtils.ts b/frontend/src/utils/classMergeUtils.ts index 365058ce..ccd813e3 100644 --- a/frontend/src/utils/classMergeUtils.ts +++ b/frontend/src/utils/classMergeUtils.ts @@ -1,6 +1,6 @@ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; -export function cn(...inputs: ClassValue[]) { +export const cn = (...inputs: ClassValue[]) => { return twMerge(clsx(inputs)); -} +}; diff --git a/frontend/src/utils/uploadthing.ts b/frontend/src/utils/uploadthing.ts new file mode 100644 index 00000000..eb778bb9 --- /dev/null +++ b/frontend/src/utils/uploadthing.ts @@ -0,0 +1,5 @@ +import { OurFileRouter } from "@/app/api/uploadthing/core"; +import { generateReactHelpers } from "@uploadthing/react"; + +export const { useUploadThing, uploadFiles } = + generateReactHelpers(); diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 090d23a6..05939b87 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -3020,6 +3020,35 @@ resolved "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz" integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== +"@uploadthing/dropzone@0.2.1": + version "0.2.1" + resolved "https://registry.yarnpkg.com/@uploadthing/dropzone/-/dropzone-0.2.1.tgz#e373496b3b59b3cf61c8993b5c12db294c48467e" + integrity sha512-OK4rSFnQ2woJ07t78hTfxjMxXedLoj+Jp34kIEtYPYBTPOnNwoKhDXfRojSu8cBilkQROzIe67i0p6F4B6LQhQ== + dependencies: + file-selector "^0.6.0" + +"@uploadthing/mime-types@0.2.7": + version "0.2.7" + resolved "https://registry.yarnpkg.com/@uploadthing/mime-types/-/mime-types-0.2.7.tgz#f4d5d7a0faa5afd4bc16fcac1e61c80e6487b638" + integrity sha512-HfQpL1GSyLOOnIT4WqVv8aW3HotEpFKuuCnK+dUZThMzeiFs09s9J0wk5/VBwupXvIXQwMiB//bp9aUE2p/mFg== + +"@uploadthing/react@^6.4.2": + version "6.4.2" + resolved "https://registry.yarnpkg.com/@uploadthing/react/-/react-6.4.2.tgz#1e8bae2654e42b8dc5d1ec5d52d1d08e44fa4a70" + integrity sha512-Z/LIku44nhLF9aXEBMEsknL+ADpLKC+MzeD+H8v7jVtghcbYHrW8mF2M91z2H223mVLR0Tnj77yemRoBAh7+3Q== + dependencies: + "@uploadthing/dropzone" "0.2.1" + "@uploadthing/shared" "6.5.0" + file-selector "^0.6.0" + tailwind-merge "^2.2.1" + +"@uploadthing/shared@6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@uploadthing/shared/-/shared-6.5.0.tgz#954deb113aaa674890e1b83773b99e53e8d242a6" + integrity sha512-RVzHODev1FRnPSEOih/rTjN89FW9ZXK1uvxSCg+q43cYkUcgJ8mF5LbEmS4dwRqRJkFpspfTPo8IcMe7WwoQ2A== + dependencies: + std-env "^3.7.0" + abab@^2.0.6: version "2.0.6" resolved "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz" @@ -3826,6 +3855,11 @@ concurrently@^8.2.2: tree-kill "^1.2.2" yargs "^17.7.2" +consola@^3.2.3: + version "3.2.3" + resolved "https://registry.yarnpkg.com/consola/-/consola-3.2.3.tgz#0741857aa88cfa0d6fd53f1cff0375136e98502f" + integrity sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ== + content-disposition@0.5.2: version "0.5.2" resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz" @@ -4798,6 +4832,13 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" +file-selector@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.6.0.tgz#fa0a8d9007b829504db4d07dd4de0310b65287dc" + integrity sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw== + dependencies: + tslib "^2.4.0" + fill-range@^7.0.1: version "7.0.1" resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz" @@ -7577,6 +7618,11 @@ statuses@~1.4.0: resolved "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz" integrity sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew== +std-env@^3.7.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.7.0.tgz#c9f7386ced6ecf13360b6c6c55b8aaa4ef7481d2" + integrity sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg== + stethoskop@1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/stethoskop/-/stethoskop-1.0.0.tgz" @@ -7810,7 +7856,7 @@ tailwind-merge@^1.14.0: resolved "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-1.14.0.tgz" integrity sha512-3mFKyCo/MBcgyOTlrY8T7odzZFx+w+qKSMAmdFzRvqBfLlSigU6TZnlFHK0lkMwj9Bj8OYU+9yW9lmGuS0QEnQ== -tailwind-merge@^2.2.2: +tailwind-merge@^2.2.1, tailwind-merge@^2.2.2: version "2.2.2" resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.2.2.tgz#87341e7604f0e20499939e152cd2841f41f7a3df" integrity sha512-tWANXsnmJzgw6mQ07nE3aCDkCK4QdT3ThPMCzawoYA2Pws7vSTCvz3Vrjg61jVUGfFZPJzxEP+NimbcW+EdaDw== @@ -8153,6 +8199,16 @@ update-browserslist-db@^1.0.13: escalade "^3.1.1" picocolors "^1.0.0" +uploadthing@^6.8.0: + version "6.8.0" + resolved "https://registry.yarnpkg.com/uploadthing/-/uploadthing-6.8.0.tgz#1bfd58a2d72c836255d034cb44c020c5f1eda29c" + integrity sha512-89DwCDsLLdF1bhPtCX4TYgl3NCZtyTeixn9hOVUViOIc3R429nJ5sViz2Lr9wz+nN5gW66qaa4qao6E43sN/hA== + dependencies: + "@uploadthing/mime-types" "0.2.7" + "@uploadthing/shared" "6.5.0" + consola "^3.2.3" + std-env "^3.7.0" + uri-js@^4.2.2: version "4.4.1" resolved "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz"