diff --git a/apollo.config.js b/apollo.config.js new file mode 100644 index 0000000..25e5183 --- /dev/null +++ b/apollo.config.js @@ -0,0 +1,9 @@ +module.exports = { + client: { + service: { + name: 'lab-hub', + url: 'http://localhost:8080/graphql' + }, + includes: ['hub/ui/app/**/*.{ts,tsx}'], + } +}; diff --git a/common/eslint-config-custom/next.js b/common/eslint-config-custom/next.js index 466341b..1a4bc73 100644 --- a/common/eslint-config-custom/next.js +++ b/common/eslint-config-custom/next.js @@ -12,31 +12,83 @@ const project = resolve(process.cwd(), "tsconfig.json"); */ module.exports = { - extends: [ - "@vercel/style-guide/eslint/node", - "@vercel/style-guide/eslint/browser", - "@vercel/style-guide/eslint/typescript", - "@vercel/style-guide/eslint/react", - "@vercel/style-guide/eslint/next", - "eslint-config-turbo", - ].map(require.resolve), - parserOptions: { - project, - }, - globals: { - React: true, - JSX: true, - }, - settings: { - "import/resolver": { - typescript: { + extends: [ + "@vercel/style-guide/eslint/node", + "@vercel/style-guide/eslint/browser", + "@vercel/style-guide/eslint/typescript", + "@vercel/style-guide/eslint/react", + "@vercel/style-guide/eslint/next", + "eslint-config-turbo", + ].map(require.resolve), + parserOptions: { project, - }, }, - }, - ignorePatterns: ["node_modules/", "dist/"], - // add rules configurations here - rules: { - "import/no-default-export": "off", - }, + globals: { + React: true, + JSX: true, + }, + settings: { + "import/resolver": { + typescript: { + project, + }, + }, + }, + ignorePatterns: ["node_modules/", "dist/", "__generated__"], + // add rules configurations here + rules: { + "import/no-default-export": "off", + "@typescript-eslint/no-unused-vars": "warn", + "unicorn/filename-case": "warn", + "import/no-extraneous-dependencies": "off", // since we define dependencies at top level this is not needed + "import/no-default-export": "off", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unsafe-argument": "warn", + "@typescript-eslint/unbound-method": "warn", + "@typescript-eslint/no-unnecessary-condition": "warn", + "@typescript-eslint/no-shadow": "warn", + "@typescript-eslint/no-unsafe-assignment": "warn", + "@typescript-eslint/no-unsafe-member-access": "warn", + "@typescript-eslint/no-floating-promises": "warn", + "@typescript-eslint/naming-convention": "warn", + "@typescript-eslint/require-await": "warn", + "@typescript-eslint/no-misused-promises": "off", + "@typescript-eslint/no-unsafe-call": "warn", + "@typescript-eslint/no-unsafe-return": "warn", + "@typescript-eslint/no-empty-function": "warn", + "camelcase": "warn", + "no-console": "warn", + "tsdoc/syntax": "off", + "react/jsx-key": "off", + "quotes": [ + "error", + "double" + ], + "linebreak-style": [ + "error", + "unix" + ], + "comma-dangle": [ + "error", + { + "arrays": "always-multiline", + "objects": "always-multiline", + "imports": "always-multiline", + "exports": "always-multiline", + "functions": "always-multiline" + } + ], + "object-curly-spacing": [ + "warn", + "always" + ], + "comma-spacing": [ + "warn", + { + "before": false, + "after": true + } + ], + }, }; diff --git a/common/eslint-config-custom/postcss.config.js b/common/eslint-config-custom/postcss.config.js new file mode 100644 index 0000000..33ad091 --- /dev/null +++ b/common/eslint-config-custom/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/common/eslint-config-custom/react-internal.js b/common/eslint-config-custom/react-internal.js index 1858131..80a96f3 100644 --- a/common/eslint-config-custom/react-internal.js +++ b/common/eslint-config-custom/react-internal.js @@ -13,27 +13,79 @@ const project = resolve(process.cwd(), "tsconfig.json"); */ module.exports = { - extends: [ - "@vercel/style-guide/eslint/browser", - "@vercel/style-guide/eslint/typescript", - "@vercel/style-guide/eslint/react", - ].map(require.resolve), - parserOptions: { - project, - }, - globals: { - JSX: true, - }, - settings: { - "import/resolver": { - typescript: { + extends: [ + "@vercel/style-guide/eslint/browser", + "@vercel/style-guide/eslint/typescript", + "@vercel/style-guide/eslint/react", + ].map(require.resolve), + parserOptions: { project, - }, }, - }, - ignorePatterns: ["node_modules/", "dist/", ".eslintrc.js"], + globals: { + JSX: true, + }, + settings: { + "import/resolver": { + typescript: { + project, + }, + }, + }, + ignorePatterns: ["node_modules/", "dist/", ".eslintrc.js", "__generated__"], - rules: { - // add specific rules configurations here - }, + rules: { + "import/no-default-export": "off", + "@typescript-eslint/no-unused-vars": "warn", + "unicorn/filename-case": "warn", + "import/no-extraneous-dependencies": "off", // since we define dependencies at top level this is not needed + "import/no-default-export": "off", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unsafe-argument": "warn", + "@typescript-eslint/unbound-method": "warn", + "@typescript-eslint/no-unnecessary-condition": "warn", + "@typescript-eslint/no-shadow": "warn", + "@typescript-eslint/no-unsafe-assignment": "warn", + "@typescript-eslint/no-unsafe-member-access": "warn", + "@typescript-eslint/no-floating-promises": "warn", + "@typescript-eslint/naming-convention": "warn", + "@typescript-eslint/require-await": "warn", + "@typescript-eslint/no-misused-promises": "off", + "@typescript-eslint/no-empty-function": "warn", + "@typescript-eslint/no-unsafe-return": "warn", + "@typescript-eslint/no-unsafe-call": "warn", + "camelcase": "warn", + "no-console": "warn", + "tsdoc/syntax": "off", + "react/jsx-key": "off", + "quotes": [ + "error", + "double" + ], + "linebreak-style": [ + "error", + "unix" + ], + "comma-dangle": [ + "error", + { + "arrays": "always-multiline", + "objects": "always-multiline", + "imports": "always-multiline", + "exports": "always-multiline", + "functions": "always-multiline" + } + ], + "object-curly-spacing": [ + "warn", + "always" + ], + "comma-spacing": [ + "warn", + { + "before": false, + "after": true + } + ], + }, }; diff --git a/common/eslint-config-custom/tailwind.config.ts b/common/eslint-config-custom/tailwind.config.ts new file mode 100644 index 0000000..654999f --- /dev/null +++ b/common/eslint-config-custom/tailwind.config.ts @@ -0,0 +1,21 @@ +import type { Config } from 'tailwindcss' + +const config: Config = { + content: [ + '../../common/ui/components/**/*.{js,ts,jsx,tsx,mdx}', + '../../common/ui/lib/**/*.{js,ts,jsx,tsx,mdx}', + '../../common/ui/models/**/*.{js,ts,jsx,tsx,mdx}', + './app/**/*.{js,ts,jsx,tsx,mdx}', + ], + theme: { + extend: { + backgroundImage: { + 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', + 'gradient-conic': + 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', + }, + }, + }, + plugins: [], +} +export default config diff --git a/common/protobuf/criterion.proto b/common/protobuf/criterion.proto new file mode 100644 index 0000000..134fa01 --- /dev/null +++ b/common/protobuf/criterion.proto @@ -0,0 +1,33 @@ +syntax = "proto3"; + +package org.sourcegrade.lab.proto; + +import "number_range.proto"; +import "point_range.proto"; +import "test_run.proto"; +import "criterion_accumulator.proto"; +import "showcase_string.proto"; + +// Represents a criterion in a rubric. +message Criterion { + // The id of the criterion. + string id = 1; + // The name of the criterion. + ShowcaseString name = 2; + // The description of the criterion. + ShowcaseString description = 3; + // The possible amount of points that can be achieved. + NumberRange possible_points = 4; + // The points that have been achieved. + PointRange achieved_points = 5; + // The message displayed if the criterion is not fulfilled. + ShowcaseString message = 6; + // Tests used to determine the achieved points. + repeated TestRun tests = 7; + // The accumulator used to determine the achieved points. + CriterionAccumulator test_accumulator = 8; + // The children of the criterion. + repeated Criterion children = 9; + // The accumulator used to determine the achieved points of the children. + CriterionAccumulator children_accumulator = 10; +} diff --git a/common/protobuf/criterion_accumulator.proto b/common/protobuf/criterion_accumulator.proto new file mode 100644 index 0000000..224cf87 --- /dev/null +++ b/common/protobuf/criterion_accumulator.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +package org.sourcegrade.lab.proto; + +// Enumerates the types of accumulators used to determine achieved points of a criterion. +enum CriterionAccumulator { + // Default accumulator: requires all tests to be successful. + AND = 0; + // Requires at least one test to be successful. + OR = 1; + // Requires no test to be successful. + NOT = 2; + // Adds 1 point for each successful test. + SUM = 3; +} diff --git a/common/protobuf/number_range.proto b/common/protobuf/number_range.proto new file mode 100644 index 0000000..c971533 --- /dev/null +++ b/common/protobuf/number_range.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package org.sourcegrade.lab.proto; + +// Defines a range of numbers. The range is inclusive. +message NumberRange { + // Minimum value of the range (inclusive) + int32 min = 1; + // Maximum value of the range (inclusive) + int32 max = 2; +} diff --git a/common/protobuf/point_range.proto b/common/protobuf/point_range.proto new file mode 100644 index 0000000..b3bf163 --- /dev/null +++ b/common/protobuf/point_range.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +package org.sourcegrade.lab.proto; + +import "number_range.proto"; + +// Defines a range of points. The range is inclusive. +message PointRange { + oneof value { + // Single point value + int32 single = 1; + // Range of points + NumberRange range = 2; + } +} diff --git a/common/protobuf/rubric.proto b/common/protobuf/rubric.proto new file mode 100644 index 0000000..2acd415 --- /dev/null +++ b/common/protobuf/rubric.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; + +package org.sourcegrade.lab.proto; + +import "point_range.proto"; +import "criterion.proto"; +import "showcase_string.proto"; +import "number_range.proto"; + +// Represents a rubric containing criteria. +message Rubric { + // The id of the rubric. + string id = 1; + // The name of the rubric. + ShowcaseString name = 2; + // The description of the rubric. + ShowcaseString description = 3; + // The possible amount of points that can be achieved. + NumberRange possible_points = 4; + // The points that have been achieved. + PointRange achieved_points = 5; + // The criteria of the rubric. + repeated Criterion criteria = 6; +} diff --git a/common/protobuf/showcase_string.proto b/common/protobuf/showcase_string.proto new file mode 100644 index 0000000..7831640 --- /dev/null +++ b/common/protobuf/showcase_string.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +package org.sourcegrade.lab.proto; + +// Represents a string that can have both text and HTML representations. +message ShowcaseString { + oneof value { + // Simple text representation + string simple = 1; + // Text representation + string text = 2; + // HTML representation + string html = 3; + } +} diff --git a/common/protobuf/test_run.proto b/common/protobuf/test_run.proto new file mode 100644 index 0000000..832bcc9 --- /dev/null +++ b/common/protobuf/test_run.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; + +package org.sourcegrade.lab.proto; + +import "showcase_string.proto"; +import "test_state.proto"; + +// Represents the result of a test. +message TestRun { + // The id of the test. + string id = 1; + // The name of the test. + string name = 2; + // The state of the test. + TestState state = 3; + // The message of the test. + ShowcaseString message = 4; + // The stacktrace of the test. + string stacktrace = 5; + // The duration of the test in milliseconds. + int64 duration = 6; + // The children of the test. + repeated TestRun children = 7; +} diff --git a/common/protobuf/test_state.proto b/common/protobuf/test_state.proto new file mode 100644 index 0000000..622ad89 --- /dev/null +++ b/common/protobuf/test_state.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package org.sourcegrade.lab.proto; + +// Enumerates the states of a test. +enum TestState { + // The test is pending, i.e., it has not been executed yet. + PENDING = 0; + // The test is running, i.e., it is currently being executed. + RUNNING = 1; + // The test is successful. + SUCCESS = 2; + // The test is not successful, but no exception has been thrown. + FAILURE = 3; + // The test is not successful, and an exception has been thrown. + ERROR = 4; + // The test has been skipped. + SKIPPED = 5; + // The state of the test is unknown. + UNKNOWN = 6; +} diff --git a/common/ui/.eslintrc.js b/common/ui/.eslintrc.js index d750d83..545b410 100644 --- a/common/ui/.eslintrc.js +++ b/common/ui/.eslintrc.js @@ -1,3 +1,3 @@ module.exports = { - extends: ["custom/react-internal"], + extends: ["@repo/eslint-config-custom/react-internal"], }; diff --git a/common/ui/components/assignments-view.tsx b/common/ui/components/assignments-view.tsx new file mode 100644 index 0000000..0f7b7ed --- /dev/null +++ b/common/ui/components/assignments-view.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { MaterialReactTable, type MRT_ColumnDef } from "material-react-table"; +import dayjs from "dayjs"; +import { Box, ListItemIcon, MenuItem } from "@mui/material"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs" +import type { Theme } from "@mui/material/styles"; +import React from "react"; +import { Report, Visibility } from "@mui/icons-material"; + + +export interface AssignmentDto { + id: string; + title: string; + dueDate: string; + maxPoints: number; + actualPoints: number; + status: "not submitted" | "submitted" | "graded" | "plagiarized"; +} + +export function statusColors(status: string, theme: Theme) { + switch (status) { + case "submitted": + return theme.palette.info.main; + case "graded": + return theme.palette.success.main; + case "plagiarized": + return theme.palette.error.dark; + case "overdue": + return theme.palette.warning.main; + default: + return "gray"; + } +}; + +export default function AssignmentsView( + { + assignments, + demoDate, + }: { + assignments: AssignmentDto[]; + demoDate: dayjs.Dayjs | null; + }, +) { + // material react table + const columns: MRT_ColumnDef[] = [ + { + header: "Übungsblatt ID", + accessorFn: (row) => row.id === "99" ? "Projekt" : row.id, + enableSorting: false, + }, + { + accessorKey: "title", + header: "Title", + }, + // { + // accessorKey: "dueDate", + // header: "Due Date", + // }, + { + accessorFn: (row) => new Date(row.dueDate), //convert to Date for sorting and filtering + id: "dueDate", + header: "Due Date", + filterVariant: "date", + filterFn: "moreThan", + sortingFn: "datetime", + Cell: ({ cell }) => cell.getValue().toLocaleDateString("de"), //render Date as a string + Header: ({ column }) => {column.columnDef.header}, //custom header markup + muiFilterTextFieldProps: { + sx: { + minWidth: "250px", + }, + }, + }, + { + header: "Grade", + accessorFn: (row) => `${row.status === "submitted" ? "??" : row.actualPoints}/${row.maxPoints}`, + Cell: ({ row, cell }) => ( + ({ + color: Number(row.original.actualPoints) / row.original.maxPoints === 1 && row.original.status === "graded" ? theme.palette.success.main + : theme.palette.text.primary, + })} + > + {cell.getValue()} + + ), + enableSorting: false, + }, + { + header: "Status", + accessorFn: (row) => row.status === "not submitted" && demoDate?.isAfter(dayjs(row.dueDate)) ? "overdue" : row.status, + Cell: ({ cell }) => ( + ({ + backgroundColor: statusColors(cell.getValue() as AssignmentDto["status"], theme), + borderRadius: "0.35rem", + color: "white", + padding: "4px", + })} + > + {cell.getValue()} + + ), + }, + ]; + // const table = useMaterialReactTable({ + // columns, + // data: dummyAssignments, + // // initialState: {columnVisibility: {description: false}}, + // // enableColumnResizing: true, + // // enableStickyHeader: true, + // // enableRowSelection: true, + // // enableColumnOrdering: true, + // // rowPinningDisplayMode: "select-sticky", + // // getRowId: (row) => row.id, + // // layoutMode: "grid", + // // columnResizeMode: "onEnd", + // }); + return ( + + + [ + + + + + View Rubric + , + + + + + File Complaint + , + ] + } + enableColumnResizing + // enableRowSelection + enableStickyHeader + layoutMode="grid" + // rowPinningDisplayMode="select-sticky" + muiPaginationProps={{ + color: "secondary", + // rowsPerPageOptions: [10, 20, 30], + shape: "rounded", + variant: "outlined", + }} + /> + + + ); +} diff --git a/common/ui/components/auth-stuff.tsx b/common/ui/components/auth-stuff.tsx new file mode 100644 index 0000000..9f4c6af --- /dev/null +++ b/common/ui/components/auth-stuff.tsx @@ -0,0 +1,98 @@ +"use client"; +import { redirect } from "next/navigation"; +import { useRouter } from "next/router"; +import LoginIcon from "@mui/icons-material/Login"; +import { useState } from "react"; +import { useUser, loginOIDC, loginWithCredentials } from "@repo/ui/lib/auth"; + + +export function OIDCSignInButton() { + const handleOIDCClick = () => { + loginOIDC(); + }; + return ( + + ); +} + +interface CredentialsFormProps { + csrfToken?: string; + returnUrl?: string; +} + +export function CredentialsForm(props: CredentialsFormProps) { + const [error, setError] = useState(null); + + const returnURL = props.returnUrl; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const data = new FormData(e.currentTarget); + + const username = data.get("username")?.toString(); + const password = data.get("password")?.toString(); + if (!username || !password) { + setError("Please enter a username and password"); + return; + } + + const user = await loginWithCredentials(username, password); + + if (!user) { + setError("Your Email or Password is wrong!"); + } else { + redirectToReturnURL(returnURL); + } + }; + return ( +
+ {error ? + {error} + : null} + + + + + +
+ ); +} + +export function redirectToReturnURL(returnUrl?: unknown) { + // if no return url is set, redirect to home page + console.log("redirecting to return url", returnUrl); + redirect(returnUrl && typeof returnUrl === "string" ? returnUrl : "/"); +} + +export async function redirectToReturnURLIfLoggedIn(returnUrl?: unknown) { + // const user = await useUser(); + // if (user) { + // redirectToReturnURL(returnUrl); + // } +} diff --git a/common/ui/components/card-carousel.scss b/common/ui/components/card-carousel.scss new file mode 100644 index 0000000..205d7ad --- /dev/null +++ b/common/ui/components/card-carousel.scss @@ -0,0 +1,3 @@ +.react-multi-carousel-dot-list { + position: relative !important; +} diff --git a/common/ui/components/card-carousel.tsx b/common/ui/components/card-carousel.tsx new file mode 100644 index 0000000..13ce661 --- /dev/null +++ b/common/ui/components/card-carousel.tsx @@ -0,0 +1,126 @@ +// using client because of https://github.com/YIZHUANG/react-multi-carousel/issues/379 +"use client"; +import Card from "@mui/material/Card"; +import CardMedia from "@mui/material/CardMedia"; +import CardContent from "@mui/material/CardContent"; +import Typography from "@mui/material/Typography"; +import CardActions from "@mui/material/CardActions"; +import Button from "@mui/material/Button"; +import Carousel from "react-multi-carousel"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import SettingsIcon from "@mui/icons-material/Settings"; +import "react-multi-carousel/lib/styles.css"; +import "./card-carousel.scss"; + + +export interface CardContent { + title: string; + description: string; + image: string; + openButtonEnabled?: boolean; + settingsButtonEnabled?: boolean; + openButtonHref?: string | null; +} + +const responsive = { + superLargeDesktop: { + // the naming can be any, depends on you. + breakpoint: { max: 4000, min: 3000 }, + items: 5, + }, + desktop: { + breakpoint: { max: 3000, min: 1024 }, + items: 3, + }, + tablet: { + breakpoint: { max: 1024, min: 464 }, + items: 2, + }, + mobile: { + breakpoint: { max: 464, min: 0 }, + items: 1, + }, +}; + +export default function CardCarousel( + { + items, + }: { + items: CardContent[]; + }, +) { + return ( +
+ + {items.map(item => ( + + + + + {item.title} + + + {item.description} + + + + + + + + ))} + +
+ ); +} diff --git a/common/ui/card.tsx b/common/ui/components/card.tsx similarity index 100% rename from common/ui/card.tsx rename to common/ui/components/card.tsx diff --git a/common/ui/components/courses.tsx b/common/ui/components/courses.tsx new file mode 100644 index 0000000..013349c --- /dev/null +++ b/common/ui/components/courses.tsx @@ -0,0 +1,43 @@ +"use client"; +import type { TypedDocumentNode } from "@apollo/client"; +import { useQuery } from "@apollo/client"; +import gql from "graphql-tag"; +import type { CourseDto } from "lab-hub/app/__generated__/graphql"; +import withApollo, { client } from "@repo/ui/lib/with-apollo"; +import type { CardContent } from "./card-carousel"; +import CardCarousel from "./card-carousel"; + +const query: TypedDocumentNode<{course: {fetchAll: CourseDto[]}}> += gql` +query fetchAllCourses { + course { + fetchAll { + id + name + description + semesterStartYear + semesterType + } + } +} +`; +export default function Courses() { + const { loading, data } = useQuery(query, { client }); + if(loading) { + return

Loading...

; + } + if(!data) { + return

No data

; + } + const courses = data.course.fetchAll; + console.log("courses", courses) + const cardCourses: CardContent[] = courses.map((course) => ({ + title: course.name, + description: course.description, + image: "https://www.pngkit.com/png/full/12-120436_open-book-icon-png.png", + openButtonEnabled: true, + settingsButtonEnabled: false, + openButtonHref: `/courses/${course.id}`, + })) || []; + return ; +} diff --git a/common/ui/components/footer.tsx b/common/ui/components/footer.tsx new file mode 100644 index 0000000..e452f9c --- /dev/null +++ b/common/ui/components/footer.tsx @@ -0,0 +1,28 @@ +"use client"; +import { Box } from "@mui/material"; +import GitHubButton from "react-github-btn" + +export default function Footer() { + return ( + +
+

Copyright © 2023 Ruben Deisenroth and Alexander Staeding

+ + Star on GitHub + +
+
+ ); +} diff --git a/common/ui/index.tsx b/common/ui/components/index.tsx similarity index 100% rename from common/ui/index.tsx rename to common/ui/components/index.tsx diff --git a/common/ui/components/navbar.tsx b/common/ui/components/navbar.tsx new file mode 100644 index 0000000..c4e7a48 --- /dev/null +++ b/common/ui/components/navbar.tsx @@ -0,0 +1,188 @@ +"use client"; + +import * as React from "react"; +import AppBar from "@mui/material/AppBar"; +import Box from "@mui/material/Box"; +import Toolbar from "@mui/material/Toolbar"; +import IconButton from "@mui/material/IconButton"; +import Typography from "@mui/material/Typography"; +import Menu from "@mui/material/Menu"; +import MenuIcon from "@mui/icons-material/Menu"; +import Container from "@mui/material/Container"; +import Avatar from "@mui/material/Avatar"; +import Button from "@mui/material/Button"; +import Tooltip from "@mui/material/Tooltip"; +import MenuItem from "@mui/material/MenuItem"; +import AdbIcon from "@mui/icons-material/Adb"; +import md5 from "md5"; +import { Route } from "@mui/icons-material"; +import { useUser } from "@repo/ui/lib/auth"; + +const pages = [ + { name: "Dashboard", href: "/" }, + { name: "Courses", href: "/courses" }, + { name: "Jobs", href: "/jobs" }, + { name: "Imprint", href: "/imprint" }, +]; +const settings = ["Profile", "Account", "Dashboard", "Logout"]; + +export default function Navbar() { + const user = useUser(); + const [anchorElNav, setAnchorElNav] = React.useState( + null, + ); + const [anchorElUser, setAnchorElUser] = React.useState( + null, + ); + + const handleOpenNavMenu = (event: React.MouseEvent) => { + setAnchorElNav(event.currentTarget); + }; + const handleOpenUserMenu = (event: React.MouseEvent) => { + setAnchorElUser(event.currentTarget); + }; + + const handleCloseNavMenu = () => { + setAnchorElNav(null); + }; + + const handleCloseUserMenu = () => { + setAnchorElUser(null); + }; + + return ( + + + + + + SGLab + + + + + + + + {pages.map((page) => ( + + + {page.name} + + + ))} + + + + + SGLab + + + {pages.map((page) => ( + + ))} + + + + + + + + + + {settings.map((setting) => ( + + {setting} + + ))} + + + + + + ); +} diff --git a/common/ui/components/order-preview.tsx b/common/ui/components/order-preview.tsx new file mode 100644 index 0000000..f566a64 --- /dev/null +++ b/common/ui/components/order-preview.tsx @@ -0,0 +1,41 @@ +import Image from "next/image" +import Button from "@mui/material/Button"; + +// Assuming you have an Order type defined somewhere +interface Order { + creator: { + username: string; + profilePicture: string; + }; + titleImage: string; + sumOfPurchasedItems: number; +} + +export default function Home() { + // This would be fetched from an API in a real application + const orders: Order[] = [ + { + creator: { + username: "John Doe", + profilePicture: "url-to-john-doe-profile-picture", + }, + titleImage: "url-to-title-image", + sumOfPurchasedItems: 5, + }, + // More orders here... + ]; + + return ( +
+

