Initial commit: FL-Akademie LMS mit Docker, Admin, Portal und Dokumentation.

Made-with: Cursor
This commit is contained in:
lo
2026-04-13 23:17:07 +02:00
commit d3367f0046
66 changed files with 3641 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
import { getLessonContext } from "@/lib/course-queries";
import { prisma } from "@/lib/prisma";
import { CompleteLessonButton } from "@/components/complete-lesson-button";
type Props = { params: Promise<{ slug: string; lessonSlug: string }> };
export default async function LessonPage({ params }: Props) {
const { slug: courseSlug, lessonSlug } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
redirect(`/login?callbackUrl=/kurse/${courseSlug}/lektionen/${lessonSlug}`);
}
const ctx = await getLessonContext(courseSlug, lessonSlug);
if (!ctx) notFound();
const enrollment = await prisma.enrollment.findUnique({
where: {
userId_courseId: { userId: session.user.id, courseId: ctx.course.id },
},
});
if (!enrollment) {
redirect(`/kurse/${ctx.course.slug}`);
}
const lessonIds = ctx.course.modules.flatMap((m) => m.lessons.map((l) => l.id));
const progressRows = await prisma.lessonProgress.findMany({
where: { userId: session.user.id, lessonId: { in: lessonIds } },
});
const completed = new Set(
progressRows.filter((p) => p.completedAt).map((p) => p.lessonId),
);
const currentDone = !!completed.has(ctx.lesson.id);
return (
<section className="section">
<div className="container">
<p className="muted">
<Link href={`/kurse/${ctx.course.slug}`}> {ctx.course.title}</Link>
</p>
<div className="two-col">
<nav className="panel" aria-label="Kurrikulum">
{ctx.course.modules.map((m) => (
<div key={m.id}>
<div className="module-title">{m.title}</div>
<ul className="curriculum">
{m.lessons.map((l) => {
const href = `/kurse/${ctx.course.slug}/lektionen/${l.slug}`;
const active = l.id === ctx.lesson.id;
return (
<li key={l.id}>
<Link href={href} className={active ? "active" : undefined}>
{completed.has(l.id) ? "✓ " : ""}
{l.title}
</Link>
</li>
);
})}
</ul>
</div>
))}
</nav>
<article className="panel prose">
<h1 style={{ marginTop: 0 }}>{ctx.lesson.title}</h1>
{ctx.lesson.videoUrl ? (
<p className="muted">
Video: <a href={ctx.lesson.videoUrl}>{ctx.lesson.videoUrl}</a>
</p>
) : null}
<div dangerouslySetInnerHTML={{ __html: ctx.lesson.contentHtml }} />
<CompleteLessonButton lessonId={ctx.lesson.id} initialCompleted={currentDone} />
</article>
</div>
</div>
</section>
);
}

90
app/kurse/[slug]/page.tsx Normal file
View File

@@ -0,0 +1,90 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
import { getCourseBySlug, firstLessonPath } from "@/lib/course-queries";
import { formatMoney, billingLabel } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { EnrollButton } from "@/components/enroll-button";
type Props = { params: Promise<{ slug: string }> };
export default async function CoursePage({ params }: Props) {
const { slug } = await params;
const course = await getCourseBySlug(slug);
if (!course) notFound();
const session = await getServerSession(authOptions);
const enrollment =
session?.user?.id &&
(await prisma.enrollment.findUnique({
where: { userId_courseId: { userId: session.user.id, courseId: course.id } },
}));
const startPath = firstLessonPath(course);
const cats = course.categories.map((c) => c.category.name).join(", ");
const priceSuffix = billingLabel(course.billingInterval);
const isFree = course.priceCents === 0;
return (
<section className="section">
<div className="container">
<p className="muted">
<Link href="/kurse"> Alle Kurse</Link>
</p>
<h1>{course.title}</h1>
<p className="course-meta">
Von <strong>{course.authorName}</strong>
{cats ? <> · {cats}</> : null}
</p>
{!isFree && (
<p className="course-price">
{formatMoney(course.priceCents, course.currency)}
{priceSuffix ? ` ${priceSuffix}` : ""}{" "}
<span className="muted">(Zahlungsanbindung in Arbeit aktuell nicht kaufbar)</span>
</p>
)}
<div style={{ margin: "1.5rem 0" }}>
{!session ? (
<Link href={`/login?callbackUrl=/kurse/${course.slug}`} className="btn btn-primary">
Anmelden, um fortzufahren
</Link>
) : enrollment && startPath ? (
<Link href={startPath} className="btn btn-primary">
Mit dem Lernen beginnen
</Link>
) : enrollment ? (
<span className="muted">Noch keine Lektionen.</span>
) : isFree ? (
<EnrollButton courseSlug={course.slug} />
) : (
<p className="muted">Kostenpflichtige Kurse werden über Stripe angebunden.</p>
)}
</div>
<div className="panel">
<h2>Kurrikulum</h2>
{course.description ? <p>{course.description}</p> : null}
<ul className="curriculum">
{course.modules.map((m) => (
<li key={m.id}>
<div className="module-title">{m.title}</div>
<ul className="curriculum">
{m.lessons.map((lesson) => {
const href = `/kurse/${course.slug}/lektionen/${lesson.slug}`;
return (
<li key={lesson.id}>
<Link href={href}>{lesson.title}</Link>
</li>
);
})}
</ul>
</li>
))}
</ul>
</div>
</div>
</section>
);
}

35
app/kurse/page.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
import { listPublishedCourses } from "@/lib/course-queries";
import { CourseCard } from "@/components/course-card";
import { prisma } from "@/lib/prisma";
export default async function CoursesPage() {
const session = await getServerSession(authOptions);
const courses = await listPublishedCourses();
let enrolledIds = new Set<string>();
if (session?.user?.id) {
const rows = await prisma.enrollment.findMany({
where: { userId: session.user.id },
select: { courseId: true },
});
enrolledIds = new Set(rows.map((r) => r.courseId));
}
return (
<section className="section">
<div className="container">
<h1>Kursinhalte</h1>
<p className="muted" style={{ marginBottom: "1.5rem" }}>
Alle veröffentlichten Kurse Einschreibung und Lektionen wie auf der Live-Akademie vorgesehen.
</p>
<div className="course-grid">
{courses.map((c) => (
<CourseCard key={c.id} course={c} enrolled={enrolledIds.has(c.id)} />
))}
</div>
</div>
</section>
);
}