From 956b99b29a4a21179863a7e6ed0760f9f43b5c60 Mon Sep 17 00:00:00 2001 From: Valentin Morlock Date: Fri, 13 Sep 2024 16:12:20 +0200 Subject: [PATCH] implement search bar and video chapters --- .eslintrc.json | 5 +- app/(dashboard)/student.tsx | 6 +- .../media/[mediaId]/ContentMediaDisplay.tsx | 90 ++ .../[courseId]/media/[mediaId]/lecturer.tsx | 5 +- .../[courseId]/media/[mediaId]/student.tsx | 589 ++++++++----- app/courses/[courseId]/skills/student.tsx | 165 ++-- components/ItemFormSection.tsx | 348 ++++---- components/Navbar.tsx | 139 ++- components/PdfViewer.tsx | 57 +- components/RichTextEditor.tsx | 4 +- package.json | 5 +- pnpm-lock.yaml | 107 ++- src/schema.graphql | 790 +++++++++++------- 13 files changed, 1483 insertions(+), 827 deletions(-) create mode 100644 app/courses/[courseId]/media/[mediaId]/ContentMediaDisplay.tsx diff --git a/.eslintrc.json b/.eslintrc.json index 4d765f2..ed20e77 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,3 +1,6 @@ { - "extends": ["next/core-web-vitals", "prettier"] + "extends": ["next/core-web-vitals", "prettier"], + "rules": { + "@next/next/no-img-element": "off" + } } diff --git a/app/(dashboard)/student.tsx b/app/(dashboard)/student.tsx index 15fac50..04cd9f1 100644 --- a/app/(dashboard)/student.tsx +++ b/app/(dashboard)/student.tsx @@ -13,7 +13,7 @@ import { import dayjs from "dayjs"; import { chain } from "lodash"; import Link from "next/link"; -import { useState } from "react"; +import { Fragment, useState } from "react"; import { useLazyLoadQuery } from "react-relay"; import { graphql } from "relay-runtime"; @@ -103,7 +103,7 @@ export default function StudentPage() { ) .map(([key, courses]) => { return ( - <> + {key} @@ -118,7 +118,7 @@ export default function StudentPage() { /> ))} - + ); }) diff --git a/app/courses/[courseId]/media/[mediaId]/ContentMediaDisplay.tsx b/app/courses/[courseId]/media/[mediaId]/ContentMediaDisplay.tsx new file mode 100644 index 0000000..a8de06c --- /dev/null +++ b/app/courses/[courseId]/media/[mediaId]/ContentMediaDisplay.tsx @@ -0,0 +1,90 @@ +"use client"; +import { ContentMediaDisplayFragment$key } from "@/__generated__/ContentMediaDisplayFragment.graphql"; +import { ContentMediaDisplayVideoFragment$key } from "@/__generated__/ContentMediaDisplayVideoFragment.graphql"; +import { PdfViewer } from "@/components/PdfViewer"; +import { MediaPlayer, MediaProvider } from "@vidstack/react"; +import { + defaultLayoutIcons, + DefaultVideoLayout, +} from "@vidstack/react/player/layouts/default"; +import "@vidstack/react/player/styles/default/layouts/video.css"; +import "@vidstack/react/player/styles/default/theme.css"; +import { useFragment } from "react-relay"; +import { graphql } from "relay-runtime"; + +export function ContentMediaDisplay({ + _record, + onProgressChange, +}: { + _record: ContentMediaDisplayFragment$key; + onProgressChange: (fraction: number) => void; +}) { + const mediaRecord = useFragment( + graphql` + fragment ContentMediaDisplayFragment on MediaRecord { + type + name + downloadUrl + ...ContentMediaDisplayVideoFragment + } + `, + _record + ); + + switch (mediaRecord.type) { + case "VIDEO": + return ; + case "PRESENTATION": + case "DOCUMENT": + return ( + + ); + case "IMAGE": + // eslint-disable-next-line @next/next/no-img-element + return ( + {mediaRecord.name} + ); + default: + return <>Unsupported media type; + } +} + +export function VideoPlayer({ + _video, +}: { + _video: ContentMediaDisplayVideoFragment$key; +}) { + const mediaRecord = useFragment( + graphql` + fragment ContentMediaDisplayVideoFragment on MediaRecord { + type + name + downloadUrl + segments { + id + ... on VideoRecordSegment { + startTime + transcript + thumbnail + title + } + } + } + `, + _video + ); + + return ( + + + + + ); +} diff --git a/app/courses/[courseId]/media/[mediaId]/lecturer.tsx b/app/courses/[courseId]/media/[mediaId]/lecturer.tsx index c9838d7..0275ca5 100644 --- a/app/courses/[courseId]/media/[mediaId]/lecturer.tsx +++ b/app/courses/[courseId]/media/[mediaId]/lecturer.tsx @@ -12,7 +12,8 @@ import { Alert, Button, CircularProgress, Typography } from "@mui/material"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useState } from "react"; import { graphql, useLazyLoadQuery, useMutation } from "react-relay"; -import { ContentMediaDisplay, DownloadButton } from "./student"; +import { ContentMediaDisplay } from "./ContentMediaDisplay"; +import { DownloadButton } from "./student"; export default function LecturerMediaPage() { const { mediaId, courseId } = useParams(); @@ -33,7 +34,7 @@ export default function LecturerMediaPage() { mediaRecords { id name - ...studentContentMediaDisplayFragment + ...ContentMediaDisplayFragment ...studentContentDownloadButtonFragment } } diff --git a/app/courses/[courseId]/media/[mediaId]/student.tsx b/app/courses/[courseId]/media/[mediaId]/student.tsx index 2efd0fb..116d4bf 100644 --- a/app/courses/[courseId]/media/[mediaId]/student.tsx +++ b/app/courses/[courseId]/media/[mediaId]/student.tsx @@ -1,40 +1,54 @@ "use client"; +/* eslint-disable @next/next/no-img-element */ +import { + MediaPlayer, + MediaPlayerInstance, + MediaProvider, + Track, +} from "@vidstack/react"; +import { + defaultLayoutIcons, + DefaultVideoLayout, +} from "@vidstack/react/player/layouts/default"; +import "@vidstack/react/player/styles/default/layouts/video.css"; +import "@vidstack/react/player/styles/default/theme.css"; import { studentContentDownloadButtonFragment$key } from "@/__generated__/studentContentDownloadButtonFragment.graphql"; -import { studentContentMediaDisplayFragment$key } from "@/__generated__/studentContentMediaDisplayFragment.graphql"; +import { studentContentSideFragment$key } from "@/__generated__/studentContentSideFragment.graphql"; import { studentMediaLogProgressMutation } from "@/__generated__/studentMediaLogProgressMutation.graphql"; import { studentMediaQuery } from "@/__generated__/studentMediaQuery.graphql"; -import { MediaContentLink } from "@/components/content-link/MediaContentLink"; import { ContentTags } from "@/components/ContentTags"; import { Heading } from "@/components/Heading"; -import { DisplayError, PageError } from "@/components/PageError"; -import { PdfViewer } from "@/components/PdfViewer"; +import { PageError } from "@/components/PageError"; import { Check, Download } from "@mui/icons-material"; -import { - Alert, - Button, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - Typography, -} from "@mui/material"; +import { Alert, Button, MenuItem, Select } from "@mui/material"; +import "@vidstack/react/player/styles/default/layouts/video.css"; +import "@vidstack/react/player/styles/default/theme.css"; import { differenceInHours } from "date-fns"; -import { first } from "lodash"; -import { useParams, useSearchParams } from "next/navigation"; -import { useEffect, useState } from "react"; -import ReactPlayer from "react-player"; +import dayjs from "dayjs"; +import duration from "dayjs/plugin/duration"; +import { clamp } from "lodash"; + +dayjs.extend(duration); + +import { studentContentSideVideoFragment$key } from "@/__generated__/studentContentSideVideoFragment.graphql"; +import { studentMediaLogProgressVideoMutation } from "@/__generated__/studentMediaLogProgressVideoMutation.graphql"; +import { useParams } from "next/navigation"; + +import { MutableRefObject, RefObject, useRef, useState } from "react"; import { graphql, useFragment, useLazyLoadQuery, useMutation, } from "react-relay"; +import { ContentMediaDisplay } from "./ContentMediaDisplay"; export default function StudentMediaPage() { - const { mediaId, courseId } = useParams(); - const searchParams = useSearchParams(); - const media = useLazyLoadQuery( + const { mediaId } = useParams(); + const { + contentsByIds: [content], + } = useLazyLoadQuery( graphql` query studentMediaQuery($mediaId: UUID!) { contentsByIds(ids: [$mediaId]) { @@ -45,13 +59,10 @@ export default function StudentMediaPage() { } ... on MediaContent { mediaRecords { + ...studentContentSideFragment + ...studentContentSideVideoFragment + type id - name - ...studentContentMediaDisplayFragment - ...studentContentDownloadButtonFragment - userProgressData { - dateWorkedOn - } } } @@ -61,47 +72,19 @@ export default function StudentMediaPage() { `, { mediaId } ); - - const recordId = searchParams.get("recordId"); - - const content = media.contentsByIds[0]; - - const mainRecord = recordId - ? content?.mediaRecords?.find((record) => record.id === recordId) - : first(content?.mediaRecords ?? []); - - const [mediaRecordWorkedOn] = - useMutation(graphql` - mutation studentMediaLogProgressMutation($id: UUID!) { - logMediaRecordWorkedOn(mediaRecordId: $id) { - id - userProgressData { - dateWorkedOn - } - } - } - `); - - const workedOnToday = - Math.abs( - differenceInHours( - new Date(), - new Date(mainRecord?.userProgressData.dateWorkedOn ?? "") - ) - ) < 24; + const videoRef = useRef(null); const [nagDismissed, setNagDismissed] = useState(false); - useEffect(() => { - setNagDismissed(false); - setProgress(0); - }, [mainRecord?.id]); - const [progress, setProgress] = useState(0); const [error, setError] = useState(null); + const ref = useRef(null); + + const [selected, setSelected] = useState({ left: 0, right: 0 }); + const [splitPercentage, setSplitPercentage] = useState(50); // Initial split at 50% - if (media.contentsByIds.length == 0) { - return ; + if (!content) { + return ; } if (content.metadata.type !== "MEDIA") { @@ -109,7 +92,7 @@ export default function StudentMediaPage() { ); } - if (!content.mediaRecords) { + if (!content.mediaRecords?.length) { return ( - ); - } - - const relatedRecords = content.mediaRecords.filter( - (record) => record.id !== mainRecord?.id - ); + const videos = content.mediaRecords.filter((x) => x.type === "VIDEO"); + const documents = content.mediaRecords.filter((x) => x.type !== "VIDEO"); return (
: undefined - } + title={content.metadata.name} + overline={content.metadata.name} backButton /> @@ -155,134 +125,101 @@ export default function StudentMediaPage() { ))} - {mainRecord && ( - <> - 0.8 && !workedOnToday && !nagDismissed}> - Do you want to mark this as understood? - - You've completed more than 80% of this content - this could - be a good time to mark it as completed. - - - - - - -
- -
- + {/* TODO progress tracking */} + {/* 0.8 && !workedOnToday && !nagDismissed}> + Do you want to mark this as understood? + + You've completed more than 80% of this content - this could be a + good time to mark it as completed. + + + + + + */} + +
+ {videos.length > 0 && ( + + setSelected({ ...selected, left: val }) + } + /> + )} + {videos.length > 0 && documents.length > 0 && ( +
{ + const l = (e: MouseEvent) => { + const dimensions = ref.current?.getBoundingClientRect(); + if (!dimensions) return; + + e.stopPropagation(); + + setSplitPercentage( + clamp( + (100 * (e.screenX - dimensions.x)) / dimensions.width, + 20, + 70 + ) + ); + }; + + window.addEventListener("mousemove", l); + + window.onmouseup = () => + window.removeEventListener("mousemove", l); + }} + className="group w-full flex items-center justify-center cursor-col-resize" + > +
+
- - )} - {!mainRecord && } - - {relatedRecords.length > 0 && ( - <> - Related media -
- {relatedRecords.map((record) => ( - - ))} -
- - )} + )} + {documents.length > 0 && ( + + setSelected({ ...selected, left: val }) + } + /> + )} +
); } -export function ContentMediaDisplay({ - _record, - onProgressChange, -}: { - _record: studentContentMediaDisplayFragment$key; - onProgressChange: (fraction: number) => void; -}) { - const mediaRecord = useFragment( - graphql` - fragment studentContentMediaDisplayFragment on MediaRecord { - type - name - downloadUrl - } - `, - _record - ); - - const [duration, setDuration] = useState(); - - switch (mediaRecord.type) { - case "VIDEO": - return ( - { - setDuration(duration); - }} - onProgress={(progress) => { - if (duration) { - onProgressChange(progress.playedSeconds / duration); - } - }} - /> - ); - case "PRESENTATION": - case "DOCUMENT": - return ( - - ); - case "IMAGE": - // eslint-disable-next-line @next/next/no-img-element - return ( - {mediaRecord.name} - ); - default: - return <>Unsupported media type; - } -} - export function DownloadButton({ _record, }: { @@ -327,3 +264,253 @@ export function DownloadButton({ ); } + +function DocumentSide({ + _records, + selected, + setSelected, + setError, + videoRef, +}: { + _records: studentContentSideFragment$key; + selected: number; + setSelected: (val: number) => void; + setError: (err: any) => void; + videoRef: RefObject; +}) { + const [progress, setProgress] = useState(0); + + const mediaRecords = useFragment( + graphql` + fragment studentContentSideFragment on MediaRecord @relay(plural: true) { + id + name + downloadUrl + ...ContentMediaDisplayFragment + userProgressData { + dateWorkedOn + } + } + `, + _records + ); + const currentRecord = mediaRecords[selected]; + + const [mediaRecordWorkedOn] = + useMutation(graphql` + mutation studentMediaLogProgressMutation($id: UUID!) { + logMediaRecordWorkedOn(mediaRecordId: $id) { + id + } + } + `); + + const workedOnToday = + Math.abs( + differenceInHours( + new Date(), + new Date(currentRecord?.userProgressData.dateWorkedOn ?? "") + ) + ) < 24; + + return ( +
+ {(mediaRecords?.length ?? 0) > 1 && ( + + )} + + {currentRecord && ( + + )} + +
+ +
+
+ ); +} + +function VideoSide({ + _records, + selected, + setSelected, + setError, + videoRef, +}: { + _records: studentContentSideVideoFragment$key; + selected: number; + setSelected: (val: number) => void; + setError: (err: any) => void; + videoRef: MutableRefObject; +}) { + const mediaRecords = useFragment( + graphql` + fragment studentContentSideVideoFragment on MediaRecord + @relay(plural: true) { + id + name + downloadUrl + userProgressData { + dateWorkedOn + } + closedCaptions + segments { + id + ... on VideoRecordSegment { + startTime + + transcript + thumbnail + title + } + } + } + `, + _records + ); + const currentRecord = mediaRecords[selected]; + + const [mediaRecordWorkedOn] = + useMutation(graphql` + mutation studentMediaLogProgressVideoMutation($id: UUID!) { + logMediaRecordWorkedOn(mediaRecordId: $id) { + id + } + } + `); + + const workedOnToday = + Math.abs( + differenceInHours( + new Date(), + new Date(currentRecord?.userProgressData.dateWorkedOn ?? "") + ) + ) < 24; + + const [duration, setDuration] = useState(0); + + return ( +
+ {(mediaRecords?.length ?? 0) > 1 && ( + + )} + + setDuration(e)} + > + {currentRecord.closedCaptions && ( + + )} + + ({ + startTime: x.startTime ?? 0, + text: x.title ?? "", + endTime: currentRecord.segments[idx + 1]?.startTime ?? duration, + })), + }} + label="Chapters" + kind="chapters" + type="json" + default + /> + + + ({ + startTime: x.startTime ?? 0, + url: "data:image/jpeg;base64," + x.thumbnail, + }))} + /> + + +
+ {currentRecord.segments.map((segment) => ( +
{ + if (videoRef.current && segment.startTime !== undefined) + videoRef.current.currentTime = segment.startTime; + }} + key={segment.id} + className="bg-slate-50 border borders-slate-200 shadow hover:bg-slate-100 text-xs rounded-md p-2 transition duration-100 cursor-pointer flex gap-2" + > + {segment.title!} +
+
+ {dayjs + .duration(segment.startTime ?? 0, "seconds") + .format("HH:mm:ss")} +
+ {segment.title} +
+
+ ))} +
+ +
+ +
+
+ ); +} diff --git a/app/courses/[courseId]/skills/student.tsx b/app/courses/[courseId]/skills/student.tsx index 6342495..a9678f1 100644 --- a/app/courses/[courseId]/skills/student.tsx +++ b/app/courses/[courseId]/skills/student.tsx @@ -1,87 +1,102 @@ +import { studentCourseSkillsQuery } from "@/__generated__/studentCourseSkillsQuery.graphql"; import { Heading } from "@/components/Heading"; import { SkillLevels } from "@/components/SkillLevels"; import { Grid, Typography } from "@mui/material"; import { useParams } from "next/navigation"; import { graphql, useLazyLoadQuery } from "react-relay"; -import { studentCourseSkillsQuery } from "@/__generated__/studentCourseSkillsQuery.graphql"; export default function StudentSkills() { - // Get course id from url - const { courseId:id } = useParams(); - const { coursesByIds } = useLazyLoadQuery( - graphql` - query studentCourseSkillsQuery($id: UUID!) { - coursesByIds(ids: [$id]) { - id - title - skills { - skillName - skillLevels { - remember { - value - } - understand { - value - } - apply { - value - } - analyze { - value - } - evaluate{ - value - } - create{ - value - } - } - ...SkillLevelsFragment + // Get course id from url + const { courseId: id } = useParams(); + const { coursesByIds } = useLazyLoadQuery( + graphql` + query studentCourseSkillsQuery($id: UUID!) { + coursesByIds(ids: [$id]) { + id + title + skills { + skillName + skillLevels { + remember { + value + } + understand { + value + } + apply { + value + } + analyze { + value + } + evaluate { + value + } + create { + value } } + ...SkillLevelsFragment } - `, - { id } - ); - // Extract course - const course = coursesByIds[0]; - const skills=course.skills; - const title="Knowledge Status for Course "+course.title; - return (
- -

- This pages shows your current knowledge status. Your knowledge status is calculated based on your performances on flashcardsets and quizzes. - For the calculation a method called M-Elo is used. Elo was originally used to rank chess players, but due to adaptions it can also be used to calculate students - knowledge status. After the completion of a flashcardset or a quiz, M-Elo will automatically recalulate your knowledge status of the skills, that were covered by - the flashcardset or quiz you worked on. Each flashcard and each question has a list with the skills and levels of Blooms Taxonomy, that belong to the flashcard or quiz. - After the completion of a quiz only the values for the involved skills and the corresponding levels are recalulated. Due to this, this overview only shows the skills and - the level of Blooms Taxonomy you have already worked on. - - - -{skills.map((skill, index) => ( - - skill.skillLevels && (skill.skillLevels.remember.value>0 ||skill.skillLevels.understand.value>0 -||skill.skillLevels.analyze.value>0||skill.skillLevels.apply.value>0||skill.skillLevels.evaluate.value>0||skill.skillLevels.create.value>0)&& -

- - - - {skill.skillName} - - - - - - - - - -
+ } + } + `, + { id } + ); + // Extract course + const course = coursesByIds[0]; + const skills = course.skills; + const title = "Knowledge Status for Course " + course.title; + return ( +
+ +

+ + This pages shows your current knowledge status. Your knowledge status is + calculated based on your performances on flashcardsets and quizzes. For + the calculation a method called M-Elo is used. Elo was originally used + to rank chess players, but due to adaptions it can also be used to + calculate students knowledge status. After the completion of a + flashcardset or a quiz, M-Elo will automatically recalulate your + knowledge status of the skills, that were covered by the flashcardset or + quiz you worked on. Each flashcard and each question has a list with the + skills and levels of Blooms Taxonomy, that belong to the flashcard or + quiz. After the completion of a quiz only the values for the involved + skills and the corresponding levels are recalulated. Due to this, this + overview only shows the skills and the level of Blooms Taxonomy you have + already worked on. + + + {skills.map( + (skill, index) => + skill.skillLevels && + ((skill.skillLevels.remember?.value ?? 0) > 0 || + (skill.skillLevels.understand?.value ?? 0) > 0 || + (skill.skillLevels.analyze?.value ?? 0) > 0 || + (skill.skillLevels.apply?.value ?? 0) > 0 || + (skill.skillLevels.evaluate?.value ?? 0) > 0 || + (skill.skillLevels.create?.value ?? 0) > 0) && ( +

+ + + {skill.skillName} + + -
+ + + - ))} - -
); -} \ No newline at end of file +
+
+ ) + )} + +
+ ); +} diff --git a/components/ItemFormSection.tsx b/components/ItemFormSection.tsx index eb6cdcf..354d119 100644 --- a/components/ItemFormSection.tsx +++ b/components/ItemFormSection.tsx @@ -1,179 +1,209 @@ -import { Button, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, FormControlLabel, FormGroup, InputLabel, ListItemText, MenuItem, Select, TextField, Typography } from "@mui/material"; -import { Form, FormSection } from "./Form"; -import { useState,useEffect } from "react"; -import { graphql, useLazyLoadQuery } from "react-relay"; +import { + BloomLevel, + SkillInput, +} from "@/__generated__/AddFlashcardSetModalMutation.graphql"; import { ItemFormSectionCourseSkillsQuery } from "@/__generated__/ItemFormSectionCourseSkillsQuery.graphql"; -import { BloomLevel,SkillInput } from "@/__generated__/AddFlashcardSetModalMutation.graphql"; import { Add } from "@mui/icons-material"; +import { + Button, + Checkbox, + FormControl, + FormControlLabel, + FormGroup, + InputLabel, + ListItemText, + MenuItem, + Select, + TextField, +} from "@mui/material"; +import { useEffect, useState } from "react"; +import { graphql, useLazyLoadQuery } from "react-relay"; +import { FormSection } from "./Form"; const bloomLevelLabel: Record = { - CREATE:"Create", - EVALUATE:"Evaluate", - ANALYZE: "Analyze", - APPLY: "Apply", - REMEMBER: "Remember", - UNDERSTAND: "Understand", - "%future added value": "Unknown", + CREATE: "Create", + EVALUATE: "Evaluate", + ANALYZE: "Analyze", + APPLY: "Apply", + REMEMBER: "Remember", + UNDERSTAND: "Understand", + "%future added value": "Unknown", }; export function ItemFormSection({ - onChange , - item, - courseId, - useEffectNecessary, - }: { - onChange: (item: ItemData| null,newSkillAdded?:boolean) => void; - item: ItemData ; - courseId: string; - useEffectNecessary?:boolean; - }) { - const [bloomLevels, setBloomLevels] = useState(item?.associatedBloomLevels ?? []);; - const[skills,setSkills]=useState(item?.associatedSkills??[]); - const[itemId,setItemId]=useState(item?.id) - const valid =bloomLevels.length>0 && skills.length>0; - const[newSkillAdded,setNewSkillAdded]=useState(false); - const { coursesByIds } = useLazyLoadQuery( - graphql` - query ItemFormSectionCourseSkillsQuery($id: UUID!) { - coursesByIds(ids: [$id]) { - id + onChange, + item, + courseId, +}: { + onChange: (item: ItemData | null, newSkillAdded?: boolean) => void; + item: ItemData; + courseId: string; +}) { + const [bloomLevels, setBloomLevels] = useState( + item?.associatedBloomLevels ?? [] + ); + const [skills, setSkills] = useState(item?.associatedSkills ?? []); + const [itemId, setItemId] = useState(item?.id); + const valid = bloomLevels.length > 0 && skills.length > 0; + const [newSkillAdded, setNewSkillAdded] = useState(false); + const { coursesByIds } = useLazyLoadQuery( + graphql` + query ItemFormSectionCourseSkillsQuery($id: UUID!) { + coursesByIds(ids: [$id]) { + id title - skills { - id - skillName - } + skills { + id + skillName } } - `, - {id:courseId } - ); + } + `, + { id: courseId } + ); - const[availableSkills,setAvailableSkills]=useState(coursesByIds[0].skills); - const [newSkill, setNewSkill] = useState(""); // new state variable for the new skill - function handleAddSkill() { - if (newSkill) { - const isAlreadyAvailable=availableSkills.some(skill=>skill.skillName===newSkill) - if(!isAlreadyAvailable){ - setAvailableSkills([...availableSkills, { skillName: newSkill,id:null}]); - setNewSkill(""); - setNewSkillAdded(true); - } - else{ - alert("The skill is already available!"); - } + const [availableSkills, setAvailableSkills] = useState( + coursesByIds[0].skills + ); + const [newSkill, setNewSkill] = useState(""); // new state variable for the new skill + function handleAddSkill() { + if (newSkill) { + const isAlreadyAvailable = availableSkills.some( + (skill) => skill.skillName === newSkill + ); + if (!isAlreadyAvailable) { + setAvailableSkills([ + ...availableSkills, + { skillName: newSkill, id: null }, + ]); + setNewSkill(""); + setNewSkillAdded(true); + } else { + alert("The skill is already available!"); } - } - function checkIfAvailableSkillIsPartOfSkills(skillToTest:SkillInput){ -console.log(skills); - if(skills.length>0){ - for(let i=0;i< skills.length;i++){ - if(skills[i].skillName==skillToTest.skillName){ - return true; - } + } + function checkIfAvailableSkillIsPartOfSkills(skillToTest: SkillInput) { + console.log(skills); + if (skills.length > 0) { + for (let i = 0; i < skills.length; i++) { + if (skills[i].skillName == skillToTest.skillName) { + return true; } } - return false; } - function handleSkillChange(e: React.ChangeEvent, skill:Skill) { - if (e.target.checked) { - setSkills([...skills,skill]); - } else { - let newSkillSet=skills.filter(s => s.id !== skill.id); - setSkills(newSkillSet); - - } - } - - + return false; + } + function handleSkillChange( + e: React.ChangeEvent, + skill: Skill + ) { + if (e.target.checked) { + setSkills([...skills, skill]); + } else { + let newSkillSet = skills.filter((s) => s.id !== skill.id); + setSkills(newSkillSet); + } + } - useEffect(() => { - onChange( - valid - ? { - id: itemId, - associatedBloomLevels: bloomLevels, - associatedSkills: skills, - } - : null, - newSkillAdded - ); - }, [bloomLevels, skills]); - return( - - - Levels of Blooms Taxonomy + useEffect(() => { + onChange( + valid + ? { + id: itemId, + associatedBloomLevels: bloomLevels, + associatedSkills: skills, + } + : null, + newSkillAdded + ); + }, [bloomLevels, skills]); + return ( + + + + Levels of Blooms Taxonomy + - + setBloomLevels( + (typeof value === "string" + ? value.split(",") + : value) as BloomLevel[] + ) + } + renderValue={(selected) => + selected.map((x) => bloomLevelLabel[x]).join(", ") + } + inputProps={{ id: "assessmentBloomLevelsInput" }} + required + multiple + > + {( + [ + "REMEMBER", + "UNDERSTAND", + "APPLY", + "ANALYZE", + "EVALUATE", + "CREATE", + ] as const + ).map((val, i) => ( + + -1} /> - {bloomLevelLabel[val]} - - ) - )} - - - - Associated Skills: - {availableSkills.map((availableSkill:SkillInput) => ( -
- ) => handleSkillChange(e, availableSkill)} - key={availableSkill.id} - /> - } - label={ - availableSkill.skillName - } - /> -
- ),)} -
- - - setNewSkill(e.target.value)} + {bloomLevelLabel[val]} + + ))} + +
+ + Associated Skills: + {availableSkills.map((availableSkill: SkillInput) => ( +
+ ) => + handleSkillChange(e, availableSkill) + } + key={availableSkill.id} + /> + } + label={availableSkill.skillName} /> -

- - - ); - } +

+ ))} +
+ + + setNewSkill(e.target.value)} + /> +

+ + + ); +} - export type ItemData = { - associatedBloomLevels: BloomLevel[]; - associatedSkills: SkillInput[]; - id?:string; - }; - export type Skill={ - skillName: string, - id?: string | null; - }; \ No newline at end of file +export type ItemData = { + associatedBloomLevels: BloomLevel[]; + associatedSkills: SkillInput[]; + id?: string; +}; +export type Skill = { + skillName: string; + id?: string | null; +}; diff --git a/components/Navbar.tsx b/components/Navbar.tsx index 5c89da9..79fe9d0 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -1,13 +1,22 @@ "use client"; import { NavbarIsTutor$key } from "@/__generated__/NavbarIsTutor.graphql"; +import { NavbarSemanticSearchQuery } from "@/__generated__/NavbarSemanticSearchQuery.graphql"; import { NavbarStudentQuery } from "@/__generated__/NavbarStudentQuery.graphql"; import logo from "@/assets/logo.svg"; import { PageView, usePageView } from "@/src/currentView"; -import { CollectionsBookmark, Dashboard, Logout } from "@mui/icons-material"; import { + CollectionsBookmark, + Dashboard, + Logout, + Search, +} from "@mui/icons-material"; +import { + Autocomplete, Avatar, + CircularProgress, Divider, IconButton, + InputAdornment, List, ListItem, ListItemAvatar, @@ -15,11 +24,13 @@ import { ListItemIcon, ListItemText, ListSubheader, + TextField, Tooltip, } from "@mui/material"; import dayjs from "dayjs"; +import { chain, debounce } from "lodash"; import { usePathname, useRouter } from "next/navigation"; -import { ReactElement } from "react"; +import { ReactElement, useCallback, useState, useTransition } from "react"; import { useAuth } from "react-oidc-context"; import { graphql, useFragment, useLazyLoadQuery } from "react-relay"; @@ -51,13 +62,137 @@ function NavbarBase({ children: React.ReactElement; _isTutor: NavbarIsTutor$key; }) { + const [term, setTerm] = useState(""); + const router = useRouter(); + + const searchResults = useLazyLoadQuery( + graphql` + query NavbarSemanticSearchQuery($term: String!, $skip: Boolean!) { + semanticSearch(queryText: $term, count: 10) @skip(if: $skip) { + score + mediaRecordSegment { + __typename + ... on VideoRecordSegment { + startTime + mediaRecord { + id + name + + contents { + id + metadata { + name + course { + id + title + } + } + } + } + } + ... on DocumentRecordSegment { + page + mediaRecord { + id + name + contents { + id + metadata { + name + course { + id + title + } + } + } + } + } + } + } + } + `, + { term: term, skip: term.length < 3 } + ); + + const [isPending, startTransition] = useTransition(); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const debouncedSetter = useCallback( + debounce((value: string) => startTransition(() => setTerm(value)), 150), + [setTerm, startTransition] + ); + + const results = chain(searchResults.semanticSearch) + .orderBy((x) => x?.score) + .slice(0, 15) + .flatMap((x) => { + const seg = x.mediaRecordSegment; + + return seg.__typename !== "%other" + ? seg?.mediaRecord?.contents.map((content) => ({ + content, + ...x, + mediaRecordSegment: seg, + })) + : []; + }) + .value(); + return (

{/* eslint-disable-next-line @next/next/no-img-element */} GITS logo
+ + { + if (typeof newVal == "string") return; + router.push( + `/courses/${newVal?.content?.metadata.course.id}/media/${newVal?.content?.id}?recordId=${newVal?.mediaRecordSegment.mediaRecord?.id}` + ); + }} + filterOptions={(x) => x} + groupBy={(x) => + `${x?.content?.metadata.course.title} › ${x?.content?.metadata.name}` + } + renderOption={(props, option) => ( +
  • +
    + {option?.mediaRecordSegment.mediaRecord?.name} +
    + {option.mediaRecordSegment.__typename === + "DocumentRecordSegment" + ? `Page ${option.mediaRecordSegment.page}` + : `Page ${option.mediaRecordSegment.startTime}`} +
    +
    +
  • + )} + options={term.length >= 3 ? results ?? [] : []} + onInputChange={(_, value) => value && debouncedSetter(value)} + renderInput={(params) => ( + + {isPending ? : } + + ), + }} + /> + )} + /> } href="/" exact /> void; }) { + const ref = useRef(null); + const { width = 0 } = useResizeObserver({ + ref, + box: "border-box", + }); + + const [debouncedWidth] = useDebounceValue(width, 200); + const [numPages, setNumPages] = useState(); const [pageNumber, setPageNumber] = useState(1); const [_, setViewedPages] = useState([] as number[]); @@ -45,26 +55,37 @@ export function PdfViewer({ } return ( -
    +
    } > - } - pageNumber={pageNumber} - height={600} - /> +
    + } + pageNumber={pageNumber} + /> +
    +
    + {numPages != null && + times(numPages, () => null).map((_, idx) => ( + setPageNumber(idx + 1)} + pageNumber={idx + 1} + key={idx} + > + ))} +
    -
    - setPageNumber(val)} - color="primary" - /> -
    ); } diff --git a/components/RichTextEditor.tsx b/components/RichTextEditor.tsx index af1e520..24cd329 100644 --- a/components/RichTextEditor.tsx +++ b/components/RichTextEditor.tsx @@ -1,6 +1,6 @@ import { MediaRecordSelector$key } from "@/__generated__/MediaRecordSelector.graphql"; import { RichTextEditorMediaRecordQuery } from "@/__generated__/RichTextEditorMediaRecordQuery.graphql"; -import { ContentMediaDisplay } from "@/app/courses/[courseId]/media/[mediaId]/student"; +import { ContentMediaDisplay } from "@/app/courses/[courseId]/media/[mediaId]/ContentMediaDisplay"; import { Code, Delete, @@ -631,7 +631,7 @@ function DisplayMediaRecord({ id }: { id: string }) { graphql` query RichTextEditorMediaRecordQuery($id: UUID!) { mediaRecordsByIds(ids: [$id]) { - ...studentContentMediaDisplayFragment + ...ContentMediaDisplayFragment } } `, diff --git a/package.json b/package.json index b79725d..72c83ac 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "format": "prettier --write .", "check-format": "prettier --check .", "prepare": "husky install", - "update-schema": "fetch-graphql-schema http://orange.informatik.uni-stuttgart.de/graphql -o src/schema.graphql -r && ( [[ \"$OSTYPE\" == \"darwin\"* ]] && sed -i '' 's/Assessment,/Assessment \\&/' src/schema.graphql || sed -i 's/Assessment,/Assessment \\&/' src/schema.graphql )" + "update-schema": "fetch-graphql-schema http://localhost:8080/graphql -o src/schema.graphql -r && ( [[ \"$OSTYPE\" == \"darwin\"* ]] && sed -i '' 's/Assessment,/Assessment \\&/' src/schema.graphql || sed -i 's/Assessment,/Assessment \\&/' src/schema.graphql )" }, "lint-staged": { "*.{ts,tsx,css,json}": "prettier --write" @@ -34,6 +34,7 @@ "@types/pdfobject": "^2.2.5", "@types/react": "18.0.37", "@types/react-dom": "18.0.11", + "@vidstack/react": "^1.12.9", "autoprefixer": "10.4.14", "clsx": "^2.1.0", "date-fns": "^2.30.0", @@ -57,7 +58,6 @@ "react-error-boundary": "^4.0.13", "react-oidc-context": "^2.3.1", "react-pdf": "^7.7.1", - "react-player": "^2.16.0", "react-relay": "15.0.0", "recharts": "^2.12.5", "relay-runtime": "15.0.0", @@ -66,6 +66,7 @@ "slate-react": "^0.98.4", "tailwindcss": "3.3.1", "typescript": "5.0.4", + "usehooks-ts": "^3.1.0", "victory": "^36.9.2", "yup": "^1.4.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 037e77e..0a0a657 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ dependencies: '@types/react-dom': specifier: 18.0.11 version: 18.0.11 + '@vidstack/react': + specifier: ^1.12.9 + version: 1.12.9(@types/react@18.0.37)(react@18.2.0) autoprefixer: specifier: 10.4.14 version: 10.4.14(postcss@8.4.23) @@ -118,10 +121,7 @@ dependencies: version: 2.3.1(oidc-client-ts@2.4.0)(react@18.2.0) react-pdf: specifier: ^7.7.1 - version: 7.7.1(@types/react@18.0.37)(react-dom@18.2.0)(react@18.2.0) - react-player: - specifier: ^2.16.0 - version: 2.16.0(react@18.2.0) + version: 7.7.3(@types/react@18.0.37)(react-dom@18.2.0)(react@18.2.0) react-relay: specifier: 15.0.0 version: 15.0.0(react@18.2.0) @@ -146,6 +146,9 @@ dependencies: typescript: specifier: 5.0.4 version: 5.0.4 + usehooks-ts: + specifier: ^3.1.0 + version: 3.1.0(react@18.2.0) victory: specifier: ^36.9.2 version: 36.9.2(react@18.2.0) @@ -420,6 +423,13 @@ packages: '@floating-ui/utils': 0.2.1 dev: false + /@floating-ui/dom@1.6.10: + resolution: {integrity: sha512-fskgCFv8J8OamCmyun8MfjB1Olfn+uZKjOKZ0vhYF3gRmEUXcGOjxWL8bBr7i4kIuPZ2KD2S3EUIOxnjC8kl2A==} + dependencies: + '@floating-ui/core': 1.6.0 + '@floating-ui/utils': 0.2.7 + dev: false + /@floating-ui/dom@1.6.3: resolution: {integrity: sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==} dependencies: @@ -442,6 +452,10 @@ packages: resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==} dev: false + /@floating-ui/utils@0.2.7: + resolution: {integrity: sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==} + dev: false + /@fontsource/roboto@4.5.8: resolution: {integrity: sha512-CnD7zLItIzt86q4Sj3kZUiLcBk1dSk81qcqgMGaZe7SQ1P8hFNxhMl5AZthK1zrDM5m74VVhaOpuMGIL4gagaA==} dev: false @@ -1185,6 +1199,19 @@ packages: eslint-visitor-keys: 3.4.3 dev: false + /@vidstack/react@1.12.9(@types/react@18.0.37)(react@18.2.0): + resolution: {integrity: sha512-2YBkMN590u20P9JVw6EoaAegVz4YP7utxeRXuDkzvn60UG8Ky6v4CdywFaBAHBrxyRefiCJTLB5noDmIRyVplg==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': ^18.0.0 + react: ^18.0.0 + dependencies: + '@floating-ui/dom': 1.6.10 + '@types/react': 18.0.37 + media-captions: 1.0.4 + react: 18.2.0 + dev: false + /@webassemblyjs/ast@1.12.1: resolution: {integrity: sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==} dependencies: @@ -1415,6 +1442,7 @@ packages: /are-we-there-yet@2.0.0: resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==} engines: {node: '>=10'} + deprecated: This package is no longer supported. requiresBuild: true dependencies: delegates: 1.0.0 @@ -1691,7 +1719,7 @@ packages: requiresBuild: true dependencies: '@mapbox/node-pre-gyp': 1.0.11 - nan: 2.19.0 + nan: 2.20.0 simple-get: 3.1.1 transitivePeerDependencies: - encoding @@ -2079,11 +2107,6 @@ packages: engines: {node: '>=0.10.0'} dev: false - /deepmerge@4.3.1: - resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} - engines: {node: '>=0.10.0'} - dev: false - /define-data-property@1.1.4: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} @@ -2186,6 +2209,7 @@ packages: /emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + requiresBuild: true /emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} @@ -2866,6 +2890,7 @@ packages: /gauge@3.0.2: resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==} engines: {node: '>=10'} + deprecated: This package is no longer supported. requiresBuild: true dependencies: aproba: 2.0.0 @@ -2962,6 +2987,8 @@ packages: /glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + requiresBuild: true dependencies: fs.realpath: 1.0.0 inflight: 1.0.6 @@ -3244,6 +3271,7 @@ packages: /is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + requiresBuild: true /is-fullwidth-code-point@4.0.0: resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} @@ -3554,10 +3582,6 @@ packages: strip-bom: 2.0.0 dev: true - /load-script@1.0.0: - resolution: {integrity: sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==} - dev: false - /loader-runner@4.3.0: resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==} engines: {node: '>=6.11.5'} @@ -3582,6 +3606,10 @@ packages: resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} dev: false + /lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + dev: false + /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -3622,6 +3650,7 @@ packages: /lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + requiresBuild: true dependencies: yallist: 4.0.0 dev: false @@ -3648,8 +3677,9 @@ packages: engines: {node: '>=0.10.0'} dev: true - /memoize-one@5.2.1: - resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + /media-captions@1.0.4: + resolution: {integrity: sha512-cyDNmuZvvO4H27rcBq2Eudxo9IZRDCOX/I7VEyqbxsEiD2Ei7UYUhG/Sc5fvMZjmathgz3fEK7iAKqvpY+Ux1w==} + engines: {node: '>=16'} dev: false /meow@3.7.0: @@ -3668,10 +3698,10 @@ packages: trim-newlines: 1.0.0 dev: true - /merge-refs@1.2.2(@types/react@18.0.37): - resolution: {integrity: sha512-RwcT7GsQR3KbuLw1rRuodq4Nt547BKEBkliZ0qqsrpyNne9bGTFtsFIsIpx82huWhcl3kOlOlH4H0xkPk/DqVw==} + /merge-refs@1.3.0(@types/react@18.0.37): + resolution: {integrity: sha512-nqXPXbso+1dcKDpPCXvwZyJILz+vSLqGGOnDrYHQYE+B8n9JTCekVLC65AfCpR4ggVyA/45Y0iR9LDyS2iI+zA==} peerDependencies: - '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: '@types/react': optional: true @@ -3800,8 +3830,8 @@ packages: thenify-all: 1.6.0 dev: false - /nan@2.19.0: - resolution: {integrity: sha512-nO1xXxfh/RWNxfd/XPfbIfFk5vgLsAxUR9y5O0cHMJu/AW9U95JLXqthYHjEp+8gQ5p96K9jUp8nbVOxCdRbtw==} + /nan@2.20.0: + resolution: {integrity: sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw==} requiresBuild: true dev: false optional: true @@ -3922,6 +3952,7 @@ packages: /npmlog@5.0.1: resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==} + deprecated: This package is no longer supported. requiresBuild: true dependencies: are-we-there-yet: 2.0.0 @@ -4427,8 +4458,8 @@ packages: react: 18.2.0 dev: false - /react-pdf@7.7.1(@types/react@18.0.37)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-cbbf/PuRtGcPPw+HLhMI1f6NSka8OJgg+j/yPWTe95Owf0fK6gmVY7OXpTxMeh92O3T3K3EzfE0ML0eXPGwR5g==} + /react-pdf@7.7.3(@types/react@18.0.37)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-a2VfDl8hiGjugpqezBTUzJHYLNB7IS7a2t7GD52xMI9xHg8LdVaTMsnM9ZlNmKadnStT/tvX5IfV0yLn+JvYmw==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 @@ -4442,7 +4473,7 @@ packages: dequal: 2.0.3 make-cancellable-promise: 1.3.2 make-event-props: 1.6.2 - merge-refs: 1.2.2(@types/react@18.0.37) + merge-refs: 1.3.0(@types/react@18.0.37) pdfjs-dist: 3.11.174 prop-types: 15.8.1 react: 18.2.0 @@ -4454,19 +4485,6 @@ packages: - supports-color dev: false - /react-player@2.16.0(react@18.2.0): - resolution: {integrity: sha512-mAIPHfioD7yxO0GNYVFD1303QFtI3lyyQZLY229UEAp/a10cSW+hPcakg0Keq8uWJxT2OiT/4Gt+Lc9bD6bJmQ==} - peerDependencies: - react: '>=16.6.0' - dependencies: - deepmerge: 4.3.1 - load-script: 1.0.0 - memoize-one: 5.2.1 - prop-types: 15.8.1 - react: 18.2.0 - react-fast-compare: 3.2.2 - dev: false - /react-relay@15.0.0(react@18.2.0): resolution: {integrity: sha512-KWdeMMKMJanOL9LsGZYkyAekayYIi+Y4mbDM8VYbHVPgTWJWAQP6yJKS+V4D17qIMo1L84QJQjGaQWEG139p9Q==} peerDependencies: @@ -4692,6 +4710,7 @@ packages: /rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true dependencies: glob: 7.2.3 @@ -5285,6 +5304,7 @@ packages: /tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + requiresBuild: true dev: false /tree-kill@1.2.2: @@ -5429,6 +5449,16 @@ packages: dependencies: punycode: 2.3.1 + /usehooks-ts@3.1.0(react@18.2.0): + resolution: {integrity: sha512-bBIa7yUyPhE1BCc0GmR96VU/15l/9gP1Ch5mYdLcFBaFGQsdmXkvjV0TtOqW1yUd6VjIwDunm+flSciCQXujiw==} + engines: {node: '>=16.15.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 + dependencies: + lodash.debounce: 4.0.8 + react: 18.2.0 + dev: false + /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: false @@ -5819,6 +5849,7 @@ packages: /webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + requiresBuild: true dev: false /webpack-sources@3.2.3: @@ -5868,6 +5899,7 @@ packages: /whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + requiresBuild: true dependencies: tr46: 0.0.3 webidl-conversions: 3.0.1 @@ -5963,6 +5995,7 @@ packages: /yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + requiresBuild: true dev: false /yaml@1.10.2: diff --git a/src/schema.graphql b/src/schema.graphql index ff8f909..a965b7c 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -35,6 +35,13 @@ directive @Size(min: Int = 0, max: Int = 2147483647, message: String = "graphql. directive @ContainerSize(min: Int = 0, max: Int = 2147483647, message: String = "graphql.validation.ContainerSize.message") on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION +# The @OnDemand directive is used to mark fields that are only internally resolved when requested. +# Implementation Note: This will cause the code generator to omit the field from the generated DTOs. +directive @OnDemand on FIELD_DEFINITION + +# Indicates an Input Object is a OneOf Input Object. +directive @oneOf on INPUT_OBJECT + directive @resolveTo(requiredSelectionSet: String, sourceName: String!, sourceTypeName: String!, sourceFieldName: String!, sourceSelectionSet: String, sourceArgs: ResolveToSourceArgs, keyField: String, keysArg: String, pubsubTopic: String, filterBy: String, additionalArgs: ResolveToSourceArgs, result: String, resultType: String) on FIELD_DEFINITION interface Assessment { @@ -52,6 +59,9 @@ interface Assessment { # Progress data of the specified user. progressDataForUser(userId: UUID!): UserProgressData! + + # the items that belong to the Assessment + items: [Item!]! } type AssessmentMetadata { @@ -85,6 +95,9 @@ input AssessmentMetadataInput { } input AssociationInput { + # id of the corresponding item + itemId: UUID + # Text of the left side of the association, in SlateJS JSON format. left: String! @@ -109,7 +122,7 @@ type AssociationQuestion implements Question { # Computed list of all the right sides of the associations, shuffled. rightSide: [String!]! - # Unique identifier of the question. + # Unique identifier of the question and the id of the corresponding item itemId: UUID! # Number of the question, i.e., the position of the question in the list of questions. @@ -123,7 +136,7 @@ type AssociationQuestion implements Question { hint: JSON } -#Level of Blooms Taxonomy +# Level of Blooms Taxonomy enum BloomLevel { REMEMBER UNDERSTAND @@ -173,18 +186,13 @@ type Chapter { # Sections of this chapter. sections: [Section!]! - # The skill levels of the current user in this chapter. - skillLevels: SkillLevels! - # The skill types which are achievable in this chapter. # A skill type is achievable if there is at least one assessment in this chapter with this skill type. achievableSkillTypes: [SkillType]! # The progress of the current user in this chapter. userProgress: CompositeProgressInformation! - - #The skills, that belong to the given chapter - skills: [Skill!]! + skills: [Skill]! } input ChapterFilter { @@ -217,6 +225,9 @@ type ClozeBlankElement { union ClozeElement = ClozeTextElement | ClozeBlankElement input ClozeElementInput { + # id of the corresponding item + itemId: UUID + # Type of the element. type: ClozeElementType! @@ -248,7 +259,7 @@ type ClozeQuestion implements Question { # Whether the blanks must be answered in free text or by selecting the correct answer from a list. showBlanksList: Boolean! - # Unique identifier of the question. + # Unique identifier of the question and the id of the corresponding item itemId: UUID! # Number of the question, i.e., the position of the question in the list of questions. @@ -313,6 +324,7 @@ type ContentMetadata { # TagNames this content is tagged with tagNames: [String!]! + course: Course! } type ContentMutation { @@ -423,8 +435,6 @@ type Course { # The progress of the current user in this course. userProgress: CompositeProgressInformation! - - #The skills, that belong to the given course skills: [Skill!]! } @@ -484,13 +494,15 @@ input CreateAssessmentInput { # Assessment metadata assessmentMetadata: AssessmentMetadataInput! - """ - items of the new assessments - """ - items: [CreateItemInput!] + + # items of the new assessments + items: [ItemInput!] } input CreateAssociationQuestionInput { + # id of the corresponding item + itemId: UUID + # Number of the question, used for ordering. # This can be omitted, in which case a number, one higher than the highest number of the existing questions, will be used. number: Int @@ -503,8 +515,6 @@ input CreateAssociationQuestionInput { # Optional hint for the question, in SlateJS JSON format. hint: JSON - - itemId:UUID } # Input type for creating chapters. @@ -540,6 +550,9 @@ input CreateChapterInput { } input CreateClozeQuestionInput { + # id of the corresponding item + itemId: UUID + # Number of the question, used for ordering. # This can be omitted, in which case a number, one higher than the highest number of the existing questions, will be used. number: Int @@ -555,8 +568,6 @@ input CreateClozeQuestionInput { # Optional hint for the question, in SlateJS JSON format. hint: JSON - - itemId:UUID } input CreateContentMetadataInput { @@ -606,6 +617,9 @@ input CreateCourseInput { } input CreateExactAnswerQuestionInput { + # id of the corresponding item + itemId: UUID + # Number of the question, used for ordering. # This can be omitted, in which case a number, one higher than the highest number of the existing questions, will be used. number: Int @@ -624,32 +638,21 @@ input CreateExactAnswerQuestionInput { # Optional hint for the question, in SlateJS JSON format. hint: JSON - - itemId:UUID! } input CreateFlashcardInput { + # id of the item the flashcard belongs to + itemId: UUID + # List of sides of this flashcard. Must be at least two sides. sides: [FlashcardSideInput!]! - #id of the item - itemId:UUID } input CreateFlashcardSetInput { # List of flashcards in this set. flashcards: [CreateFlashcardInput!]! } -input CreateItemInput{ - """ - The skills or the competencies the item belongs to. - """ - associatedSkills:[SkillInput!]! - """ - The Level of Blooms Taxonomy the item belongs to - """ - associatedBloomLevels:[BloomLevel!]! -} # Input for creating new media content. Media specific fields are stored in the Media Service. input CreateMediaContentInput { # Metadata for the new Content @@ -668,6 +671,9 @@ input CreateMediaRecordInput { } input CreateMultipleChoiceQuestionInput { + # UUID of the question to update and the id of the corresponding item. + itemId: UUID + # Number of the question, used for ordering. # This can be omitted, in which case a number, one higher than the highest number of the existing questions, will be used. number: Int @@ -680,11 +686,12 @@ input CreateMultipleChoiceQuestionInput { # Optional hint for the question, in SlateJS JSON format. hint: JSON - - itemId:UUID } input CreateNumericQuestionInput { + # id of the corresponding item + itemId: UUID + # Number of the question, used for ordering. # This can be omitted, in which case a number, one higher than the highest number of the existing questions, will be used. number: Int @@ -703,8 +710,6 @@ input CreateNumericQuestionInput { # Optional hint for the question, in SlateJS JSON format. hint: JSON - - itemId:UUID } input CreateQuizInput { @@ -725,7 +730,6 @@ input CreateQuizInput { # # If this is null or not set, the behavior is the same as if it was equal to the number of questions. numberOfRandomlySelectedQuestions: Int - } input CreateSectionInput { @@ -737,6 +741,9 @@ input CreateSectionInput { } input CreateSelfAssessmentQuestionInput { + # id of the corresponding item + itemId: UUID + # Number of the question, used for ordering. # This can be omitted, in which case a number, one higher than the highest number of the existing questions, will be used. number: Int @@ -749,8 +756,6 @@ input CreateSelfAssessmentQuestionInput { # Optional hint for the question, in SlateJS JSON format. hint: JSON - - itemId:UUID } input CreateStageInput { @@ -777,6 +782,27 @@ input DateTimeFilter { before: DateTime } +type DocumentRecordSegment implements MediaRecordSegment { + # UUID of this segment. + id: UUID! + + # UUID of the media record this search result references. + mediaRecordId: UUID! + + # Page of the document this search result references. + page: Int! + + # The text snippet of the document this search result references. + text: String! + + # Base64-encoded image thumbnail for this segment. + thumbnail: String! + + # Title of this segment. + title: String + mediaRecord: MediaRecord! +} + # A question with a clear, correct answer that can be automatically checked. # Differs from self-assessment questions in that the user has to enter one of the correct answers and # the answer is checked automatically. @@ -793,7 +819,7 @@ type ExactAnswerQuestion implements Question { # Feedback for the question when the user enters a wrong answer, in SlateJS JSON format. feedback: JSON - # Unique identifier of the question. + # Unique identifier of the question and the id of the corresponding item itemId: UUID! # Number of the question, i.e., the position of the question in the list of questions. @@ -811,7 +837,7 @@ type ExactAnswerQuestion implements Question { # The label is used to specify which side of the flashcard is being shown to the user first for learning # and which sides he has to guess. type Flashcard { - # Unique identifier of this flashcard. + # Unique identifier of this flashcard, which is the id of the corresponding item itemId: UUID! # List of sides of this flashcard. @@ -834,6 +860,13 @@ type FlashcardLearnedFeedback { flashcardSetProgress: FlashcardSetProgress! } +# #when a flashcard is created or changed, also the item information are changed, +# these type combines the flashcard and the changed information +type FlashcardOutput { + flashcard: Flashcard! + item: Item! +} + type FlashcardProgressData { # The date the user learned the flashcard. # This is null it the user has not learned the content item once. @@ -858,14 +891,7 @@ type FlashcardProgressDataLog { # Whether the user knew the flashcard or not. success: Boolean! } - -type FlashcardOutput{ - - flashcard:Flashcard! - - item:Item! -} # A set of flashcards. A flashcard set belongs to exactly one assessment. Therefore, the uuid of the assessment # also serves as the identifier of a flashcard set. type FlashcardSet { @@ -900,26 +926,35 @@ type FlashcardSetAssessment implements Assessment & Content { # Progress data of the specified user. progressDataForUser(userId: UUID!): UserProgressData! + # the items that belong to the Flashcard + items: [Item!]! + # The FlashcardSet of the assessment. flashcardSet: FlashcardSet - - """ - the items that belong to the Flashcard - """ - items:[Item!] } type FlashcardSetMutation { # ID of the flashcard set that is being modified. assessmentId: UUID! - #creates a new flashcard - createFlashcard(item:ItemInput!,assessmentId:UUID!,flashcardInput:CreateFlashcardInput!):FlashcardOutput! + + # Creates a new flashcard. Throws an error if the flashcard set does not exist. + # ⚠️ This mutation is only accessible internally in the system and allows the caller to create Flashcards without + # any permissions check and should not be called without any validation of the caller's permissions. ⚠️ + _internal_noauth_createFlashcard(input: CreateFlashcardInput!): Flashcard! # Updates a flashcard. Throws an error if the flashcard does not exist. - updateFlashcard(item:ItemInput!,assessmentId:UUID!,flashcardInput:UpdateFlashcardInput!): FlashcardOutput! + # ⚠️ This mutation is only accessible internally in the system and allows the caller to update Flashcards without + # any permissions check and should not be called without any validation of the caller's permissions. ⚠️ + _internal_noauth_updateFlashcard(input: UpdateFlashcardInput!): Flashcard! # Deletes the flashcard with the specified ID. Throws an error if the flashcard does not exist. deleteFlashcard(id: UUID!): UUID! + + # Creates a new flashcard and the linked item + createFlashcard(item: ItemInput!, assessmentId: UUID!, flashcardInput: CreateFlashcardInput!): FlashcardOutput! + + # Creates a new flashcard and the linked item + updateFlashcard(item: ItemInput!, assessmentId: UUID!, flashcardInput: UpdateFlashcardInput!): FlashcardOutput! } type FlashcardSetProgress { @@ -960,11 +995,21 @@ input FlashcardSideInput { isAnswer: Boolean! } +input GenerateMediaRecordLinksInput { + contentId: UUID! + mediaRecordIds: [UUID!]! +} + enum GlobalUserRole { SUPER_USER COURSE_CREATOR } +input IngestMediaRecordInput { + # UUID of the media record of this document. + id: UUID! +} + # Filter for integer values. # If multiple filters are specified, they are combined with AND. input IntFilter { @@ -978,36 +1023,40 @@ input IntFilter { lessThan: Int } -type Item{ - """ - the id of the item - """ - id:UUID! - """ - The skills or the competencies the item belongs to. - """ - associatedSkills:[Skill!]! - """ - The Level of Blooms Taxonomy the item belongs to - """ - associatedBloomLevels:[BloomLevel!]! -} - -input ItemInput{ - """ - the id of the item - """ - id:UUID - """ - The skills or the competencies the item belongs to. - """ - associatedSkills:[SkillInput!]! - """ - The Level of Blooms Taxonomy the item belongs to - """ - associatedBloomLevels:[BloomLevel!]! -} -"" +# An item is a part of an assessment. Based on students' performances on items the +# SkillLevel Service estimates a students knowledge. +# An item is something like a question in a quiz, a flashcard of a flashcard set. +type Item { + # the id of the item + id: UUID! + + # The skills or the competencies the item belongs to. + associatedSkills: [Skill!]! + + # The Level of Blooms Taxonomy the item belongs to + associatedBloomLevels: [BloomLevel!]! +} + +input ItemInput { + # the id of the item + id: UUID + + # The skills or the competencies the item belongs to. + associatedSkills: [SkillInput!]! + + # The Level of Blooms Taxonomy the item belongs to + associatedBloomLevels: [BloomLevel!]! +} + +type ItemProgress { + # the id of the corresponding item + itemId: UUID! + + # the correctness of the users response. + # Value between 0 and 1 representing the user's correctness on the content item. + responseCorrectness: Float! +} + # A JSON scalar scalar JSON @@ -1048,6 +1097,7 @@ type MediaContent implements Content { # The media records linked to this media content. mediaRecords: [MediaRecord!]! + segmentLinks: [MediaRecordSegmentLink!]! } # schema file of the microservice @@ -1079,8 +1129,29 @@ type MediaRecord { # Temporary download url for the media record downloadUrl: String! + # Temporary upload url for the media record which can only be used from within the system. + # (This is necessary because the MinIO pre-signed URLs cannot be changed, meaning we cannot use the same URL for both + # internal and external access because the hostname changes.) + internalUploadUrl: String! + + # Temporary download url for the media record which can only be used from within the system. + # (This is necessary because the MinIO pre-signed URLs cannot be changed, meaning we cannot use the same URL for both + # internal and external access because the hostname changes.) + internalDownloadUrl: String! + # The progress data of the given user for this medium. userProgressData: MediaRecordProgressData! + + # Returns the contents this media record is linked to. If the user does not have access to a particular + # content, null will be returned in its place. + contents: [Content]! + + # Returns the segments this media record consists of. + segments: [MediaRecordSegment!]! + + # Returns a closed captions string formatted in WebVTT format for the media record if it is of type video, + # returns null otherwise. + closedCaptions: String } type MediaRecordProgressData { @@ -1092,6 +1163,25 @@ type MediaRecordProgressData { dateWorkedOn: DateTime } +interface MediaRecordSegment { + # UUID of this segment. + id: UUID! + + # UUID of the media record this segment is part of. + mediaRecordId: UUID! + + # Base64-encoded image thumbnail for this segment. + thumbnail: String! + + # Title of this segment. + title: String +} + +type MediaRecordSegmentLink { + segment1: MediaRecordSegment! + segment2: MediaRecordSegment! +} + # The type of the media record enum MediaType { VIDEO @@ -1135,7 +1225,7 @@ type MultipleChoiceQuestion implements Question { # How many answers the user has to select. This is computed from the list of answers. numberOfCorrectAnswers: Int! - # Unique identifier of the question. + # Unique identifier of the question and the id of the corresponding item itemId: UUID! # Number of the question, i.e., the position of the question in the list of questions. @@ -1149,15 +1239,9 @@ type MultipleChoiceQuestion implements Question { hint: JSON } +# Mutations for the flashcard service. Provides mutations for creating, updating, and deleting flashcard as well as +# creating and deleting flashcard sets. To update a flashcard set, update, delete, and create flashcards individually. type Mutation { - # Modify Content - # 🔒 The user must have admin access to the course containing the section to perform this action. - mutateContent(contentId: UUID!): ContentMutation! - - # Modify the section with the given id. - # 🔒 The user must have admin access to the course containing the section to perform this action. - mutateSection(sectionId: UUID!): SectionMutation! - # ONLY FOR TESTING PURPOSES. DO NOT USE IN FRONTEND. WILL BE REMOVED. # # Triggers the recalculation of the reward score of the user. @@ -1186,7 +1270,7 @@ type Mutation { setLinkedMediaRecordsForContent(contentId: UUID!, mediaRecordIds: [UUID!]!): [MediaRecord!]! # Logs that a media has been worked on by the current user. - # See https://meitrex.readthedocs.io/en/latest/dev-manuals/gamification/userProgress.html + # See https://gits-enpro.readthedocs.io/en/latest/dev-manuals/gamification/userProgress.html # # Possible side effects: # When all media records of a content have been worked on by a user, @@ -1198,6 +1282,23 @@ type Mutation { # 🔒 If the mediaRecord is associated with courses the user must be an administrator of at least one of the courses. setMediaRecordsForCourse(courseId: UUID!, mediaRecordIds: [UUID!]!): [MediaRecord!]! + # Modify Content + # 🔒 The user must have admin access to the course containing the section to perform this action. + mutateContent(contentId: UUID!): ContentMutation! + + # Modify the section with the given id. + # 🔒 The user must have admin access to the course containing the section to perform this action. + mutateSection(sectionId: UUID!): SectionMutation! + + # ONLY FOR TESTING PURPOSES. DO NOT USE IN FRONTEND. WILL BE REMOVED. + # + # Triggers the recalculation of the skill level of the user. + # This is done automatically at some time in the night. + # + # The purpose of this mutation is to allow testing of the skill level score and demonstrate the functionality. + # 🔒 The user must be a super-user, otherwise an exception is thrown. + recalculateLevels(chapterId: UUID!, userId: UUID!): SkillLevels! @deprecated(reason: "Only for testing purposes. Will be removed.") + # Creates a new course with the given input and returns the created course. createCourse(input: CreateCourseInput!): Course! @@ -1242,14 +1343,16 @@ type Mutation { # 🔒 The calling user must be an admin in this course to perform this action. deleteMembership(input: CourseMembershipInput!): CourseMembership! - # ONLY FOR TESTING PURPOSES. DO NOT USE IN FRONTEND. WILL BE REMOVED. - # - # Triggers the recalculation of the skill level of the user. - # This is done automatically at some time in the night. - # - # The purpose of this mutation is to allow testing of the skill level score and demonstrate the functionality. - # 🔒 The user must be a super-user, otherwise an exception is thrown. - recalculateLevels(chapterId: UUID!, userId: UUID!): SkillLevels! @deprecated(reason: "Only for testing purposes. Will be removed.") + # Modify a quiz. + # 🔒 The user must be an admin the course the quiz is in to perform this action. + mutateQuiz(assessmentId: UUID!): QuizMutation! + + # Delete a quiz. + deleteQuiz(assessmentId: UUID!): UUID! @deprecated(reason: "Only use if you specifically only want to delete the quiz and not the whole assessment. Otherwise, use deleteAssessment in contents service instead.") + + # Log that a multiple choice quiz is completed. + # 🔒 The user must be enrolled in the course the quiz is in to perform this action. + logQuizCompleted(input: QuizCompletedInput!): QuizCompletionFeedback! # Deletes a flashcard set. Throws an error if the flashcard set does not exist. # The contained flashcards are deleted as well. @@ -1263,17 +1366,6 @@ type Mutation { # 🔒 The user must be enrolled in the course the flashcard set is in to perform this action. logFlashcardLearned(input: LogFlashcardLearnedInput!): FlashcardLearnedFeedback! - # Modify a quiz. - # 🔒 The user must be an admin the course the quiz is in to perform this action. - mutateQuiz(assessmentId: UUID!): QuizMutation! - - # Delete a quiz. - deleteQuiz(assessmentId: UUID!): UUID! @deprecated(reason: "Only use if you specifically only want to delete the quiz and not the whole assessment. Otherwise, use deleteAssessment in contents service instead.") - - # Log that a multiple choice quiz is completed. - # 🔒 The user must be enrolled in the course the quiz is in to perform this action. - logQuizCompleted(input: QuizCompletedInput!): QuizCompletionFeedback! - # Creates a new media content and links the given media records to it. createMediaContentAndLinkRecords(contentInput: CreateMediaContentInput!, mediaRecordIds: [UUID!]!): MediaContent! @@ -1285,8 +1377,6 @@ type Mutation { # Creates a new section in a chapter. createSection(input: CreateSectionInput!): Section! - - } type NumericQuestion implements Question { @@ -1302,7 +1392,7 @@ type NumericQuestion implements Question { # Feedback for the question when the user enters a wrong answer, in SlateJS JSON format. feedback: JSON - # Unique identifier of the question. + # Unique identifier of the question and the id of the corresponding item itemId: UUID! # Number of the question, i.e., the position of the question in the list of questions. @@ -1363,6 +1453,11 @@ type ProgressLogItem { # Time in milliseconds it took the user to complete the content item. # Can be null for contents that do not measure completion time. timeToComplete: Int + + # !OPTIONAL + # the items the user has completed and the students' performance on these items + # Can be null as some contents don't contains items for assessments + progressPerItem: ItemProgress! } type PublicUserInfo { @@ -1371,56 +1466,6 @@ type PublicUserInfo { } type Query { - # Retrieves all existing contents for a given course. - # 🔒 The user must have access to the courses with the given ids to access their contents, otherwise an error is thrown. - contentsByCourseIds(courseIds: [UUID!]!): [[Content!]!] - - # Get contents by ids. Throws an error if any of the ids are not found. - # 🔒 The user must have access to the courses containing the contents with the given ids to access their contents, - # otherwise an error is thrown. - contentsByIds(ids: [UUID!]!): [Content!]! - - # Get contents by ids. If any of the given ids are not found, the corresponding element in the result list will be null. - # 🔒 The user must have access to the courses containing the contents with the given ids, otherwise null is returned - # for the respective contents. - findContentsByIds(ids: [UUID!]!): [Content]! - - # Get contents by chapter ids. Returns a list containing sublists, where each sublist contains all contents - # associated with that chapter - # 🔒 The user must have access to the courses containing the chapters with the given ids, otherwise an error is thrown. - contentsByChapterIds(chapterIds: [UUID!]!): [[Content!]!]! - - # Generates user specific suggestions for multiple chapters. - # - # Only content that the user can access will be considered. - # The contents will be ranked by suggested date, with the most overdue or most urgent content first. - # - # 🔒 The user must have access to the courses containing the chapters with the given ids, otherwise an error is thrown. - suggestionsByChapterIds( - # The ids of the chapters for which suggestions should be generated. - chapterIds: [UUID!]! - - # The amount of suggestions to generate in total. - amount: Int! - - # Only suggestions for these skill types will be generated. - # If no skill types are given, suggestions for all skill types will be generated, - # also containing suggestions for media content (which do not have a skill type). - skillTypes: [SkillType!]! = [] - ): [Suggestion!]! - - # Gets the publicly available information for a list of users with the specified IDs. - # If a user does not exist, null is returned for that user. - findPublicUserInfos(ids: [UUID!]!): [PublicUserInfo]! - - # Gets the user information of the currently authorized user. - currentUserInfo: UserInfo! - - # Gets all of the users' information for a list of users with the specified IDs. - # Only available to privileged users. - # If a user does not exist, null is returned for that user. - findUserInfos(ids: [UUID!]!): [UserInfo]! - # Get the reward score of the current user for the specified course. # 🔒 The user must have access to the course with the given id to access their scores, otherwise an error is thrown. userCourseRewardScores(courseId: UUID!): RewardScores! @@ -1435,34 +1480,90 @@ type Query { # Returns the media records with the given IDs. Throws an error if a MediaRecord corresponding to a given ID # cannot be found. + # # 🔒 If the mediaRecord is associated with coursed the user must be a member of at least one of the courses. mediaRecordsByIds(ids: [UUID!]!): [MediaRecord!]! # Like mediaRecordsByIds() returns the media records with the given IDs, but instead of throwing an error if an ID # cannot be found, it instead returns NULL for that media record. + # # 🔒 If the mediaRecord is associated with coursed the user must be a member of at least one of the courses. findMediaRecordsByIds(ids: [UUID!]!): [MediaRecord]! # Returns all media records of the system. + # # 🔒 The user must be a super-user, otherwise an exception is thrown. mediaRecords: [MediaRecord!]! @deprecated(reason: "In production there should probably be no way to get all media records of the system.") # Returns all media records which the current user created. + # # 🔒 If the mediaRecord is associated with coursed the user must be a member of at least one of the courses. userMediaRecords: [MediaRecord!]! # Returns the media records associated the given content IDs as a list of lists where each sublist contains # the media records associated with the content ID at the same index in the input list + # # 🔒 If the mediaRecord is associated with coursed the user must be a member of at least one of the courses. mediaRecordsByContentIds(contentIds: [UUID!]!): [[MediaRecord!]!]! # Returns all media records for the given CourseIds + # # 🔒 If the mediaRecord is associated with coursed the user must be a member of at least one of the courses. mediaRecordsForCourses(courseIds: [UUID!]!): [[MediaRecord!]!]! # Returns all media records which were created by the users. mediaRecordsForUsers(userIds: [UUID!]!): [[MediaRecord!]!]! + # Gets the publicly available information for a list of users with the specified IDs. + # If a user does not exist, null is returned for that user. + findPublicUserInfos(ids: [UUID!]!): [PublicUserInfo]! + + # Gets the user information of the currently authorized user. + currentUserInfo: UserInfo! + + # Gets all of the users' information for a list of users with the specified IDs. + # Only available to privileged users. + # If a user does not exist, null is returned for that user. + findUserInfos(ids: [UUID!]!): [UserInfo]! + + # Retrieves all existing contents for a given course. + # 🔒 The user must have access to the courses with the given ids to access their contents, otherwise an error is thrown. + contentsByCourseIds(courseIds: [UUID!]!): [[Content!]!] + + # Get contents by ids. Throws an error if any of the ids are not found. + # 🔒 The user must have access to the courses containing the contents with the given ids to access their contents, + # otherwise an error is thrown. + contentsByIds(ids: [UUID!]!): [Content!]! + + # Get contents by ids. If any of the given ids are not found, the corresponding element in the result list will be null. + # 🔒 The user must have access to the courses containing the contents with the given ids, otherwise null is returned + # for the respective contents. + findContentsByIds(ids: [UUID!]!): [Content]! + + # Get contents by chapter ids. Returns a list containing sublists, where each sublist contains all contents + # associated with that chapter + # 🔒 The user must have access to the courses containing the chapters with the given ids, otherwise an error is thrown. + contentsByChapterIds(chapterIds: [UUID!]!): [[Content!]!]! + + # Generates user specific suggestions for multiple chapters. + # + # Only content that the user can access will be considered. + # The contents will be ranked by suggested date, with the most overdue or most urgent content first. + # + # 🔒 The user must have access to the courses containing the chapters with the given ids, otherwise an error is thrown. + suggestionsByChapterIds( + # The ids of the chapters for which suggestions should be generated. + chapterIds: [UUID!]! + + # The amount of suggestions to generate in total. + amount: Int! + + # Only suggestions for these skill types will be generated. + # If no skill types are given, suggestions for all skill types will be generated, + # also containing suggestions for media content (which do not have a skill type). + skillTypes: [SkillType!]! = [] + ): [Suggestion!]! + # Get a list of courses. Can be filtered, sorted and paginated. # Courses and their basic data can be queried by any user, even if they are not enrolled in the course. courses( @@ -1481,9 +1582,15 @@ type Query { # Courses and their basic data can be queried by any user, even if they are not enrolled in the course. coursesByIds(ids: [UUID!]!): [Course!]! + # Get quiz by assessment ID. + # If any of the assessment IDs are not found, the corresponding quiz will be null. + # 🔒 The user must be enrolled in the course the quizzes belong to to access them. Otherwise null is returned for + # an quiz if the user has no access to it. + findQuizzesByAssessmentIds(assessmentIds: [UUID!]!): [Quiz]! + # Get flashcards by their ids. # 🔒 The user must be enrolled in the course the flashcards belong to. Otherwise an error is thrown. - flashcardsByIds(ids: [UUID!]!): [Flashcard!]! + flashcardsByIds(itemIds: [UUID!]!): [Flashcard!]! # Get flashcard sets by their assessment ids. # Returns a list of flashcard sets in the same order as the provided ids. @@ -1494,17 +1601,12 @@ type Query { # Get flashcards of a course that are due to be reviewed. # 🔒 The user must be enrolled in the course the flashcards belong to. Otherwise an error is thrown. dueFlashcardsByCourseId(courseId: UUID!): [Flashcard!]! - - # Get quiz by assessment ID. - # If any of the assessment IDs are not found, the corresponding quiz will be null. - # 🔒 The user must be enrolled in the course the quizzes belong to to access them. Otherwise null is returned for - # an quiz if the user has no access to it. - findQuizzesByAssessmentIds(assessmentIds: [UUID!]!): [Quiz]! + semanticSearch(queryText: String!, count: Int! = 10): [SemanticSearchResult!]! } # Generic question interface. interface Question { - # Unique identifier of the question. + # Unique identifier of the question and the id of the corresponding item itemId: UUID! # Number of the question, i.e., the position of the question in the list of questions. @@ -1585,8 +1687,6 @@ type Quiz { content: Content } - - # A quiz, quiz related fields are stored in the quiz service. type QuizAssessment implements Assessment & Content { # Assessment metadata @@ -1604,14 +1704,12 @@ type QuizAssessment implements Assessment & Content { # Progress data of the specified user. progressDataForUser(userId: UUID!): UserProgressData! + # the items that belong to the Quiz + items: [Item!]! + # The quiz of the assessment. # If this is null the system is in an inconsistent state and the assessment should be deleted. quiz: Quiz - - """ - the items that belong to the Quiz - """ - items:[Item!]! } input QuizCompletedInput { @@ -1638,59 +1736,65 @@ type QuizMutation { # Id of the quiz to modify. assessmentId: UUID! - """ - Add a multiple choice question to the quiz questions, at the end of the list. - """ - addMultipleChoiceQuestion(questionInput: CreateMultipleChoiceQuestionInput!, assessmentId:UUID!, item:ItemInput!):QuizOutput! - - """ - Update a multiple choice question in the quiz questions. - """ - updateMultipleChoiceQuestion(questionInput: UpdateMultipleChoiceQuestionInput!, assessmentId:UUID!, item:ItemInput!):QuizOutput! - - """ - Add a cloze question to the quiz questions, at the end of the list. - """ - addClozeQuestion(questionInput: CreateClozeQuestionInput!, assessmentId:UUID!, item:ItemInput!):QuizOutput! - """ - Update a cloze question in the quiz questions. - """ - updateClozeQuestion(questionInput: UpdateClozeQuestionInput!, assessmentId:UUID!, item:ItemInput!):QuizOutput! - - """ - Add an association question to the quiz questions, at the end of the list. - """ - addAssociationQuestion(questionInput: CreateAssociationQuestionInput!, assessmentId:UUID!, item:ItemInput!):QuizOutput! - """ - Update an association question in the quiz questions. - """ - updateAssociationQuestion(questionInput: UpdateAssociationQuestionInput!, assessmentId:UUID!, item:ItemInput!):QuizOutput! - - """ - Add an free text question with exact answer to the quiz questions, at the end of the list. - """ - addExactAnswerQuestion(questionInput: CreateExactAnswerQuestionInput!, assessmentId:UUID!, item:ItemInput!):QuizOutput! - """ - Update an free text question with exact answer in the quiz questions. - """ - updateExactAnswerQuestion(questionInput: UpdateExactAnswerQuestionInput!, assessmentId:UUID!, item:ItemInput!):QuizOutput! - """ - Add a numeric question to the quiz questions, at the end of the list. - """ - addNumericQuestion(questionInput: CreateNumericQuestionInput!, assessmentId:UUID!, item:ItemInput!):QuizOutput! - """ - Update a numeric question in the quiz questions. - """ - updateNumericQuestion(questionInput: UpdateNumericQuestionInput!, assessmentId:UUID!, item:ItemInput!):QuizOutput! - - """ - Add a self assessment question to the quiz questions, at the end of the list. - """ - addSelfAssessmentQuestion(questionInput: CreateSelfAssessmentQuestionInput!, assessmentId:UUID!, item:ItemInput!):QuizOutput! - """ - Update a self assessment question in the quiz questions. - """ - updateSelfAssessmentQuestion(questionInput: UpdateSelfAssessmentQuestionInput!, assessmentId:UUID!, item:ItemInput!):QuizOutput! + # Add a multiple choice question to the quiz questions, at the end of the list. + # ️⚠️ This query is only accessible internally in the system and allows the caller to create a Question without + # any permissions check and should not be called without any validation of the caller's permissions. ⚠️ + _internal_noauth_addMultipleChoiceQuestion(input: CreateMultipleChoiceQuestionInput!): Quiz! + + # Update a multiple choice question in the quiz questions. + # ️⚠️ This query is only accessible internally in the system and allows the caller to update a Question without + # any permissions check and should not be called without any validation of the caller's permissions. ⚠️ + _internal_noauth_updateMultipleChoiceQuestion(input: UpdateMultipleChoiceQuestionInput!): Quiz! + + # Add a cloze question to the quiz questions, at the end of the list. + # ️⚠️ This query is only accessible internally in the system and allows the caller to create a Question without + # any permissions check and should not be called without any validation of the caller's permissions. ⚠️ + _internal_noauth_addClozeQuestion(input: CreateClozeQuestionInput!): Quiz! + + # Update a cloze question in the quiz questions. + # ️⚠️ This query is only accessible internally in the system and allows the caller to update a Question without + # any permissions check and should not be called without any validation of the caller's permissions. ⚠️ + _internal_noauth_updateClozeQuestion(input: UpdateClozeQuestionInput!): Quiz! + + # Add an association question to the quiz questions, at the end of the list. + # ️⚠️ This query is only accessible internally in the system and allows the caller to create a Question without + # any permissions check and should not be called without any validation of the caller's permissions. ⚠️ + _internal_noauth_addAssociationQuestion(input: CreateAssociationQuestionInput!): Quiz! + + # Update an association question in the quiz questions. + # ️⚠️ This query is only accessible internally in the system and allows the caller to update a Question without + # any permissions check and should not be called without any validation of the caller's permissions. ⚠️ + _internal_noauth_updateAssociationQuestion(input: UpdateAssociationQuestionInput!): Quiz! + + # Add an free text question with exact answer to the quiz questions, at the end of the list. + # ️⚠️ This query is only accessible internally in the system and allows the caller to create a Question without + # any permissions check and should not be called without any validation of the caller's permissions. ⚠️ + _internal_noauth_addExactAnswerQuestion(input: CreateExactAnswerQuestionInput!): Quiz! + + # Update an free text question with exact answer in the quiz questions. + # ️⚠️ This query is only accessible internally in the system and allows the caller to update a Question without + # any permissions check and should not be called without any validation of the caller's permissions. ⚠️ + _internal_noauth_updateExactAnswerQuestion(input: UpdateExactAnswerQuestionInput!): Quiz! + + # Add a numeric question to the quiz questions, at the end of the list. + # ️⚠️ This query is only accessible internally in the system and allows the caller to create a Question without + # any permissions check and should not be called without any validation of the caller's permissions. ⚠️ + _internal_noauth_addNumericQuestion(input: CreateNumericQuestionInput!): Quiz! + + # Update a numeric question in the quiz questions. + # ️⚠️ This query is only accessible internally in the system and allows the caller to update a Question without + # any permissions check and should not be called without any validation of the caller's permissions. ⚠️ + _internal_noauth_updateNumericQuestion(input: UpdateNumericQuestionInput!): Quiz! + + # Add a self assessment question to the quiz questions, at the end of the list. + # ️⚠️ This query is only accessible internally in the system and allows the caller to create a Question without + # any permissions check and should not be called without any validation of the caller's permissions. ⚠️ + _internal_noauth_addSelfAssessmentQuestion(input: CreateSelfAssessmentQuestionInput!): Quiz! + + # Update a self assessment question in the quiz questions. + # ️⚠️ This query is only accessible internally in the system and allows the caller to update a Question without + # any permissions check and should not be called without any validation of the caller's permissions. ⚠️ + _internal_noauth_updateSelfAssessmentQuestion(input: UpdateSelfAssessmentQuestionInput!): Quiz! # Removes the question with the given number from the quiz. # This will also update the numbers of the following questions. @@ -1708,14 +1812,50 @@ type QuizMutation { # Set the number of questions that are randomly selected from the list of questions. # Will only be considered if questionPoolingMode is RANDOM. setNumberOfRandomlySelectedQuestions(numberOfRandomlySelectedQuestions: Int!): Quiz! + + # Add a multiple choice question to the quiz questions, at the end of the list. + addMultipleChoiceQuestion(questionInput: CreateMultipleChoiceQuestionInput!, assessmentId: UUID!, item: ItemInput!): QuizOutput! + + # Update a multiple choice question in the quiz questions. + updateMultipleChoiceQuestion(questionInput: UpdateMultipleChoiceQuestionInput!, assessmentId: UUID!, item: ItemInput!): QuizOutput! + + # Add a cloze question to the quiz questions, at the end of the list. + addClozeQuestion(questionInput: CreateClozeQuestionInput!, assessmentId: UUID!, item: ItemInput!): QuizOutput! + + # Update a cloze question in the quiz questions. + updateClozeQuestion(questionInput: UpdateClozeQuestionInput!, assessmentId: UUID!, item: ItemInput!): QuizOutput! + + # Add an association question to the quiz questions, at the end of the list. + addAssociationQuestion(questionInput: CreateAssociationQuestionInput!, assessmentId: UUID!, item: ItemInput!): QuizOutput! + + # Update an association question in the quiz questions. + updateAssociationQuestion(questionInput: UpdateAssociationQuestionInput!, assessmentId: UUID!, item: ItemInput!): QuizOutput! + + # Add an free text question with exact answer to the quiz questions, at the end of the list. + addExactAnswerQuestion(questionInput: CreateExactAnswerQuestionInput!, assessmentId: UUID!, item: ItemInput!): QuizOutput! + + # Update an free text question with exact answer in the quiz questions. + updateExactAnswerQuestion(questionInput: UpdateExactAnswerQuestionInput!, assessmentId: UUID!, item: ItemInput!): QuizOutput! + + # Add a numeric question to the quiz questions, at the end of the list. + addNumericQuestion(questionInput: CreateNumericQuestionInput!, assessmentId: UUID!, item: ItemInput!): QuizOutput! + + # Update a numeric question in the quiz questions. + updateNumericQuestion(questionInput: UpdateNumericQuestionInput!, assessmentId: UUID!, item: ItemInput!): QuizOutput! + + # Add a self assessment question to the quiz questions, at the end of the list. + addSelfAssessmentQuestion(questionInput: CreateSelfAssessmentQuestionInput!, assessmentId: UUID!, item: ItemInput!): QuizOutput! + + # Update a self assessment question in the quiz questions. + updateSelfAssessmentQuestion(questionInput: UpdateSelfAssessmentQuestionInput!, assessmentId: UUID!, item: ItemInput!): QuizOutput! } -type QuizOutput{ - assessmentId:UUID! - - questionPool: [Question!]! - item:Item! +type QuizOutput { + assessmentId: UUID! + questionPool: [Question!]! + item: Item! } + scalar ResolveToSourceArgs # The reason why the reward score has changed. @@ -1859,7 +1999,7 @@ type SelfAssessmentQuestion implements Question { # A possible correct answer to the question. solutionSuggestion: JSON! - # Unique identifier of the question. + # Unique identifier of the question and the id of the corresponding item itemId: UUID! # Number of the question, i.e., the position of the question in the list of questions. @@ -1873,6 +2013,12 @@ type SelfAssessmentQuestion implements Question { hint: JSON } +type SemanticSearchResult { + # The similarity score of the search result. + score: Float! + mediaRecordSegment: MediaRecordSegment! +} + type SingleAssociation { # The left side of the association, in SlateJS JSON format. left: JSON! @@ -1884,33 +2030,33 @@ type SingleAssociation { feedback: JSON } -#Skill or Competency -type Skill{ - #id of the skill - id:UUID - #Skill name +# a skill or compentency. +# Something like loops or data structures. +type Skill { + # the id of a skill + id: UUID! + + # the name of the skill skillName: String! - #the skill levels of a user + # The skill levels of the current user in this chapter. skillLevels: SkillLevels - } -input SkillInput{ - """ - the id of a skill. Field is optional, because not all required skills may exist, if a new item is created. If the id is empty a new skill, - will be created - """ - id:UUID - """ - the name of the skill - """ - skillName:String! +input SkillInput { + # the id of a skill. Field is optional, because not all required skills may + # exist, if a new item is created. If the id is empty a new skill, + # will be created + id: UUID + + # the name of the skill + skillName: String! } + # The skill level of a user. type SkillLevel { # The value of the skill level. - # levels are between 0 and 100. + # levels are between 0 and 1. value: Float! # A log of the changes to the skill level @@ -1930,42 +2076,37 @@ type SkillLevelLogItem { # The new skill level. newValue: Float! - """ - The ids of the contents that are associated with the change. - """ + + # The ids of the contents that are associated with the change. associatedItemId: UUID! - """ - the response of the user to the item - """ - userResponse:Float! - """ - the probability of a correct response, that M-Elo predicts - """ - predictedCorrectness:Float! + # the response of the user to the item + userResponse: Float! + + # the probability of a correct response, that M-Elo predicts + predictedCorrectness: Float! + associatedContents: [Content]! } # The four skill level of a user. type SkillLevels { # remember represents how much user remember the concept - remember: SkillLevel! + remember: SkillLevel # understand represents how well the user understands learned content. - understand: SkillLevel! + understand: SkillLevel # apply represents the how well user applies the learned concept during assessment. - apply: SkillLevel! + apply: SkillLevel # apply is how much user can evaluate information and draw conclusions - analyze: SkillLevel! - """ - evaluate represent how well a user can use the learned content to evaluate - """ - evaluate:SkillLevel! - """ - create represents how well a user can create new things based on the learned content - """ - create:SkillLevel! + analyze: SkillLevel + + # evaluate represent how well a user can use the learned content to evaluate + evaluate: SkillLevel + + # create represents how well a user can create new things based on the learned content + create: SkillLevel } # Type of the assessment @@ -1974,8 +2115,6 @@ enum SkillType { UNDERSTAND APPLY ANALYZE - EVALUATE - CREATE } # Specifies the sort direction, either ascending or descending. @@ -2041,14 +2180,13 @@ input UpdateAssessmentInput { # Assessment metadata assessmentMetadata: AssessmentMetadataInput! - """ - items of the new assessments - """ - items: [UpdateItemInput!] + + # items of the new assessments + items: [ItemInput!] } input UpdateAssociationQuestionInput { - # UUID of the question to update. + # UUID of the question to update and the id of the corresponding item. itemId: UUID! # Text of the question, in SlateJS JSON format. @@ -2094,7 +2232,8 @@ input UpdateChapterInput { } input UpdateClozeQuestionInput { - + # UUID of the question to update and the id of the corresponding item. + itemId: UUID! # List of cloze elements. clozeElements: [ClozeElementInput!]! @@ -2107,8 +2246,6 @@ input UpdateClozeQuestionInput { # Optional hint for the question, in SlateJS JSON format. hint: JSON - - itemId:UUID! } input UpdateContentMetadataInput { @@ -2158,8 +2295,8 @@ input UpdateCourseInput { } input UpdateExactAnswerQuestionInput { - # UUID of the question to update. - id: UUID! + # UUID of the question to update and the id of the corresponding item. + itemId: UUID! # Text of the question, in SlateJS JSON format. text: JSON! @@ -2175,32 +2312,16 @@ input UpdateExactAnswerQuestionInput { # Optional hint for the question, in SlateJS JSON format. hint: JSON - - itemId:UUID! } input UpdateFlashcardInput { - # Id of the flashcard to update. + # Id of the flashcard to update, which is the id of the corresponding item. itemId: UUID! # List of sides of this flashcard. Must be at least two sides. sides: [FlashcardSideInput!]! } -input UpdateItemInput{ - """ - the id of the item - """ - id:UUID - """ - The skills or the competencies the item belongs to. - """ - associatedSkills:[SkillInput!]! - """ - The Level of Blooms Taxonomy the item belongs to - """ - associatedBloomLevels:[BloomLevel!]! -} input UpdateMediaContentInput { # Metadata for the new Content metadata: UpdateContentMetadataInput! @@ -2221,7 +2342,8 @@ input UpdateMediaRecordInput { } input UpdateMultipleChoiceQuestionInput { - + # UUID of the question to update and the id of the corresponding item. + itemId: UUID! # Text of the question, in SlateJS JSON format. text: JSON! @@ -2231,13 +2353,11 @@ input UpdateMultipleChoiceQuestionInput { # Optional hint for the question, in SlateJS JSON format. hint: JSON - - itemId:UUID! } input UpdateNumericQuestionInput { - # UUID of the question to update. - id: UUID! + # UUID of the question to update and the id of the corresponding item. + itemId: UUID! # Text of the question, in SlateJS JSON format. text: JSON! @@ -2253,13 +2373,11 @@ input UpdateNumericQuestionInput { # Optional hint for the question, in SlateJS JSON format. hint: JSON - - itemId:UUID! } input UpdateSelfAssessmentQuestionInput { - # UUID of the question to update. - id: UUID! + # UUID of the question to update and the id of the corresponding item. + itemId: UUID! # Text of the question, in SlateJS JSON format. text: JSON! @@ -2269,8 +2387,6 @@ input UpdateSelfAssessmentQuestionInput { # Optional hint for the question, in SlateJS JSON format. hint: JSON - - itemId:UUID! } input UpdateStageInput { @@ -2304,7 +2420,7 @@ type UserInfo { } # Represents a user's progress on a content item. -# See https://meitrex.readthedocs.io/en/latest/dev-manuals/gamification/userProgress.html +# See https://gits-enpro.readthedocs.io/en/latest/dev-manuals/gamification/userProgress.html type UserProgressData { # The user's id. userId: UUID! @@ -2346,6 +2462,30 @@ enum UserRoleInCourse { # A universally unique identifier compliant UUID Scalar scalar UUID +type VideoRecordSegment implements MediaRecordSegment { + # UUID of this segment. + id: UUID! + + # UUID of the media record this search result references. + mediaRecordId: UUID! + + # Start time in seconds of the snippet of the video this search result references. + startTime: Int! + + # Text on the screen during this video snippet. + screenText: String! + + # Textual transcript of the spoken text during the video snippet this search result references. + transcript: String! + + # Base64-encoded image thumbnail for this segment. + thumbnail: String! + + # Title of this segment. + title: String + mediaRecord: MediaRecord! +} + # The division of the academic year. enum YearDivision { FIRST_SEMESTER