Recent Orders

+ {orders.map((order, index) => ( +
+ {order.creator.username} +

{order.creator.username}

+ Title image +

{order.sumOfPurchasedItems}

+
+ ))} +
+ ); +} \ No newline at end of file diff --git a/common/ui/components/rubric-view.tsx b/common/ui/components/rubric-view.tsx new file mode 100644 index 0000000..90c3632 --- /dev/null +++ b/common/ui/components/rubric-view.tsx @@ -0,0 +1,187 @@ +"use client"; + +import type { MRT_ColumnDef } from "material-react-table"; +import "bootstrap/dist/css/bootstrap.min.css"; // Import bootstrap CSS +import { MaterialReactTable, useMaterialReactTable } from "material-react-table"; +import { useMemo } from "react"; +import { useTheme } from "@mui/material"; +import Box from "@mui/material/Box"; +import LinearProgress from "@mui/material/LinearProgress"; +import Paper from "@mui/material/Paper"; +import { + Chart, Legend, + PieSeries, + Title, +} from "@devexpress/dx-react-chart-material-ui"; +import { Animation } from "@devexpress/dx-react-chart"; +import type { Criterion, NumberRange, Rubric, ShowcaseString, TestRun } from "../models/rubric"; + +function renderShowcaseString(str?: ShowcaseString | null, errorMsg = "No value") { + return str?.html ?
:

