Skip to content
41 changes: 41 additions & 0 deletions apps/site/pages/dashboard/courseOverview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { GetServerSideProps } from "next";
import { getSession } from "next-auth/react";
import { EnrollmentWithDetails } from "@self-learning/types";
import { CourseOverview } from "@self-learning/teaching";
import { getEnrollmentsOfUserWithCourseDetails } from "@self-learning/api";

export default function Start({ enrollments }: { enrollments: EnrollmentWithDetails[] | null }) {
return <CourseOverview enrollments={enrollments} />;
}

export const getServerSideProps: GetServerSideProps = async context => {
const session = await getSession(context);

if (!session || !session.user) {
return {
redirect: {
destination: "/api/auth/signin",
permanent: false
}
};
}

const username = session.user.name;

try {
const enrollments = await getEnrollmentsOfUserWithCourseDetails(username);

return {
props: {
enrollments
}
};
} catch (error) {
console.error("Error fetching enrollments:", error);
return {
props: {
enrollments: null
}
};
}
};
1 change: 1 addition & 0 deletions libs/data-access/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from "./lib/trpc/app.router";
export * from "./lib/trpc/trpc";
export * from "./lib/auth";
export * from "./lib/trpc/routers/enrollment.router";

// TODO maybe find a better place for those types
export type { SkillResolved, SkillUnresolved } from "./lib/trpc/routers/skill.router";
2 changes: 1 addition & 1 deletion libs/data-access/api/src/lib/trpc/routers/course.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export const courseRouter = t.router({
} = {};

for (const lesson of lessons) {
lessonMap[lesson.lessonId] = lesson as typeof lessons[0] & { meta: LessonMeta };
lessonMap[lesson.lessonId] = lesson as (typeof lessons)[0] & { meta: LessonMeta };
}

return { content, lessonMap };
Expand Down
51 changes: 50 additions & 1 deletion libs/data-access/api/src/lib/trpc/routers/enrollment.router.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { database } from "@self-learning/database";
import { CourseEnrollment } from "@self-learning/types";
import { CourseEnrollment, EnrollmentWithDetails } from "@self-learning/types";
import { AlreadyExists } from "@self-learning/util/http";
import { z } from "zod";
import { authProcedure, t } from "../trpc";
import { getCourseCompletionOfStudent } from "@self-learning/completion";

export const enrollmentRouter = t.router({
getEnrollments: authProcedure.query(async ({ ctx }) => {
Expand Down Expand Up @@ -34,6 +35,54 @@ export const enrollmentRouter = t.router({
})
});

export async function getEnrollmentsOfUserWithCourseDetails(
username: string
): Promise<EnrollmentWithDetails[]> {
const enrollments = await database.enrollment.findMany({
where: { username },
select: {
completedAt: true,
status: true,
lastProgressUpdate: true,
course: {
select: {
title: true,
slug: true,
imgUrl: true,
authors: {
select: {
displayName: true
}
}
}
}
}
});

const enrollmentsWithDetails = await Promise.all(
enrollments.map(async enrollment => {
const completions = await getCourseCompletionOfStudent(
enrollment.course.slug,
username
);

return {
...enrollment,
completedAt: enrollment.completedAt ? enrollment.completedAt.toISOString() : null,
lastProgressUpdate: enrollment.lastProgressUpdate.toISOString(),
completions
};
})
);

enrollmentsWithDetails.sort(
(a, b) =>
new Date(b.lastProgressUpdate).getTime() - new Date(a.lastProgressUpdate).getTime()
);

return enrollmentsWithDetails as EnrollmentWithDetails[];
}

export async function getEnrollmentsOfUser(username: string): Promise<CourseEnrollment[]> {
const enrollments = await database.enrollment.findMany({
where: { username },
Expand Down
1 change: 1 addition & 0 deletions libs/feature/teaching/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export * from "./lib/lesson/forms/license-form";
export * from "./lib/skills/repository/repository-overview";
export * from "./lib/skills/folder-editor";
export * from "./lib/course/full-course-export";
export * from "./lib/course/courseOverview";
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export function ExportCourseProgressDialog({
// Prevent closing the dialog by clicking on the backdrop
onClose={() => {}}
>
{progress < 100 && <ProgressBar progress={progress} />}
{progress < 100 && <ProgressBar completionPercentage={progress} />}
<div className="overlay">{message}</div>
<div className="grid justify-items-end">
<button
Expand Down
145 changes: 145 additions & 0 deletions libs/feature/teaching/src/lib/course/courseOverview/courseOverview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import React, { useState } from "react";
import { ProgressBar, Tab, Tabs } from "@self-learning/ui/common";
import Link from "next/link";
import { EnrollmentWithDetails } from "@self-learning/types";
import { PlayIcon } from "@heroicons/react/24/solid";

export function CourseOverview({ enrollments }: { enrollments: EnrollmentWithDetails[] | null }) {
const [selectedTab, setSelectedTab] = useState(0);

if (!enrollments) {
return <p>No enrollments found</p>;
}

const inProgress = enrollments.filter(
enrollment => enrollment.completions.courseCompletion.completionPercentage < 100
);
const complete = enrollments.filter(
enrollment => enrollment.completions.courseCompletion.completionPercentage >= 100
);

return (
<div className="py-2 px-4">
{selectedTab === 0 && (
<TabContent
selectedTab={selectedTab}
setSelectedTab={setSelectedTab}
enrollments={inProgress}
notFoundMessage={"Derzeit ist kein Kurs angefangen."}
/>
)}
{selectedTab === 1 && (
<TabContent
selectedTab={selectedTab}
setSelectedTab={setSelectedTab}
enrollments={complete}
notFoundMessage={"Derzeit ist kein Kurs abgeschlossen."}
/>
)}
</div>
);
}

function EnrollmentOverview({
enrollments,
notFoundMessage
}: {
enrollments: EnrollmentWithDetails[] | null;
notFoundMessage: string;
}) {
if (!enrollments) {
return <p>Keine Kurse Gefunden.</p>;
}

return (
<div>
{enrollments.length > 0 ? (
<ul className="space-y-4">
{enrollments.map((enrollment, index) => (
<li
key={index}
className="flex flex-col space-y-2 rounded-lg bg-white p-4 shadow-md md:flex-row md:space-y-0 md:space-x-4"
>
{enrollment.course.imgUrl && (
<img
src={enrollment.course.imgUrl}
alt={enrollment.course.title}
className="h-24 w-full rounded-t-lg object-cover md:h-auto md:w-24"
/>
)}
<div className="flex flex-grow flex-col space-y-1">
<table className="w-full">
<tbody>
<tr>
<td className="w-3/5">
<div>
<h2 className="text-lg font-bold text-gray-900">
{enrollment.course.title}
</h2>
<span className="text-sm text-gray-600">
{enrollment.course.authors[0].displayName}
</span>
</div>
</td>
<td className="w-2/5">
<div className="flex justify-end">
<Link
href={`/courses/${enrollment.course.slug}/`}
className="btn-primary flex items-center justify-between rounded-md bg-green-500 px-4 py-2 text-white hover:bg-emerald-600"
style={{ maxWidth: "40%" }}
>
<span>Fortfahren</span>
<PlayIcon className="h-6 w-6" />
</Link>
</div>
</td>
</tr>
</tbody>
</table>
<div className="mt-2">
<ProgressBar
completionPercentage={
enrollment.completions.courseCompletion
.completionPercentage
}
/>
</div>
</div>
</li>
))}
</ul>
) : (
<p className="text-center">{notFoundMessage}</p>
)}
</div>
);
}

function TabContent({
selectedTab,
setSelectedTab,
enrollments,
notFoundMessage
}: {
selectedTab: number;
setSelectedTab: (v: number) => void;
enrollments: EnrollmentWithDetails[] | null;
notFoundMessage: string;
}) {
return (
<div className="xl:grid-cols grid h-full gap-8">
<div>
<Tabs selectedIndex={selectedTab} onChange={v => setSelectedTab(v)}>
<Tab>In Bearbeitung</Tab>
<Tab>Abgeschlossen</Tab>
</Tabs>
<div className={"pt-4"}>
<EnrollmentOverview
enrollments={enrollments}
notFoundMessage={notFoundMessage}
/>
</div>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { CourseOverview } from "./courseOverview";
30 changes: 20 additions & 10 deletions libs/ui/common/src/lib/progress-bar/progress-bar.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
/**
* A reusable progress bar component, which uses the platform's color scheme (emerald).
* @param progress A function which indicates the current progress in percent (0 = not started; 100 = finished)
* @returns A React component to visualize the progress
*/
export function ProgressBar({ progress }: { progress: number }) {
import { motion } from "framer-motion";
import React from "react";

export function ProgressBar({ completionPercentage }: { completionPercentage: number }) {
return (
<div className="mb-4 h-2.5 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div className="relative h-5 w-full rounded-lg bg-gray-200">
<motion.div
className="absolute left-0 h-5 rounded-lg bg-secondary"
initial={{ width: 0 }}
animate={{
width: `${completionPercentage}%`
}}
transition={{ type: "tween" }}
/>
<div
className="h-2.5 rounded-full bg-emerald-500"
style={{ width: `${progress}%` }}
></div>
className={`absolute top-0 w-full px-2 text-start text-sm font-semibold ${
completionPercentage === 0 ? "text-secondary" : "text-white"
}`}
style={{ justifyContent: "flex-start" }}
>
{completionPercentage}%
</div>
</div>
);
}
2 changes: 1 addition & 1 deletion libs/ui/forms/src/lib/upload-progress-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function UploadProgressDialog({ name, progress }: { name: string; progres
// This makes the dialog modal (i.e. the user cannot click outside of the dialog to close it)
onClose={() => {}}
>
<ProgressBar progress={progress} />
<ProgressBar completionPercentage={progress} />
</DialogWithReactNodeTitle>
</CenteredContainer>
);
Expand Down
39 changes: 12 additions & 27 deletions libs/ui/lesson/src/lib/playlist/playlist.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
PlayIcon
} from "@heroicons/react/24/solid";
import { CourseCompletion, extractLessonIds, LessonMeta } from "@self-learning/types";
import { Divider } from "@self-learning/ui/common";
import { Divider, ProgressBar } from "@self-learning/ui/common";
import { motion } from "framer-motion";
import Link from "next/link";
import { useRouter } from "next/router";
Expand Down Expand Up @@ -174,21 +174,8 @@ function PlaylistHeader({ content, course, lesson, completion }: PlaylistProps)
{extractLessonIds(content).length} Lerneinheiten abgeschlossen
</span>
</div>
<span className="relative h-5 w-full rounded-lg bg-gray-200">
<motion.span
className="absolute left-0 h-5 rounded-lg bg-secondary"
initial={{ width: 0 }}
animate={{ width: `${completionPercentage}%` }}
transition={{ type: "tween" }}
></motion.span>
<span
className={`absolute top-0 w-full px-2 text-start text-sm font-semibold ${
completionPercentage === 0 ? "text-secondary" : "text-white"
}`}
>
{completionPercentage}%
</span>
</span>

<ProgressBar completionPercentage={completionPercentage} />

<Divider />

Expand Down Expand Up @@ -241,18 +228,16 @@ function CurrentlyPlaying({ lesson, content, course }: PlaylistProps) {
</span>
</span>
<span className="flex justify-between">

{lesson.meta.hasQuiz && (

<Link
href={`/courses/${course.slug}/${lesson.slug}${
router.pathname.endsWith("quiz") ? "" : "/quiz"
}`}
className="btn-primary text-sm"
data-testid="quizLink"
>
{router.pathname.endsWith("quiz") ? "Zum Lernhinhalt" : "Zur Lernkontrolle"}
</Link>
<Link
href={`/courses/${course.slug}/${lesson.slug}${
router.pathname.endsWith("quiz") ? "" : "/quiz"
}`}
className="btn-primary text-sm"
data-testid="quizLink"
>
{router.pathname.endsWith("quiz") ? "Zum Lernhinhalt" : "Zur Lernkontrolle"}
</Link>
)}

<span className="flex gap-2">
Expand Down
Loading