{str?.text || errorMsg}

; +} + +function renderNumberRange(range: NumberRange) { + return range.min === range.max ? range.max : `[${range.min}, ${range.max}]`; +} + +/** + * Normalize the points to a value between 0 and 100 + * @param value the value to normalize + * @param possiblePoints the range of possible points + */ +function normalizePoints(value: number, possiblePoints: NumberRange) { + return ((value - possiblePoints.min)* 100) / (possiblePoints.max - possiblePoints.min); +} + +export default function RubricView(params: { rubric: Rubric }) { + const theme = useTheme(); + const rubric = params.rubric; + const columns = useMemo[]>(() => [ + { + header: "Kriterium", + accessorFn: (row) => row.name, + Cell: ({ cell }) => { + return renderShowcaseString(cell.getValue() as ShowcaseString, ""); + }, + }, + { + header: "Möglich", + accessorFn: (row) => renderNumberRange(row.possiblePoints), + }, + { + header: "Erreicht", + accessorFn: (row) => renderNumberRange(row.achievedPoints), + }, + { + header: "Kommentar", + accessorFn: (row) => row.message, + Cell: ({ cell }) => { + return renderShowcaseString(cell.getValue() as ShowcaseString, ""); + }, + }, + ], + []); + const data = params.rubric.criteria; + + const testRunColumns = useMemo[]>(() => [ + { + header: "Test ID", + accessorFn: (row) => row.id, + }, + { + header: "Name", + accessorFn: (row) => row.name, + }, + { + header: "State", + accessorFn: (row) => row.state, + }, + { + header: "Message", + grow: true, + accessorFn: (row) => row.message, + Cell: ({ cell }) => { + return renderShowcaseString(cell.getValue() as ShowcaseString, ""); + }, + }, + { + header: "Duration", + id: "duration", + accessorFn: (row) => row.duration, + }, + { + header: "Stacktrace", + id: "stacktrace", + accessorFn: (row) => row.stacktrace, + Cell: ({ cell }) => { + return
; + }, + }, + ], + []); + + const table = useMaterialReactTable({ + columns, + data, + enableExpandAll: true, //hide expand all double arrow in column header + enableExpanding: true, + enableColumnOrdering: true, + enableColumnResizing: true, + enableStickyHeader: true, + layoutMode: "grid", + filterFromLeafRows: true, //apply filtering to all rows instead of just parent rows + getSubRows: (row) => row.children, //default + muiDetailPanelProps: () => ({ + sx: (them) => ({ + backgroundColor: + theme.palette.mode === "dark" + ? "rgba(255,210,244,0.1)" + : "rgba(0,0,0,0.1)", + }), + }), + muiPaginationProps: ({ table }) => ({ + color: "secondary", + shape: "rounded", + variant: "outlined", + }), + renderDetailPanel: ({ row }) => { + const testRunData = row.original.tests; + const result = renderShowcaseString(row.original.description, "No Description."); + + if (!testRunData) { + return result; + } + // eslint-disable-next-line react-hooks/rules-of-hooks + const testRunTable = useMaterialReactTable({ + columns: testRunColumns, + data: testRunData, + enableColumnOrdering: true, + enableColumnResizing: true, + enableStickyHeader: true, + layoutMode: "grid", + // initialState: { columnVisibility: { "duration": false, "stacktrace": false } }, + }); + return + {result} + {/**/} + ; + }, + initialState: {}, //expand all rows by default + paginateExpandedRows: false, //When rows are expanded, do not count sub-rows as number of rows on the page towards pagination + }); + + return +

{renderShowcaseString(rubric.name)}

+

{renderShowcaseString(rubric.description)}

+

Erreichte Punkte: {renderNumberRange(rubric.achievedPoints)}/{rubric.possiblePoints.max} ({normalizePoints(rubric.achievedPoints.min, rubric.possiblePoints)})% + {/**/} + + + + {/**/} + + + + <Animation /> + </Chart> + </Paper> + </p> + <MaterialReactTable table={table}/> + </Box>; +} diff --git a/common/ui/components/user-showcase.tsx b/common/ui/components/user-showcase.tsx new file mode 100644 index 0000000..a48fe83 --- /dev/null +++ b/common/ui/components/user-showcase.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { Avatar, Button } from "@mui/material"; +import md5 from "md5"; +import { useUser } from "../lib/auth"; + +export function UserDisplay() { + const user = useUser(); + return ( + <div className="bg-slate-700 p-5 rounded-sm flex flex-col items-center"> + <Avatar + alt={user?.username ?? undefined} + src={ + user?.email + ? `http://www.gravatar.com/avatar/${md5(user.email)}?s=256` + : undefined + } + sx={{ width: 128, height: 128 }} + /> + <p>User: {user?.username ?? "null"}</p> + <p>Email: {user?.email ?? "null"}</p> + <p>Last Seen: {user?.lastSeen.toString() ?? "null"}</p> + <Button + onClick={() => { + fetch("/login/logout", { + method: "POST", + credentials: "include", + }).then(() => { + window.location.reload(); + }); + }} + variant="contained" + >Logout</Button> + </div> + ); +} diff --git a/common/ui/graphql.config.yml b/common/ui/graphql.config.yml new file mode 100644 index 0000000..5e26dab --- /dev/null +++ b/common/ui/graphql.config.yml @@ -0,0 +1,2 @@ +schema: http://localhost:8080/graphql +documents: '**/*.graphql' diff --git a/common/ui/lib/auth.tsx b/common/ui/lib/auth.tsx new file mode 100644 index 0000000..6a6f5f4 --- /dev/null +++ b/common/ui/lib/auth.tsx @@ -0,0 +1,103 @@ +import { plainToClass } from "class-transformer"; +import { redirect } from "next/navigation"; +import useSWR from "swr"; +import { useEffect } from "react"; +import { Router } from "next/router"; +import { User } from "../models/User"; + +export const api_url = process.env.BACKEND_URL ?? "http://localhost:3000"; + +/** + * Fetches the currently logged in user. + * @returns The logged in user or null if the user is not logged in. + */ +export function useUser( + redirectTo?: string, + redirectIfFound?: string, +): User | null { + const { data: userJson, error } = useSWR(`${api_url}/login/me`, () => + fetch(`${api_url}/login/me`, { + method: "GET", + credentials: "include", + }).then((res) => res.json()), + ); + const finished = Boolean(userJson); + console.log("userJson", userJson); + + useEffect(() => { + if (!redirectTo || !finished) return; + if ( + // If redirectTo is set, redirect if the user was not found. + (redirectTo && !redirectIfFound && !userJson) || + // If redirectIfFound is also set, redirect if the user was found + (redirectIfFound && userJson) + ) { + redirect(redirectTo); + } + }, [redirectTo, redirectIfFound, finished, userJson]); + if (error) { + console.log("error", error); + return null; + } + return userJson ? plainToClass(User, userJson) : null; +} + +/** + * Logs the user in using the credentials flow. + * + * @param username the username of the user + * @param password the password of the user + * @returns The logged in user or null if the user is not logged in. + */ +export async function loginWithCredentials( + username: string, + password: string, +): Promise<User | null> { + const response = await fetch(`${api_url}/login/login`, { + method: "POST", + body: JSON.stringify({ username, password }), + }); + // return response.ok ? useUser() : null; + return null; +} + +/** + * Logs the user in using the OpenID Connect flow. + * + * @returns The logged in user or null if the user is not logged in. + */ +export async function loginOIDC(): Promise<User | null> { + window.location.href = `${api_url}/login/oidc`; + return null; +} + +/** + * Navigates to the login page and redirects back to the current page after login. + */ +export function loginFlow() { + redirect(`/login?returnUrl=${ window.location.pathname}`); +} + +/** + * Logs the user out. + * + * @returns true if the user was logged out successfully, false otherwise. + */ +export async function logout() { + const response = await fetch(`${api_url}/login/logout`, { + method: "GET", + credentials: "include", + }); + return response.ok; +} + +/** + * Logs the user out and redirects to the login page. + */ +export async function logoutFlow() { + await logout(); + if (!logout) { + throw new Error("Logout failed"); + } + redirect(`/login?returnUrl=${ window.location.pathname}`); +} diff --git a/common/ui/lib/sample-data.tsx b/common/ui/lib/sample-data.tsx new file mode 100644 index 0000000..e69de29 diff --git a/common/ui/lib/theme-registry.tsx b/common/ui/lib/theme-registry.tsx new file mode 100644 index 0000000..dac8142 --- /dev/null +++ b/common/ui/lib/theme-registry.tsx @@ -0,0 +1,80 @@ +"use client"; +import createCache from "@emotion/cache"; +import { useServerInsertedHTML } from "next/navigation"; +import { CacheProvider } from "@emotion/react"; +import { createTheme, ThemeProvider } from "@mui/material/styles"; +import CssBaseline from "@mui/material/CssBaseline"; +import React, { useMemo } from "react"; +import { useMediaQuery } from "@mui/material"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; + +// This implementation is from emotion-js +// https://github.com/emotion-js/emotion/issues/2928#issuecomment-1319747902 +export default function ThemeRegistry(props: any) { + const { options, children } = props; + + const [{ cache, flush }] = React.useState(() => { + const ecache = createCache(options); + ecache.compat = true; + const prevInsert = ecache.insert; + let inserted: string[] = []; + ecache.insert = (...args) => { + const serialized = args[1]; + if (ecache.inserted[serialized.name] === undefined) { + inserted.push(serialized.name); + } + return prevInsert(...args); + }; + const flush = () => { + const prevInserted = inserted; + inserted = []; + return prevInserted; + }; + return { cache: ecache, flush }; + }); + + useServerInsertedHTML(() => { + const names = flush(); + if (names.length === 0) { + return null; + } + let styles = ""; + for (const name of names) { + styles += cache.inserted[name]; + } + return ( + <style + dangerouslySetInnerHTML={{ + __html: styles, + }} + data-emotion={`${cache.key} ${names.join(" ")}`} + key={cache.key} + /> + ); + }); + + const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)"); + + const theme = useMemo( + () => + createTheme({ + palette: { + mode: "dark", + }, + }), + [prefersDarkMode], + ); + + return ( + <LocalizationProvider + dateAdapter={AdapterDayjs}> + <CacheProvider value={cache}> + <ThemeProvider theme={theme}> + <CssBaseline/> + {children} + </ThemeProvider> + </CacheProvider> + </LocalizationProvider> + ); +} diff --git a/common/ui/lib/with-apollo.tsx b/common/ui/lib/with-apollo.tsx new file mode 100644 index 0000000..959ca2b --- /dev/null +++ b/common/ui/lib/with-apollo.tsx @@ -0,0 +1,43 @@ +import { + ApolloClient, + InMemoryCache, + HttpLink, + ApolloProvider, +} from "@apollo/client"; +import withApollo from "next-with-apollo"; + +export const client = new ApolloClient({ + ssrMode: typeof window === "undefined", + link: new HttpLink({ + uri: "http://localhost:8080/graphql", + }), + credentials: "include", + cache: new InMemoryCache(), +}); + +export default withApollo( + ({ initialState, headers }) => { + return new ApolloClient({ + ssrMode: typeof window === "undefined", + link: new HttpLink({ + uri: "http://localhost:8080/graphql", + }), + credentials: "include", + headers: { + ...(headers as Record<string, string>), + }, + cache: new InMemoryCache().restore(initialState || {}), + }); + }, + { + render: ({ Page, props }) => { + // const router = useRouter(); + return ( + <ApolloProvider client={props.apollo}> + <Page {...props}/> + {/* <Page {...props} router={router} /> */} + </ApolloProvider> + ); + }, + }, +); diff --git a/common/ui/models/User.ts b/common/ui/models/User.ts new file mode 100644 index 0000000..9131369 --- /dev/null +++ b/common/ui/models/User.ts @@ -0,0 +1,10 @@ +export interface INewUser { + username: string; + email: string; +} +export class User { + lastSeen!: Date; + username!: string; + email!: string; + password!: string; +} diff --git a/common/ui/models/rubric.ts b/common/ui/models/rubric.ts new file mode 100644 index 0000000..278da05 --- /dev/null +++ b/common/ui/models/rubric.ts @@ -0,0 +1,191 @@ +/** + * A number range defines a range of numbers. The range is inclusive. + */ +export interface NumberRange { min: number; max: number } + +/** + * A showcase string is a string that can either be a simple string or an object that contains a text and an html property. The text property is used for the text representation of the string and the html property is used for the html representation of the string. Both representations should contain the same information. + */ +export interface ShowcaseString { text: string; html?: string|null } + +/** + * The state of a test + */ +export enum TestState { + /** + * The test is pending, i.e. it has not been executed yet + */ + PENDING = "PENDING", + /** + * The test is running, i.e. it is currently being executed + */ + RUNNING = "RUNNING", + /** + * The test is successful, i.e. it has been executed and the result is positive + */ + SUCCESS = "SUCCESS", + /** + * The test is not successful, i.e. it has been executed and the result is negative but no exception has been thrown + */ + FAILURE = "FAILURE", + /** + * The test is not successful, i.e. it has been executed and the result is negative and an exception has been thrown + */ + ERROR = "ERROR", + /** + * The test has been skipped, i.e. it has not been executed. This is the case if a test is skipped by the user or if a test is skipped because a prerequisite test has failed. + */ + SKIPPED = "SKIPPED", + /** + * The state of the test is unknown, this is most likely due to an error in the test runner + */ + UNKNOWN = "UNKNOWN", +} + +/** + * A test run is the result of a test. It contains information about the test such as the state, the message, the stacktrace, the duration and the children of the test. + */ +export interface TestRun { + /** + * The id of the test + */ + id: string; + /** + * The name of the test + */ + name: string; + /** + * The state of the test + */ + state: string; + /** + * The message of the test. It should be empty if the test is successful. If the test is not successful, it should contain the error message as well as context information. + */ + message?: ShowcaseString; + /** + * The stacktrace of the test + */ + stacktrace?: string|null; + /** + * The duration of the test in milliseconds + */ + duration: number; + /** + * The children of the test + */ + children?: TestRun[]; +} + +/** + * The accumulator that is used to determine the achieved points of a criterion + */ +export enum CriterionAccumulator { + /** + * This is the default accumulator. It is used if no other accumulator is specified. It requires all tests to be successful and sums up to possiblePoints.max. If any test is not successful, the sum is possiblePoints.min. + */ + AND = "AND", + /** + * This accumulator requires at least one test to be successful and sums up to possiblePoints.max. If no test is successful, the sum is possiblePoints.min. + */ + OR = "OR", + /** + * This accumulator requires no test to be successful and sums up to possiblePoints.max. If any test is successful, the sum is possiblePoints.min. + */ + NOT = "NOT", + /** + * This accumulator starts with possiblePoints.min and adds 1 point for each successful test. The result is clamped to the range [possiblePoints.min, possiblePoints.max]. + */ + SUM = "SUM", +}; + +/** + * A criterion is a part of a rubric. + */ +export interface Criterion { + /** + * The id of the criterion + */ + id: string; + /** + * The name of the criterion + */ + name: ShowcaseString; + /** + * The description of the criterion + */ + description: ShowcaseString; + /** + * The possible amount of points that can be achieved + */ + possiblePoints: NumberRange; + /** + * The points that have been achieved. Can be a range if the points are not fixed yet + */ + achievedPoints: NumberRange; + /** + * The message that is displayed if the criterion is not fulfilled + */ + message?: ShowcaseString; + /** + * Tests that were used to determine the achieved points + */ + tests?: TestRun[]; + /** + * The accumulator that is used to determine the achieved points of the criterion. + */ + testAccumulator?: CriterionAccumulator; + /** + * The children of the criterion + */ + children?: Criterion[]; + /** + * The accumulator that is used to determine the achieved points of the children of the criterion. + */ + childrenAccumulator?: CriterionAccumulator; +} + +/** + * A leaf criterion is a criterion that does not have any children. + */ +export type LeafCriterion = Omit<Criterion, "children" | "childrenAccumulator"> & { + tests: TestRun[]; + testAccumulator: CriterionAccumulator; +}; + +/** + * A parent criterion is a criterion that has children. + */ +export type ParentCriterion = Omit<Criterion, "tests" | "testAccumulator"> & { + children: Criterion[]; + childrenAccumulator: CriterionAccumulator; +}; + +/** + * A rubric is a collection of criteria. It is used to provide feedback to the student about their solution. + */ +export interface Rubric { + /** + * The id of the rubric + */ + id: string; + /** + * The name of the rubric. This should contain information about the exercise such as sheet-number, group, etc. + */ + name: ShowcaseString; + /** + * The description of the rubric + */ + description: ShowcaseString; + /** + * The possible amount of points that can be achieved + */ + possiblePoints: NumberRange; + /** + * The points that have been achieved. Can be a range if the points are not fixed yet + */ + achievedPoints: NumberRange; + /** + * The criteria of the rubric + */ + criteria: Criterion[]; +} diff --git a/common/ui/models/rubrics.schema.json b/common/ui/models/rubrics.schema.json new file mode 100644 index 0000000..b15a3ce --- /dev/null +++ b/common/ui/models/rubrics.schema.json @@ -0,0 +1,281 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "NumberRange": { + "type": "object", + "description": "A number range defines a range of numbers. The range is inclusive.", + "properties": { + "min": { + "description": "The minimum value of the range.", + "type": "number", + "default": 0 + }, + "max": { + "description": "The maximum value of the range.", + "type": "number" + } + }, + "required": [ + "min", + "max" + ], + "additionalProperties": false + }, + "PointRange": { + "description": "A point range defines a range of points. This can be a single number or a range of numbers. The range is inclusive.", + "oneOf": [ + { + "type": "number" + }, + { + "$ref": "#/definitions/NumberRange" + } + ], + "default": { + "min": 0, + "max": 1 + } + }, + "ShowcaseString": { + "description": "A showcase string is a string that can either be a simple string or an object that contains a text and an html property. The text property is used for the text representation of the string and the html property is used for the html representation of the string. Both representations should contain the same information.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "text": { + "description": "The text representation of the string.", + "type": "string" + }, + "html": { + "description": "The html representation of the string.", + "type": "string" + } + }, + "required": [ + "text", + "html" + ], + "additionalProperties": false + } + ] + }, + "TestState": { + "type": "string", + "description": "The state of a test run.", + "enum": [ + "PENDING", + "RUNNING", + "SUCCESS", + "FAILURE", + "ERROR", + "SKIPPED", + "UNKNOWN" + ] + }, + "TestRun": { + "type": "object", + "description": "A test run is the result of a test. It contains information about the test such as the state, the message, the stacktrace, the duration and the children of the test.", + "properties": { + "id": { + "description": "The id of the test run. This id is used to identify the test run in the showcase.", + "type": "string" + }, + "name": { + "description": "The name of the test run.", + "type": "string" + }, + "type": { + "description": "The type of the test run.", + "$ref": "#/definitions/TestState" + }, + "message": { + "description": "The message of the test. It should be empty if the test is successful. If the test is not successful, it should contain the error message as well as context information.", + "$ref": "#/definitions/ShowcaseString" + }, + "stacktrace": { + "description": "The stacktrace of the test. It should be empty if the test is successful. If the test is not successful, it should contain the stacktrace of the error.", + "type": "string" + }, + "duration": { + "description": "The duration of the test in milliseconds.", + "type": "number" + }, + "children": { + "type": "array", + "description": "The children of the test. This can be other tests or test runs.", + "items": { + "$ref": "#/definitions/TestRun" + } + } + }, + "required": [ + "id", + "name", + "type", + "duration" + ], + "additionalProperties": false + }, + "CriterionAccumulator": { + "type": "string", + "description": "The accumulator of a criterion. It defines how the points of the criterion are calculated.", + "enum": [ + "AND", + "OR", + "NOT", + "SUM" + ], + "default": "AND" + }, + "Criterion": { + "type": "object", + "description": "A criterion is a single criterion of a rubric. It can be a leaf criterion or a parent criterion. A leaf criterion contains tests and a parent criterion contains other criteria.", + "properties": { + "id": { + "type": "string", + "description": "The id of the criterion. This id is used to identify the criterion in the showcase." + }, + "name": { + "description": "The name of the criterion.", + "$ref": "#/definitions/ShowcaseString" + }, + "description": { + "description": "The description of the criterion.", + "$ref": "#/definitions/ShowcaseString" + }, + "possiblePoints": { + "description": "The possible amount of points that can be achieved.", + "$ref": "#/definitions/NumberRange" + }, + "achievedPoints": { + "description": "The amount of points that were achieved.", + "$ref": "#/definitions/PointRange" + }, + "message": { + "description": "The message that is displayed if the criterion is not fulfilled", + "$ref": "#/definitions/ShowcaseString" + }, + "tests": { + "type": "array", + "description": "The tests of the criterion. This is only defined if the criterion is a leaf criterion.", + "items": { + "$ref": "#/definitions/TestRun" + } + }, + "testAccumulator": { + "description": "The accumulator of the tests. It defines how the points of the tests are calculated. This is only defined if the criterion is a leaf criterion.", + "$ref": "#/definitions/CriterionAccumulator" + }, + "children": { + "type": "array", + "description": "The children of the criterion. This is only defined if the criterion is a parent criterion.", + "items": { + "$ref": "#/definitions/Criterion" + } + }, + "childrenAccumulator": { + "description": "The accumulator of the children. It defines how the points of the children are calculated. This is only defined if the criterion is a parent criterion.", + "$ref": "#/definitions/CriterionAccumulator" + } + }, + "required": [ + "id", + "name", + "description", + "possiblePoints", + "achievedPoints" + ], + "additionalProperties": false + }, + "LeafCriterion": { + "description": "A leaf criterion is a criterion that does not have any children.", + "allOf": [ + { + "$ref": "#/definitions/Criterion" + }, + { + "not": { + "required": [ + "children", + "childrenAccumulator" + ] + } + }, + { + "required": [ + "tests", + "testAccumulator" + ] + } + ] + }, + "ParentCriterion": { + "description": "A parent criterion is a criterion that has children.", + "allOf": [ + { + "$ref": "#/definitions/Criterion" + }, + { + "not": { + "required": [ + "tests", + "testAccumulator" + ] + } + }, + { + "required": [ + "children", + "childrenAccumulator" + ] + } + ] + }, + "Rubric": { + "type": "object", + "description": "A rubric is a collection of criteria. It is used to provide feedback to the student about their solution.", + "properties": { + "id": { + "type": "string", + "description": "The id of the rubric. This id is used to identify the rubric in the showcase." + }, + "name": { + "description": "The name of the rubric. This should contain information about the exercise such as sheet-number, group, etc.", + "$ref": "#/definitions/ShowcaseString" + }, + "description": { + "description": "The description of the rubric.", + "$ref": "#/definitions/ShowcaseString" + }, + "possiblePoints": { + "description": "The possible amount of points that can be achieved.", + "$ref": "#/definitions/NumberRange" + }, + "achievedPoints": { + "description": "The amount of points that were achieved.", + "$ref": "#/definitions/PointRange" + }, + "criteria": { + "type": "array", + "description": "The criteria of the rubric.", + "items": { + "$ref": "#/definitions/Criterion" + } + } + }, + "required": [ + "id", + "name", + "description", + "possiblePoints", + "achievedPoints", + "criteria" + ], + "additionalProperties": false + } + }, + "$ref": "#/definitions/Rubric" +} \ No newline at end of file diff --git a/common/ui/package.json b/common/ui/package.json index 6d51c3f..a70033a 100644 --- a/common/ui/package.json +++ b/common/ui/package.json @@ -8,14 +8,45 @@ "lint": "eslint .", "generate:component": "turbo gen react-component" }, + "dependencies": { + "@devexpress/dx-react-chart": "^4.0.8", + "@devexpress/dx-react-chart-material-ui": "^4.0.8", + "@devexpress/dx-react-core": "^4.0.8", + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", + "@fontsource/roboto": "^5.0.8", + "@mui/icons-material": "^5.15.1", + "@mui/lab": "^5.0.0-alpha.170", + "@mui/material": "^5.15.1", + "@mui/x-date-pickers": "^7.1.1", + "@typegoose/typegoose": "^12.0.0", + "class-transformer": "^0.5.1", + "common": "^0.2.5", + "dayjs": "^1.11.10", + "material-react-table": "^2.12.1", + "md5": "^2.3.0", + "passport-local": "^1.0.0", + "passport-local-mongoose": "^8.0.0", + "react-github-btn": "^1.4.0", + "react-multi-carousel": "^2.8.4", + "react-swipeable-views": "^0.14.0", + "sass": "^1.69.5", + "swr": "^2.2.4", + "typegoose": "^5.9.1" + }, "devDependencies": { + "@repo/eslint-config-custom": "*", + "@repo/tsconfig": "*", "@turbo/gen": "^1.10.12", + "@types/md5": "^2.3.5", "@types/node": "^20.5.2", + "@types/passport-local": "^1.0.38", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", - "@repo/eslint-config-custom": "*", - "@repo/tsconfig": "*", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", "react": "^18.2.0", + "tailwindcss": "^3.4.0", "typescript": "^4.5.2" } } diff --git a/common/ui/turbo/generators/config.ts b/common/ui/turbo/generators/config.ts index f8dc6e8..7ebd962 100644 --- a/common/ui/turbo/generators/config.ts +++ b/common/ui/turbo/generators/config.ts @@ -24,7 +24,7 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void { type: "append", path: "index.tsx", pattern: /(?<insertion>\/\/ component exports)/g, - template: 'export * from "./{{pascalCase name}}";', + template: "export * from \"./{{pascalCase name}}\";", }, ], }); diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d0db7a9..9c54a5b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ ktor-server-sessions = { module = "io.ktor:ktor-server-sessions" } ktor-server-status-pages = { module = "io.ktor:ktor-server-status-pages" } ktor-serialization-kotlinx = { module = "io.ktor:ktor-serialization-kotlinx" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json" } # TODO: Remove +kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } kubernetes-client = "io.fabric8:kubernetes-client:6.11.0" logging-api = { module = "org.apache.logging.log4j:log4j-api", version.ref = "log4j" } diff --git a/hub/build.gradle.kts b/hub/build.gradle.kts index bea470f..b5e0d11 100644 --- a/hub/build.gradle.kts +++ b/hub/build.gradle.kts @@ -15,6 +15,8 @@ dependencies { implementation(libs.logging.api) implementation(libs.logging.core) implementation(libs.logging.slf4jimpl) + implementation(libs.ktor.server.cors) +// implementation("org.apache.logging.log4j:log4j-slf4j-impl:2.14.0") implementation(libs.exposed.core) implementation(libs.exposed.crypt) implementation(libs.exposed.dao) @@ -26,6 +28,7 @@ dependencies { implementation(libs.ktor.client.cio) implementation(libs.ktor.server.call.logging) implementation(libs.ktor.client.logging) + implementation(libs.kotlin.reflect) } application { diff --git a/hub/src/main/kotlin/org/sourcegrade/lab/hub/Module.kt b/hub/src/main/kotlin/org/sourcegrade/lab/hub/Module.kt index 45d57ab..aad31cf 100644 --- a/hub/src/main/kotlin/org/sourcegrade/lab/hub/Module.kt +++ b/hub/src/main/kotlin/org/sourcegrade/lab/hub/Module.kt @@ -5,28 +5,31 @@ import com.expediagroup.graphql.server.ktor.graphQLGetRoute import com.expediagroup.graphql.server.ktor.graphQLPostRoute import com.expediagroup.graphql.server.ktor.graphQLSDLRoute import com.expediagroup.graphql.server.ktor.graphiQLRoute +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod import io.ktor.http.Url import io.ktor.server.application.Application import io.ktor.server.application.install import io.ktor.server.config.tryGetString import io.ktor.server.plugins.callloging.CallLogging +import io.ktor.server.plugins.cors.routing.CORS import io.ktor.server.request.path import io.ktor.server.routing.Routing import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.DatabaseConfig import org.sourcegrade.lab.hub.http.authenticationModule -import org.sourcegrade.lab.hub.queries.CourseMutations -import org.sourcegrade.lab.hub.queries.CourseQueries -import org.sourcegrade.lab.hub.queries.HelloWorldQuery -import org.sourcegrade.lab.hub.queries.UserMutations -import org.sourcegrade.lab.hub.queries.UserQueries -import kotlin.collections.listOf +import org.sourcegrade.lab.hub.query.CourseMutations +import org.sourcegrade.lab.hub.query.CourseQueries +import org.sourcegrade.lab.hub.query.HelloWorldQuery +import org.sourcegrade.lab.hub.query.UserMutations +import org.sourcegrade.lab.hub.query.UserQueries fun Application.module() { val environment = environment val url = Url( - environment.config.tryGetString("ktor.deployment.url") ?: throw IllegalStateException("No deployment url set"), + environment.config.tryGetString("ktor.deployment.url") + ?: throw IllegalStateException("No deployment url set"), ) val databaseConfig = @@ -42,6 +45,16 @@ fun Application.module() { databaseConfig = databaseConfig, ) + install(CORS) { +// allowHost("localhost:3000", schemes = listOf("http", "https")) + allowMethod(HttpMethod.Options) + allowMethod(HttpMethod.Post) + allowMethod(HttpMethod.Get) + allowHeader(HttpHeaders.AccessControlAllowOrigin) + allowHeader(HttpHeaders.ContentType) + anyHost() + } + install(GraphQL) { schema { packages = listOf("org.sourcegrade.lab.hub") @@ -63,20 +76,10 @@ fun Application.module() { graphQLGetRoute() graphQLPostRoute() graphQLSDLRoute() - // graphQLSubscriptionsRoute() +// graphQLSubscriptionsRoute() graphiQLRoute() } -// install(ContentNegotiation) { -// json( -// Json { -// prettyPrint = true -// isLenient = true -// ignoreUnknownKeys = true -// }, -// ) -// } - authenticationModule() configureRouting() install(CallLogging) { diff --git a/hub/src/main/kotlin/org/sourcegrade/lab/hub/http/AuthenticationModule.kt b/hub/src/main/kotlin/org/sourcegrade/lab/hub/http/AuthenticationModule.kt index 111286b..1486cca 100644 --- a/hub/src/main/kotlin/org/sourcegrade/lab/hub/http/AuthenticationModule.kt +++ b/hub/src/main/kotlin/org/sourcegrade/lab/hub/http/AuthenticationModule.kt @@ -22,11 +22,11 @@ import io.ktor.server.auth.oauth import io.ktor.server.auth.principal import io.ktor.server.auth.session import io.ktor.server.config.tryGetString +import io.ktor.server.plugins.contentnegotiation.* import io.ktor.server.response.respond import io.ktor.server.response.respondRedirect import io.ktor.server.response.respondText -import io.ktor.server.routing.get -import io.ktor.server.routing.routing +import io.ktor.server.routing.* import io.ktor.server.sessions.Sessions import io.ktor.server.sessions.clear import io.ktor.server.sessions.cookie @@ -86,80 +86,99 @@ fun Application.authenticationModule() { client = httpClient val oauthSettings = OAuthServerSettings.OAuth2ServerSettings( - name = "Authentik", - authorizeUrl = ktorEnv.config.property("ktor.oauth.authorizeUrl").getString(), - accessTokenUrl = ktorEnv.config.property("ktor.oauth.accessTokenUrl").getString(), - requestMethod = HttpMethod.Post, - clientId = ktorEnv.config.property("ktor.oauth.clientId").getString(), - clientSecret = ktorEnv.config.property("ktor.oauth.clientSecret").getString(), - defaultScopes = ktorEnv.config.tryGetString("ktor.oauth.scopes") - ?.split(" ") - ?: listOf("openid", "profile", "email"), - onStateCreated = { call, state -> - // saves new state with redirect url value - call.request.queryParameters["redirectUrl"]?.let { - redirects[state] = it - } - }, - ) - - providerLookup = { oauthSettings } + name = "Authentik", + authorizeUrl = + ktorEnv.config.property("ktor.oauth.authorizeUrl") + .getString(), + accessTokenUrl = + ktorEnv.config.property("ktor.oauth.accessTokenUrl") + .getString(), + requestMethod = HttpMethod.Post, + clientId = + ktorEnv.config.property("ktor.oauth.clientId") + .getString(), + clientSecret = + ktorEnv.config.property("ktor.oauth.clientSecret") + .getString(), + defaultScopes = + ktorEnv.config.tryGetString("ktor.oauth.scopes") + ?.split(" ") + ?: listOf("openid", "profile", "email"), + onStateCreated = { call, state -> + // saves new state with redirect url value + call.request.queryParameters["redirectUrl"]?.let { + redirects[state] = it + } + }, + ) + providerLookup = { oauthSettings } } } routing { - authenticate("Authentik") { - get("/api/session/login") { - // Redirects to 'authorizeUrl' automatically + route("/api/session") { + install(io.ktor.server.plugins.contentnegotiation.ContentNegotiation) { + json( + Json { + prettyPrint = true + isLenient = true + ignoreUnknownKeys = true + }, + ) } + authenticate("Authentik") { + get("login") { + // Redirects to 'authorizeUrl' automatically + } - get(callback) { - val principal: OAuthAccessTokenResponse.OAuth2 = checkNotNull(call.principal()) { "No principal" } - - val userInfo = - httpClient.get( - this@authenticationModule.environment.config.tryGetString("ktor.oauth.userInfoUrl") - ?: throw IllegalStateException("Missing OAuth user info Url"), - ) { - header("Authorization", "Bearer ${principal.accessToken}") - }.body<OAuthUserInfo>() - - // find user in db - - val user = - newSuspendedTransaction { - User.find { Users.email eq userInfo.email }.firstOrNull() - } ?: newSuspendedTransaction { - User.new { - username = userInfo.preferredUsername - email = userInfo.email + get("callback") { + val principal: OAuthAccessTokenResponse.OAuth2 = checkNotNull(call.principal()) { "No principal" } + + val userInfo = + httpClient.get( + this@authenticationModule.environment.config.tryGetString("ktor.oauth.userInfoUrl") + ?: throw IllegalStateException("Missing OAuth user info Url"), + ) { + header("Authorization", "Bearer ${principal.accessToken}") + }.body<OAuthUserInfo>() + + // find user in db + + val user = + newSuspendedTransaction { + User.find { Users.email eq userInfo.email }.firstOrNull() + } ?: newSuspendedTransaction { + User.new { + username = userInfo.preferredUsername + email = userInfo.email + } } - } - val session = - UserSession( - user.id.value, - checkNotNull(principal.state) { "No state" }, - principal.accessToken, - userInfo.email, - ) - - call.sessions.set(session) - principal.state?.let { state -> - redirects[state]?.let { redirect -> - call.respondRedirect(redirect) - return@get + val session = + UserSession( + user.id.value, + checkNotNull(principal.state) { "No state" }, + principal.accessToken, + userInfo.email, + ) + + call.sessions.set(session) + principal.state?.let { state -> + redirects[state]?.let { redirect -> + call.respondRedirect(redirect) + return@get + } } + call.respondRedirect("/") } - call.respondRedirect("/") - } - get("/api/session/logout") { - call.sessions.clear<UserSession>() - call.respondRedirect("/") + get("logout") { + call.sessions.clear<UserSession>() + call.respondRedirect("/") + } + } + get("current-user") { + withUser { call.respond(it.toDTO()) } } - } - get("/api/session/current-user") { - withUser { call.respond(it.toDTO()) } } } } diff --git a/hub/src/main/kotlin/org/sourcegrade/lab/hub/models/DummyDataPopulator.kt b/hub/src/main/kotlin/org/sourcegrade/lab/hub/models/DummyDataPopulator.kt index 72aac91..26e6c2a 100644 --- a/hub/src/main/kotlin/org/sourcegrade/lab/hub/models/DummyDataPopulator.kt +++ b/hub/src/main/kotlin/org/sourcegrade/lab/hub/models/DummyDataPopulator.kt @@ -20,10 +20,13 @@ suspend fun main(args: Array<String>) { addLogger(StdOutSqlLogger) // delete and re-create tables - val tables = SchemaUtils.listTables() - SchemaUtils.drop(CourseMembers, Courses, Users) + val wantedTables = listOf(Users, Courses, CourseMembers) - SchemaUtils.create(Users) + val tables = SchemaUtils.listTables() + if (tables.isNotEmpty()) { + SchemaUtils.drop(*wantedTables.toTypedArray()) + } + SchemaUtils.create(*wantedTables.toTypedArray()) val dummyUsers = listOf( @@ -44,8 +47,6 @@ suspend fun main(args: Array<String>) { }, ) - SchemaUtils.create(Courses) - val dummyCourses = listOf( Course.new { @@ -70,8 +71,6 @@ suspend fun main(args: Array<String>) { }, ) - SchemaUtils.create(CourseMembers) - val dummyCourseMembers = listOf( CourseMember.new { diff --git a/hub/src/main/kotlin/org/sourcegrade/lab/hub/models/Rubric.kt b/hub/src/main/kotlin/org/sourcegrade/lab/hub/models/Rubric.kt new file mode 100644 index 0000000..8202ccd --- /dev/null +++ b/hub/src/main/kotlin/org/sourcegrade/lab/hub/models/Rubric.kt @@ -0,0 +1,5 @@ +package org.sourcegrade.lab.hub.models + +object Rubric : Models("rubrics") { + val blob = text("blob") +} diff --git a/hub/src/main/kotlin/org/sourcegrade/lab/hub/queries/BasicQuery.kt b/hub/src/main/kotlin/org/sourcegrade/lab/hub/query/BasicQuery.kt similarity index 97% rename from hub/src/main/kotlin/org/sourcegrade/lab/hub/queries/BasicQuery.kt rename to hub/src/main/kotlin/org/sourcegrade/lab/hub/query/BasicQuery.kt index 46fb668..902d511 100644 --- a/hub/src/main/kotlin/org/sourcegrade/lab/hub/queries/BasicQuery.kt +++ b/hub/src/main/kotlin/org/sourcegrade/lab/hub/query/BasicQuery.kt @@ -1,4 +1,4 @@ -package org.sourcegrade.lab.hub.queries +package org.sourcegrade.lab.hub.query import graphql.schema.DataFetchingEnvironment import org.jetbrains.exposed.dao.EntityClass diff --git a/hub/src/main/kotlin/org/sourcegrade/lab/hub/queries/CoursEndpoints.kt b/hub/src/main/kotlin/org/sourcegrade/lab/hub/query/CoursEndpoints.kt similarity index 99% rename from hub/src/main/kotlin/org/sourcegrade/lab/hub/queries/CoursEndpoints.kt rename to hub/src/main/kotlin/org/sourcegrade/lab/hub/query/CoursEndpoints.kt index 4dabaef..122b559 100644 --- a/hub/src/main/kotlin/org/sourcegrade/lab/hub/queries/CoursEndpoints.kt +++ b/hub/src/main/kotlin/org/sourcegrade/lab/hub/query/CoursEndpoints.kt @@ -1,4 +1,4 @@ -package org.sourcegrade.lab.hub.queries +package org.sourcegrade.lab.hub.query import com.expediagroup.graphql.generator.annotations.GraphQLDescription import com.expediagroup.graphql.generator.annotations.GraphQLName diff --git a/hub/src/main/kotlin/org/sourcegrade/lab/hub/queries/HelloWorldQuery.kt b/hub/src/main/kotlin/org/sourcegrade/lab/hub/query/HelloWorldQuery.kt similarity index 80% rename from hub/src/main/kotlin/org/sourcegrade/lab/hub/queries/HelloWorldQuery.kt rename to hub/src/main/kotlin/org/sourcegrade/lab/hub/query/HelloWorldQuery.kt index e78a596..c178fdf 100644 --- a/hub/src/main/kotlin/org/sourcegrade/lab/hub/queries/HelloWorldQuery.kt +++ b/hub/src/main/kotlin/org/sourcegrade/lab/hub/query/HelloWorldQuery.kt @@ -1,4 +1,4 @@ -package org.sourcegrade.lab.hub.queries +package org.sourcegrade.lab.hub.query import com.expediagroup.graphql.server.operations.Query diff --git a/hub/src/main/kotlin/org/sourcegrade/lab/hub/queries/UserEndpoints.kt b/hub/src/main/kotlin/org/sourcegrade/lab/hub/query/UserEndpoints.kt similarity index 99% rename from hub/src/main/kotlin/org/sourcegrade/lab/hub/queries/UserEndpoints.kt rename to hub/src/main/kotlin/org/sourcegrade/lab/hub/query/UserEndpoints.kt index fd5295f..6b6e530 100644 --- a/hub/src/main/kotlin/org/sourcegrade/lab/hub/queries/UserEndpoints.kt +++ b/hub/src/main/kotlin/org/sourcegrade/lab/hub/query/UserEndpoints.kt @@ -1,4 +1,4 @@ -package org.sourcegrade.lab.hub.queries +package org.sourcegrade.lab.hub.query import com.expediagroup.graphql.generator.annotations.GraphQLDescription import com.expediagroup.graphql.generator.annotations.GraphQLName diff --git a/hub/ui/.eslintrc.js b/hub/ui/.eslintrc.js index 0e5e86f..b0fa46c 100644 --- a/hub/ui/.eslintrc.js +++ b/hub/ui/.eslintrc.js @@ -1,3 +1,3 @@ module.exports = { - extends: ["custom/next"], + extends: ["@repo/eslint-config-custom/next"], }; diff --git a/hub/ui/.gitignore b/hub/ui/.gitignore index 1437c53..b05328a 100644 --- a/hub/ui/.gitignore +++ b/hub/ui/.gitignore @@ -32,3 +32,6 @@ yarn-error.log* # vercel .vercel + +# appollo Client +__generated__/ diff --git a/hub/ui/app/courses/[slug]/assignments/page.tsx b/hub/ui/app/courses/[slug]/assignments/page.tsx new file mode 100644 index 0000000..71c63e1 --- /dev/null +++ b/hub/ui/app/courses/[slug]/assignments/page.tsx @@ -0,0 +1,145 @@ +"use client"; + +import type { Dayjs } from "dayjs"; +import dayjs from "dayjs"; +import Box from "@mui/material/Box"; +import { DatePicker } from "@mui/x-date-pickers/DatePicker"; +import React from "react"; +import type { AssignmentDto } from "@repo/ui/components/assignments-view"; +import AssignmentsView from "@repo/ui/components/assignments-view"; + +export default function Page({ params }: { params: { slug: string } }) { + const [demoDate, setDemoDate] = React.useState<Dayjs | null>(dayjs("2024-01-01")); + const dummyAssignments: AssignmentDto[] = [ + { + id: "00", + title: "Hands on mit Java & FopBot", + dueDate: "2023-10-27 23:50", + maxPoints: 5, + actualPoints: 5, + status: "graded", + }, + { + id: "01", + title: "Foreign Contaminants", + dueDate: "2023-11-03 23:50", + maxPoints: 16, + actualPoints: 10, + status: "graded", + }, + { + id: "02", + title: "Cleaning Convoy", + dueDate: "2023-11-10 23:50", + maxPoints: 32, + actualPoints: 15, + status: "graded", + }, + { + id: "03", + title: "Multi-Family Robots & Synchronizers", + dueDate: "2023-11-17 23:50", + maxPoints: 32, + actualPoints: 0, + status: "not submitted", + }, + { + id: "04", + title: "Die Übung mit der Maus", + dueDate: "2023-11-24 23:50", + maxPoints: 32, + actualPoints: 0, + status: "plagiarized", + }, + { + id: "05", + title: "EDV-Zoo", + dueDate: "2023-12-01 23:50", + maxPoints: 32, + actualPoints: 0, + status: "graded", + }, + { + id: "06", + title: "Maze Runner", + dueDate: "2023-12-08 23:50", + maxPoints: 32, + actualPoints: 15, + status: "submitted", + }, + { + id: "07", + title: "Ausdrucksbaum", + dueDate: "2023-12-15 23:50", + maxPoints: 32, + actualPoints: 32, + status: "graded", + }, + { + id: "08", + title: "The Exceptionals - State of Emergency", + dueDate: "2024-01-05 23:50", + maxPoints: 32, + actualPoints: 30, + status: "graded", + }, + { + id: "09", + title: "Generics", + dueDate: "2024-01-12 23:50", + maxPoints: 32, + actualPoints: 26, + status: "graded", + }, + { + id: "10", + title: "Mengen", + dueDate: "2024-01-19 23:50", + maxPoints: 32, + actualPoints: 32, + status: "graded", + }, + { + id: "11", + title: "Running a Business", + dueDate: "2024-01-31 23:50", + maxPoints: 32, + actualPoints: 0, + status: "not submitted", + }, + { + id: "12", + title: "Automaten parsen", + dueDate: "2024-02-07 23:50", + maxPoints: 32, + actualPoints: 0, + status: "not submitted", + }, + { + id: "13", + title: "Codecraft", + dueDate: "2024-02-14 23:50", + maxPoints: 32, + actualPoints: 0, + status: "not submitted", + }, + { + "id": "99", + "title": "Die Siedler von Catan", + "dueDate": "2024-03-15 23:50", + "maxPoints": 32, + "actualPoints": 0, + "status": "submitted", + }, + ]; + return ( + <Box sx={{ width: "100%" }}> + <p>Select Current Date:</p> + <DatePicker + onChange={(newValue) => { setDemoDate(newValue); }} + value={demoDate} + /> + <AssignmentsView assignments={dummyAssignments} demoDate={demoDate}/> + </Box> + ); +} diff --git a/hub/ui/app/courses/[slug]/layout.tsx b/hub/ui/app/courses/[slug]/layout.tsx new file mode 100644 index 0000000..677a681 --- /dev/null +++ b/hub/ui/app/courses/[slug]/layout.tsx @@ -0,0 +1,91 @@ +"use client"; + +import type { TypedDocumentNode } from "@graphql-typed-document-node/core"; +import Tabs from "@mui/material/Tabs"; +import Tab from "@mui/material/Tab"; +import Box from "@mui/material/Box"; +import gql from "graphql-tag"; +import { client } from "@repo/ui/lib/with-apollo"; +import { useQuery } from "@apollo/client"; +import React, { useState } from "react"; +import { usePathname } from "next/navigation"; +import type { CourseDto } from "lab-hub/app/__generated__/graphql"; + +const query: TypedDocumentNode<{ course: { fetch: CourseDto }, variables: { slug: string } }> += gql` + query fetchAllCourses($slug: String!) { + course(id: $slug) { + fetch { + id + name + description + semesterType + semesterStartYear + members { + id + username + email + } + } + } + } +`; + +interface LinkTabProps { + label?: string; + href?: string; + selected?: boolean; +} + +function LinkTab(props: LinkTabProps) { + console.log("LinkTab", props) + return ( + <Tab + component="a" + onClick={(event: React.MouseEvent<HTMLAnchorElement>) => { + }} + {...props} + /> + ); +} + +let courseData: CourseDto | undefined; + +export default function CourseLayout( + { + children, + params, + }: { + children: React.ReactNode + params: { slug: string } + }) { + const { loading, data } = useQuery( + query, + { + client, variables: { slug: params.slug }, + }); + courseData = data?.course.fetch; + const links:LinkTabProps[] = [ + { href: `/courses/${params.slug}`, label: "Overview" }, + { href: `/courses/${params.slug}/members`, label: "Members" }, + { href: `/courses/${params.slug}/assignments`, label: "Assignments" }, + ]; + const path = usePathname(); + return ( + <div> + <h1>{courseData?.name ?? "Loading course title..."}</h1> + <Box sx={{ width: "100%" }}> + <Tabs + aria-label="course tabs" + centered + value={links.findIndex((link) => link.href === path)} + > + <LinkTab {...links[0]} /> + <LinkTab {...links[1]} /> + <LinkTab {...links[2]} /> + </Tabs> + </Box> + <div>{children}</div> + </div> + ); +} diff --git a/hub/ui/app/courses/[slug]/members/page.tsx b/hub/ui/app/courses/[slug]/members/page.tsx new file mode 100644 index 0000000..4b265e0 --- /dev/null +++ b/hub/ui/app/courses/[slug]/members/page.tsx @@ -0,0 +1,72 @@ +"use client"; + +import type { TypedDocumentNode } from "@graphql-typed-document-node/core"; +import { + MaterialReactTable, + useMaterialReactTable, + type MRT_ColumnDef, // <--- import MRT_ColumnDef +} from "material-react-table"; +import Box from "@mui/material/Box"; +import gql from "graphql-tag"; +import { useQuery } from "@apollo/client"; +import { client } from "@repo/ui/lib/with-apollo"; +import type { UserDto } from "lab-hub/app/__generated__/graphql"; + +const query: TypedDocumentNode<{ course: { fetch: { members: UserDto[]}}, variables: { slug: string } }> += gql` + query fetchAllCourses($slug: String!) { + course(id: $slug) { + fetch { + members { + id + username + email + } + } + } + } +`; +export default function Page({ params }: { params: { slug: string } }) { + const { loading, data } = useQuery( + query, + { + client, variables: { slug: params.slug }, + }); + const courseData = data?.course.fetch; + console.log("courses", courseData) + + // material react table + const columns: MRT_ColumnDef<UserDto>[] = [ + { + accessorKey: "id", + header: "ID", + enableSorting: false, + }, + { + accessorKey: "username", + header: "Username", + }, + { + accessorKey: "email", + header: "Email", + }, + ]; + const table = useMaterialReactTable({ + columns, + data: courseData?.members?? [], + initialState: { columnVisibility: { id: false } }, + enableColumnResizing: true, + enableStickyHeader: true, + enableRowSelection: true, + enableColumnOrdering: true, + rowPinningDisplayMode: "select-sticky", + getRowId: (row) => row.id, + layoutMode: "grid", + // columnResizeMode: "onEnd", + }); + return ( + <Box sx={{ width: "100%" }}> + <MaterialReactTable table={table} /> + </Box> + ); +} diff --git a/hub/ui/app/courses/[slug]/page.tsx b/hub/ui/app/courses/[slug]/page.tsx new file mode 100644 index 0000000..a076e7d --- /dev/null +++ b/hub/ui/app/courses/[slug]/page.tsx @@ -0,0 +1,58 @@ +"use client"; + +import type { TypedDocumentNode } from "@graphql-typed-document-node/core"; +import gql from "graphql-tag"; +import { useQuery } from "@apollo/client"; +import { client } from "@repo/ui/lib/with-apollo"; +import type { CourseDto } from "lab-hub/app/__generated__/graphql"; +import { SemesterType } from "lab-hub/app/__generated__/graphql"; + +const query: TypedDocumentNode<{ course: { fetch: CourseDto }, variables: { slug: string } }> += gql` + query fetchAllCourses($slug: String!) { + course(id: $slug) { + fetch { + id + name + description + semesterType + semesterStartYear + members { + id + username + email + } + } + } + } +`; +export default function Page({ params }: { params: { slug: string } }) { + const { loading, data } = useQuery( + query, + { + client, variables: { slug: params.slug }, + }); + const courseData = data?.course.fetch; + console.log("courses", courseData) + return ( + <div> + <h2>Semester</h2> + <p>{ + courseData + ? `${courseData.semesterType === SemesterType.Ss + ? "Sommersemester" + : "Wintersemester" + } ${courseData.semesterType === SemesterType.Ws + ? `${courseData.semesterStartYear}/${courseData.semesterStartYear + 1}` + : courseData.semesterStartYear + }` + : "Loading semester..." + }</p> + <h2>Description</h2> + <p>{courseData?.description ?? "Loading description..."}</p> + Tabs for overview, members, and assignments + <h2>Upcomming Assignments</h2> + <p>TODO</p> + </div> + ); +} diff --git a/hub/ui/app/courses/page.tsx b/hub/ui/app/courses/page.tsx new file mode 100644 index 0000000..d046d80 --- /dev/null +++ b/hub/ui/app/courses/page.tsx @@ -0,0 +1,8 @@ +export default function Page() { + return ( + <div> + <h1>Courses</h1> + <p>TODO</p> + </div> + ); +} diff --git a/hub/ui/app/globals.css b/hub/ui/app/globals.css index 8eee6cb..6a6d774 100644 --- a/hub/ui/app/globals.css +++ b/hub/ui/app/globals.css @@ -1,37 +1,19 @@ -:root { - --max-width: 1100px; - --border-radius: 12px; - --font-mono: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", - "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", - "Fira Mono", "Droid Sans Mono", "Courier New", monospace; - - --foreground-rgb: 255, 255, 255; - --background-start-rgb: 0, 0, 0; - --background-end-rgb: 0, 0, 0; - - --callout-rgb: 20, 20, 20; - --callout-border-rgb: 108, 108, 108; - --card-rgb: 100, 100, 100; - --card-border-rgb: 200, 200, 200; - - --glow-conic: conic-gradient( - from 180deg at 50% 50%, - #2a8af6 0deg, - #a853ba 180deg, - #e92a67 360deg - ); -} +@tailwind base; +@tailwind components; +@tailwind utilities; -* { - box-sizing: border-box; - padding: 0; - margin: 0; +:root { + --foreground-rgb: 0, 0, 0; + --background-start-rgb: 214, 219, 220; + --background-end-rgb: 255, 255, 255; } -html, -body { - max-width: 100vw; - overflow-x: hidden; +@media (prefers-color-scheme: dark) { + :root { + --foreground-rgb: 255, 255, 255; + --background-start-rgb: 0, 0, 0; + --background-end-rgb: 0, 0, 0; + } } body { @@ -44,7 +26,10 @@ body { rgb(var(--background-start-rgb)); } -a { - color: inherit; - text-decoration: none; +h1 { + @apply text-4xl font-bold py-6; +} + +h2 { + @apply text-3xl font-bold py-4; } diff --git a/hub/ui/app/imprint/page.tsx b/hub/ui/app/imprint/page.tsx new file mode 100644 index 0000000..d3f875b --- /dev/null +++ b/hub/ui/app/imprint/page.tsx @@ -0,0 +1,8 @@ +export default function Page() { + return ( + <div> + <h1>Imprint</h1> + <p>TODO</p> + </div> + ); +} diff --git a/hub/ui/app/jobs/page.tsx b/hub/ui/app/jobs/page.tsx new file mode 100644 index 0000000..973018a --- /dev/null +++ b/hub/ui/app/jobs/page.tsx @@ -0,0 +1,8 @@ +export default function Page() { + return ( + <div> + <h1>Grading Jobs</h1> + <p>TODO</p> + </div> + ); +} diff --git a/hub/ui/app/layout.tsx b/hub/ui/app/layout.tsx index 5f90d11..f19cd68 100644 --- a/hub/ui/app/layout.tsx +++ b/hub/ui/app/layout.tsx @@ -1,22 +1,40 @@ +import Navbar from "@repo/ui/components/navbar"; import "./globals.css"; import type { Metadata } from "next"; import { Inter } from "next/font/google"; +import Footer from "@repo/ui/components/footer"; +import { Box, Container, CssBaseline } from "@mui/material"; +import ThemeRegistry from "@repo/ui/lib/theme-registry"; const inter = Inter({ subsets: ["latin"] }); export const metadata: Metadata = { - title: "Create Turborepo", - description: "Generated by create turbo", + title: "YouGrade", + description: "A tool to grade automatically grade submissions", }; export default function RootLayout({ - children, + children, }: { - children: React.ReactNode; -}): JSX.Element { - return ( - <html lang="en"> - <body className={inter.className}>{children}</body> - </html> - ); + children: React.ReactNode; +}) { + return ( + <html lang="en"> + <body className={inter.className}> + <ThemeRegistry options={{ key: "mui" }}> + <div className="flex flex-col min-h-screen bg-gray-900"> + <CssBaseline /> + <Navbar /> + {/* <Showcase /> */} + <Container disableGutters maxWidth={false}> + <main className="bg-gray-800 w-[90vw] max-w-7xl mx-auto px-4 py-3 my-6 sm:px-6 lg:px-8 flex-grow rounded min-h-[80vh]"> + {children} + </main> + </Container> + <Footer /> + </div> + </ThemeRegistry> + </body> + </html> + ); } diff --git a/hub/ui/app/page.tsx b/hub/ui/app/page.tsx index a8737e6..79759ce 100644 --- a/hub/ui/app/page.tsx +++ b/hub/ui/app/page.tsx @@ -1,136 +1,46 @@ -import Image from "next/image"; -import { Card } from "@repo/ui"; -import styles from "./page.module.css"; +import Button from "@mui/material/Button"; +import AddCircleIcon from "@mui/icons-material/AddCircle"; +import GradingIcon from "@mui/icons-material/Grading"; +import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted"; +import type { CardContent } from "@repo/ui/components/card-carousel"; +import CardCarousel from "@repo/ui/components/card-carousel"; +import Courses from "@repo/ui/components/courses"; +import _ from "lodash"; -function Gradient({ - conic, - className, - small, -}: { - small?: boolean; - conic?: boolean; - className?: string; -}): JSX.Element { - return ( - <span - className={[ - styles.gradient, - conic ? styles.glowConic : undefined, - small ? styles.gradientSmall : styles.gradientLarge, - className, - ] - .filter(Boolean) - .join(" ")} - /> - ); -} - -const LINKS = [ - { - title: "Docs", - href: "https://turbo.build/repo/docs", - description: "Find in-depth information about Turborepo features and API.", - }, - { - title: "Learn", - href: "https://turbo.build/repo/docs/handbook", - description: "Learn more about monorepos with our handbook.", - }, - { - title: "Templates", - href: "https://turbo.build/repo/docs/getting-started/from-example", - description: "Choose from over 15 examples and deploy with a single click.", - }, - { - title: "Deploy", - href: "https://vercel.com/new", - description: - " Instantly deploy your Turborepo to a shareable URL with Vercel.", - }, -]; +const demoAssignments: CardContent[] = _.range(10).map((i) => ({ + title: `Assignment ${i}`, + description: `Description ${i}`, + image: "https://www.pngkit.com/png/full/143-1435748_qb-d-grade-d-grade-png.png", + openButtonEnabled: false, + settingsButtonEnabled: false, +})); -export default function Page(): JSX.Element { - return ( - <main className={styles.main}> - <div className={styles.description}> - <p> - examples/basic  - <code className={styles.code}>web</code> - </p> +export default function Page() { + return ( <div> - <a - href="https://vercel.com?utm_source=create-turbo&utm_medium=basic&utm_campaign=create-turbo" - rel="noopener noreferrer" - target="_blank" - > - By{" "} - <Image - alt="Vercel Logo" - className={styles.vercelLogo} - height={24} - priority - src="/vercel.svg" - width={100} - /> - </a> - </div> - </div> - - <div className={styles.hero}> - <div className={styles.heroContent}> - <div className={styles.logos}> - <div className={styles.circles}> - <Image - alt="Turborepo" - height={614} - src="circles.svg" - width={614} - /> + <div className="flex justify-center space-x-6 p-3 rounded-md bg-slate-700"> + <Button className="flex-col flex-grow" href="/rubrics" variant="contained"> + <GradingIcon className="!text-5xl"/> + View Test Rubric + </Button> </div> - <div className={styles.logoGradientContainer}> - <Gradient className={styles.logoGradient} conic small /> + <h1>Dashboard</h1> + <div className="flex justify-center space-x-6 p-3 rounded-md bg-slate-700"> + <Button className="flex-col flex-grow" disabled variant="contained"> + <AddCircleIcon className="!text-5xl"/> + Create new Course + </Button> + <Button className="flex-col flex-grow" disabled variant="contained"> + <FormatListBulletedIcon className="!text-5xl"/> + Show my Courses + </Button> </div> - - <div className={styles.logo}> - <Image - alt="" - height={120} - priority - src="turborepo.svg" - width={120} - /> - </div> - </div> - <Gradient className={styles.backgroundGradient} conic /> - <div className={styles.turborepoWordmarkContainer}> - <svg - className={styles.turborepoWordmark} - viewBox="0 0 506 50" - width={200} - xmlns="http://www.w3.org/2000/svg" - > - <title>Turborepo logo - - - - - - - - - - -

+

Recent Courses

+ +

Recent Assignments

+
- - -
- {LINKS.map(({ title, href, description }) => ( - - {description} - - ))} -
- - ); + ); } + +// export default withApollo(Page, { getDataFromTree }); diff --git a/hub/ui/app/rubrics/example_rubric.json b/hub/ui/app/rubrics/example_rubric.json new file mode 100644 index 0000000..ebedbab --- /dev/null +++ b/hub/ui/app/rubrics/example_rubric.json @@ -0,0 +1,284 @@ +{ + "id": "example_rubric", + "name": { + "text": "H01 | Foreign Contaminants", + "html": "H01
Foreign Contaminants" + }, + "description": { + "text": "In dieser Hausübung mussten Sie die Bewegung von mehreren Robotern mithilfe der FOPBot-Bibliothek implementieren.", + "html": "In dieser Hausübung mussten Sie die Bewegung von mehreren Robotern mithilfe der FOPBot-Bibliothek implementieren." + }, + "possiblePoints": { + "min": 0, + "max": 16 + }, + "achievedPoints": { + "min": 7, + "max": 10 + }, + "criteria": [ + { + "id": "1", + "name": { + "text": "H1 | Steuerung des \"CleaningRobot\"", + "html": "H1 Steuerung des \"CleaningRobot\"" + }, + "description": { + "text": "In dieser Teilaufgabe mussten Sie die Steuerung des \"CleaningRobot\" implementieren.", + "html": null + }, + "possiblePoints": { + "min": 0, + "max": 4 + }, + "achievedPoints": { + "min": 1, + "max": 1 + }, + "tests": [ + { + "id": "1", + "name": "fafafa", + "state": "FAILURE", + "message": { + "text": "fififi", + "html": null + }, + "stacktrace": null, + "duration": 0 + },{ + "id": "2", + "name": "Wenn die übergebene Direction >= 0 und < 4 ist, dann schaut der Roboter in die entsprechende Richtung.", + "state": "FAILURE", + "message": { + "text": "The robot should face LEFT.", + "html": null + }, + "stacktrace": null, + "duration": 0 + }, + { + "id": "3", + "name": "Wenn die übergebene Direction >= 0 und < 4 ist, so bewegt sich der Roboter in die entsprechende Richtung, falls der Weg frei ist.", + "state": "FAILURE", + "message": { + "text": "invalid end position.", + "html": null + }, + "stacktrace": null, + "duration": 0 + }, + { + "id": "4", + "name": "shouldPutCoin und shouldPickCoin werden korrekt verarbeitet.", + "state": "SUCCESS", + "message": { + "text": "", + "html": null + }, + "stacktrace": "The Method threw an exception {
worldWidth = 1,
worldHeight = 1,
walls = [],
initialCoinsOnField = 10,
canMove = false,
contaminant1 (before call) = Robot{id='0', at=[0/0], numberOfCoins=10, direction=LEFT},
contaminant1 (after call) = Robot{id='0', at=[0/0], numberOfCoins=5, direction=LEFT}
}

java.lang.IllegalArgumentException: bound must be positive
\tjava.base/java.util.Random.nextInt(Random.java:322)
\th01.Contaminant1Test.lambda$testMovement$1(Contaminant1Test.java:105)
\th01.TestUtils.lambda$withMockedUtilsClass$2(TestUtils.java:141)
\torg.mockito.internal.stubbing.StubbedInvocationMatcher.answer(StubbedInvocationMatcher.java:42)
\torg.mockito.internal.handler.MockHandlerImpl.handle(MockHandlerImpl.java:103)
\torg.mockito.internal.handler.NullResultGuardian.handle(NullResultGuardian.java:29)
\torg.mockito.internal.handler.InvocationNotifierHandler.handle(InvocationNotifierHandler.java:34)
\torg.mockito.internal.creation.bytebuddy.MockMethodInterceptor.doIntercept(MockMethodInterceptor.java:82)
\torg.mockito.internal.creation.bytebuddy.MockMethodAdvice.handleStatic(MockMethodAdvice.java:148)
\th01.template.Utils.getRandomInteger(Unknown Source)
...
", + "duration": 0 + } + ] + }, + { + "id": "H2", + "name": { + "text": "Steuerung der \"Contaminant\"-Roboter", + "html": null + }, + "description": { + "text": "", + "html": null + }, + "possiblePoints": { + "min": 0, + "max": 8 + }, + "achievedPoints": { + "min": 3, + "max": 3 + }, + "tests": [ + { + "id": "5", + "name": "Wenn der \"Contaminant1\"-Roboter keine Münzen mehr hat, wird er ausgeschaltet und die Methode doMove() führt keine weiteren Aktionen aus.", + "state": "FAILURE", + "message": { + "text": "The robot should be turned off.", + "html": null + }, + "stacktrace": "java.lang.IllegalArgumentException: bound must be positive\n...\n", + "duration": 0 + }, + { + "id": "6", + "name": "Der \"Contaminant1\"-Roboter legt korrekt eine zufällige Anzahl an Münzen ab, genau dann wenn er es soll.", + "state": "SUCCESS", + "message": { + "text": "", + "html": null + }, + "stacktrace": null, + "duration": 0 + }, + { + "id": "7", + "name": "Der \"Contaminant1\"-Roboter prüft alle vier Richtungen ab und dreht sich wieder in die Ausgangsrichtung.", + "state": "FAILURE", + "message": { + "text": "The Method threw an exception\njava.lang.IllegalArgumentException: bound must be positive\n...", + "html": null + }, + "stacktrace": "java.lang.IllegalArgumentException: bound must be positive\n...\n", + "duration": 0 + }, + { + "id": "8", + "name": "Der \"Contaminant1\"-Roboter bewegt sich korrekt in eine zufällige Richtung, die frei ist, falls eine solche existiert.", + "state": "FAILURE", + "message": { + "text": "The Method threw an exception\njava.lang.IllegalArgumentException: bound must be positive\n...", + "html": null + }, + "stacktrace": "java.lang.IllegalArgumentException: bound must be positive\n...\n", + "duration": 0 + }, + { + "id": "9", + "name": "Wenn der \"Contaminant2\"-Roboter keine Münzen mehr hat, wird er ausgeschaltet und die Methode doMove() führt keine weiteren Aktionen aus.", + "state": "FAILURE", + "message": { + "text": "The robot should be turned off.", + "html": null + }, + "stacktrace": null, + "duration": 0 + }, + { + "id": "10", + "name": "Der \"Contaminant2\"-Roboter legt stets die korrekte Anzahl an Münzen ab.", + "state": "SUCCESS", + "message": { + "text": "", + "html": null + }, + "stacktrace": null, + "duration": 0 + }, + { + "id": "11", + "name": "Der \"Contaminant2\"-Roboter prüft alle vier Richtungen ab und dreht sich wieder in die Ausgangsrichtung.", + "state": "SUCCESS", + "message": { + "text": "", + "html": null + }, + "stacktrace": null, + "duration": 0 + }, + { + "id": "12", + "name": "Der \"Contaminant2\"-Roboter bewegt sich korrekt nach dem Bewegungsschema.", + "state": "FAILURE", + "message": { + "text": "invalid end position.", + "html": null + }, + "stacktrace": null, + "duration": 0 + } + ] + }, + { + "id": "H3", + "name": { + "text": "Gewinnbedingungen", + "html": null + }, + "description": { + "text": "", + "html": null + }, + "possiblePoints": { + "min": 0, + "max": 4 + }, + "achievedPoints": { + "min": 3, + "max": 3 + }, + "tests": [ + { + "id": "13", + "name": "Der cleaner gewinnt, wenn alle Contaminants ausgeschaltet sind.", + "state": "SUCCESS", + "message": { + "text": "", + "html": null + }, + "stacktrace": null, + "duration": 0 + }, + { + "id": "14", + "name": "Der cleaner gewinnt, wenn sich in der Abladezone mindestens 200 Münzen befinden.", + "state": "SUCCESS", + "message": { + "text": "", + "html": null + }, + "stacktrace": null, + "duration": 0 + }, + { + "id": "15", + "name": "Die Contaminants gewinnen, wenn mindestens 50% der Felder mit münzen bedeckt sind.", + "state": "FAILURE", + "message": { + "text": "The winner is not correct", + "html": null + }, + "stacktrace": null, + "duration": 0 + }, + { + "id": "16", + "name": "Wenn beide Parteien gleichzeitig gewinnen, so gewinnt der cleaner.", + "state": "SUCCESS", + "message": { + "text": "", + "html": null + }, + "stacktrace": null, + "duration": 0 + } + ], + "children": [ + { + "id": "H3.1", + "name": { + "text": "H3.1 | TEST", + "html": "H3.1 | TEST" + }, + "description": { + "text": "asdf", + "html": null + }, + "possiblePoints": { + "min": 0, + "max": 4 + }, + "achievedPoints": { + "min": 3, + "max": 3 + }, + "message": { + "text": "asdf", + "html": null + } + } + ] + } + ] +} diff --git a/hub/ui/app/rubrics/page.tsx b/hub/ui/app/rubrics/page.tsx new file mode 100644 index 0000000..ccefdcb --- /dev/null +++ b/hub/ui/app/rubrics/page.tsx @@ -0,0 +1,128 @@ +"use client"; +import { Box, Grid, Paper, Typography } from "@mui/material"; +import SwipeableViews from "react-swipeable-views"; +import * as React from "react"; +import { useState } from "react"; +import TabContext from "@mui/lab/TabContext"; +import Tab from "@mui/material/Tab"; +import TabList from "@mui/lab/TabList"; +import RubricView from "@repo/ui/components/rubric-view"; +import { useTheme } from "@mui/material/styles"; +import TabPanel from "@mui/lab/TabPanel"; +import * as rubric from "./example_rubric.json"; + +function RubricPage() { + const theme = useTheme(); + // const [rubric, setRubric] = useState(null); + // + // useEffect(() => { + // const fetchRubric = async () => { + // try { + // const response = await fetch('./example_rubric.json'); + // const data = await response.json(); + // setRubric(data); + // } catch (error) { + // console.error('Error fetching rubric:', error); + // } + // }; + // + // fetchRubric(); + // }, []); + const [tab, setTab] = useState("0"); + return ( +
+ + + { setTab(newValue); }} + > + + + + + + +
+ + {/**/} + {/* */} + {/* */} + {/* Rubric: {rubric.name.text}*/} + {/* */} + {/* */} + {/* */} + {/* */} + {/* {rubric.description.text}*/} + {/* */} + {/* */} + {/* */} + {/* {rubric.criteria.map((criterion: any, index: number) => (*/} + {/* */} + {/* {criterion?.name?.text}*/} + {/* {criterion?.description?.text}*/} + {/* */} + {/* Possible*/} + {/* Points: {criterion?.possible_points?.min} - {criterion?.possible_points?.max}*/} + {/* */} + {/* Achieved*/} + {/* Points: {criterion?.achieved_points?.single}*/} + {/* /!* Render tests if present *!/*/} + {/* {criterion?.tests?.length > 0 && (*/} + {/* <>*/} + {/* Tests:*/} + {/* {criterion.tests.map((test: any, idx: number) => (*/} + {/* */} + {/* Test {idx + 1}: {test?.name}*/} + {/* State: {test?.state}*/} + {/* Message: {test?.message?.text}*/} + {/* Duration: {test?.duration} ms*/} + {/* */} + {/* ))}*/} + {/* */} + {/* )}*/} + {/* */} + {/* ))}*/} + {/* */} + {/**/} +
+
+ +
+ + + + Tests + + + +
+
+ { setTab(index); }} + > + + +
+ + + + Source Code + + + +
+
+
+
+
+ ); +} + +export default RubricPage; diff --git a/hub/ui/codegen.ts b/hub/ui/codegen.ts new file mode 100644 index 0000000..9caf1b5 --- /dev/null +++ b/hub/ui/codegen.ts @@ -0,0 +1,19 @@ +import { CodegenConfig } from '@graphql-codegen/cli'; + +const config: CodegenConfig = { + schema: 'http://localhost:8080/graphql', + // this assumes that all your source files are in a top-level `src/` directory - you might need to adjust this to your file structure + documents: ['app/**/*.{ts,tsx}'], + generates: { + './app/__generated__/': { + preset: 'client', + plugins: [], + presetConfig: { + gqlTagName: 'gql', + } + } + }, + ignoreNoDocuments: true, +}; + +export default config; diff --git a/hub/ui/graphql.config.yml b/hub/ui/graphql.config.yml new file mode 100644 index 0000000..5e26dab --- /dev/null +++ b/hub/ui/graphql.config.yml @@ -0,0 +1,2 @@ +schema: http://localhost:8080/graphql +documents: '**/*.graphql' diff --git a/hub/ui/next.config.js b/hub/ui/next.config.js index fdda6aa..03af5a8 100644 --- a/hub/ui/next.config.js +++ b/hub/ui/next.config.js @@ -1,4 +1,38 @@ +/** @type {import('next').NextConfig} */ module.exports = { reactStrictMode: true, - transpilePackages: ["ui"], + transpilePackages: ["@repo/ui"], + async headers() { + return [ + { + source: '/(.*)', + headers: [ + { + key: 'Access-Control-Allow-Origin', + value: '*', + }, + { + key: 'Access-Control-Allow-Credentials', + value: 'true', + }, + { + key: 'Access-Control-Allow-Methods', + value: 'GET,OPTIONS,PATCH,DELETE,POST,PUT', + }, + { + key: 'Access-Control-Allow-Headers', + value: 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version', + }, + { + key: 'Access-Control-Request-Headers', + value: 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version', + }, + { + key: 'Access-Control-Request-Method', + value: 'GET,OPTIONS,PATCH,DELETE,POST,PUT', + } + ], + }, + ] + } }; diff --git a/hub/ui/package.json b/hub/ui/package.json index d2eb2d6..5b15459 100644 --- a/hub/ui/package.json +++ b/hub/ui/package.json @@ -6,21 +6,35 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "compile": "graphql-codegen", + "watch": "graphql-codegen -w" }, "dependencies": { + "@apollo/client": "^3.9.5", + "@apollo/react-ssr": "^4.0.0", + "@repo/ui": "*", + "graphql": "^16.8.1", "next": "^13.4.19", + "next-with-apollo": "^5.3.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "@repo/ui": "*" + "sass": "^1.69.5" }, "devDependencies": { + "@graphql-codegen/cli": "^5.0.2", + "@graphql-codegen/client-preset": "^4.2.4", + "@graphql-typed-document-node/core": "^3.2.0", "@next/eslint-plugin-next": "^13.4.19", + "@repo/eslint-config-custom": "*", + "@repo/tsconfig": "*", "@types/node": "^17.0.12", "@types/react": "^18.0.22", "@types/react-dom": "^18.0.7", - "@repo/eslint-config-custom": "*", - "@repo/tsconfig": "*", - "typescript": "^4.5.3" + "autoprefixer": "^10.4.16", + "encoding": "^0.1.13", + "postcss": "^8.4.32", + "tailwindcss": "^3.4.0", + "typescript": "^5.3.3" } } diff --git a/hub/ui/postcss.config.js b/hub/ui/postcss.config.js new file mode 100644 index 0000000..07aa434 --- /dev/null +++ b/hub/ui/postcss.config.js @@ -0,0 +1,9 @@ +// If you want to use other PostCSS plugins, see the following: +// https://tailwindcss.com/docs/using-with-preprocessors + +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/hub/ui/tailwind.config.ts b/hub/ui/tailwind.config.ts new file mode 100644 index 0000000..d060947 --- /dev/null +++ b/hub/ui/tailwind.config.ts @@ -0,0 +1,2 @@ +import * as defaultConfig from '@repo/eslint-config-custom/tailwind.config'; +export default defaultConfig; diff --git a/hub/ui/tsconfig.json b/hub/ui/tsconfig.json index b4023e9..37a1527 100644 --- a/hub/ui/tsconfig.json +++ b/hub/ui/tsconfig.json @@ -3,6 +3,6 @@ "compilerOptions": { "plugins": [{ "name": "next" }] }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "postcss.config.js"], "exclude": ["node_modules"] } diff --git a/package.json b/package.json index 3223f7f..5400089 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,33 @@ "build": "turbo run build", "dev": "turbo run dev", "lint": "turbo run lint", - "format": "prettier --write \"**/*.{ts,tsx,md}\"" + "format": "prettier --write \"**/*.{ts,tsx,md}\"", + "compile": "turbo run compile", + "watch": "turbo run watch" + }, + "dependencies": { + "@devexpress/dx-core": "^4.0.8", + "@devexpress/dx-react-chart": "^4.0.8", + "@devexpress/dx-react-chart-material-ui": "^4.0.8", + "bootstrap": "^5.3.3", + "lodash": "^4.17.21", + "react-bootstrap": "^2.10.2" }, "devDependencies": { + "@graphql-codegen/cli": "^5.0.2", + "@graphql-codegen/client-preset": "^4.2.4", + "@graphql-typed-document-node/core": "^3.2.0", + "@types/lodash": "^4.14.202", + "autoprefixer": "^10.4.16", "eslint": "^8.48.0", + "graphql": "^16.8.1", + "next-transpile-modules": "^10.0.1", + "postcss": "^8.4.32", "prettier": "^3.0.3", + "tailwindcss": "^3.4.0", "tsconfig": "*", - "turbo": "latest" + "turbo": "latest", + "typescript": "^5.3.3" }, "name": "my-turborepo", "packageManager": "yarn@1.22.19", diff --git a/supervisor/ui/.eslintrc.js b/supervisor/ui/.eslintrc.js index 0e5e86f..b0fa46c 100644 --- a/supervisor/ui/.eslintrc.js +++ b/supervisor/ui/.eslintrc.js @@ -1,3 +1,3 @@ module.exports = { - extends: ["custom/next"], + extends: ["@repo/eslint-config-custom/next"], }; diff --git a/supervisor/ui/app/page.tsx b/supervisor/ui/app/page.tsx index f1d689b..2fc5829 100644 --- a/supervisor/ui/app/page.tsx +++ b/supervisor/ui/app/page.tsx @@ -1,5 +1,5 @@ import Image from "next/image"; -import { Card } from "@repo/ui"; +import { Card } from "@repo/ui/components"; import styles from "./page.module.css"; function Gradient({ diff --git a/supervisor/ui/next.config.js b/supervisor/ui/next.config.js index fdda6aa..6130ca4 100644 --- a/supervisor/ui/next.config.js +++ b/supervisor/ui/next.config.js @@ -1,4 +1,5 @@ +/** @type {import('next').NextConfig} */ module.exports = { reactStrictMode: true, - transpilePackages: ["ui"], + transpilePackages: ["@repo/ui"], }; diff --git a/tsconfig.json b/tsconfig.json index 6b7962d..1a7d443 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,3 +1,3 @@ { - "extends": "tsconfig/base.json" + "extends": "./common/tsconfig/base.json" } diff --git a/turbo.json b/turbo.json index ec4d016..b52178c 100644 --- a/turbo.json +++ b/turbo.json @@ -10,6 +10,8 @@ "dev": { "cache": false, "persistent": true - } + }, + "compile": {}, + "watch": {} } }