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,148 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import {
createLessonAction,
createModuleAction,
updateCourseAction,
} from "@/app/admin/courses/actions";
type Props = { params: Promise<{ id: string }> };
export default async function AdminEditCoursePage({ params }: Props) {
const { id } = await params;
const course = await prisma.course.findUnique({
where: { id },
include: {
modules: {
orderBy: { sortOrder: "asc" },
include: { lessons: { orderBy: { sortOrder: "asc" } } },
},
},
});
if (!course) notFound();
const priceEuros = (course.priceCents / 100).toFixed(2);
return (
<div>
<p className="muted">
<Link href="/admin/courses"> Alle Kurse</Link> ·{" "}
<Link href={`/kurse/${course.slug}`}>Öffentliche Kursseite</Link>
</p>
<h1 className="page-title">Kurs bearbeiten</h1>
<p className="muted subtitle">{course.title}</p>
<form action={updateCourseAction.bind(null, course.id)} className="panel form" style={{ maxWidth: 820 }}>
<h2 style={{ marginTop: 0 }}>Stammdaten</h2>
<label>
Titel
<input name="title" defaultValue={course.title} required />
</label>
<label>
Slug
<input name="slug" defaultValue={course.slug} required />
</label>
<label>
Beschreibung
<textarea name="description" defaultValue={course.description} />
</label>
<label>
Anzeige-Autor
<input name="authorName" defaultValue={course.authorName} required />
</label>
<label>
Preis (EUR)
<input name="priceEuros" type="number" step="0.01" min="0" defaultValue={priceEuros} />
</label>
<label>
Abrechnungsintervall
<select name="billingInterval" defaultValue={course.billingInterval}>
<option value="NONE">Einmal / kein Abo</option>
<option value="MONTH">Monatlich</option>
<option value="QUARTER">Vierteljährlich</option>
<option value="YEAR">Jährlich</option>
</select>
</label>
<label className="stack" style={{ alignItems: "center" }}>
<span style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<input type="checkbox" name="published" defaultChecked={course.published} /> Veröffentlicht
</span>
</label>
<button type="submit" className="btn btn-primary">
Speichern
</button>
</form>
<div className="panel" style={{ marginTop: "1.25rem" }}>
<h2 style={{ marginTop: 0 }}>Neues Modul</h2>
<form action={createModuleAction.bind(null, course.id)} className="form" style={{ maxWidth: 720 }}>
<label>
Modultitel
<input name="title" required />
</label>
<button type="submit" className="btn btn-ghost">
Modul hinzufügen
</button>
</form>
</div>
<div className="panel" style={{ marginTop: "1.25rem" }}>
<h2 style={{ marginTop: 0 }}>Neue Lektion</h2>
<form action={createLessonAction.bind(null, course.id)} className="form" style={{ maxWidth: 720 }}>
<label>
Modul
<select name="moduleId" required defaultValue={course.modules[0]?.id ?? ""}>
<option value="" disabled>
Modul wählen
</option>
{course.modules.map((m) => (
<option key={m.id} value={m.id}>
{m.title}
</option>
))}
</select>
</label>
<label>
Lektionstitel
<input name="lessonTitle" required />
</label>
<label>
Slug (optional)
<input name="lessonSlug" placeholder="wird sonst aus dem Titel erzeugt" />
</label>
<label>
Inhalt (HTML)
<textarea name="contentHtml" placeholder="<p>…</p>" />
</label>
<button type="submit" className="btn btn-ghost">
Lektion hinzufügen
</button>
</form>
</div>
<div className="panel" style={{ marginTop: "1.25rem" }}>
<h2 style={{ marginTop: 0 }}>Kurrikulum</h2>
{course.modules.length === 0 ? (
<p className="muted">Noch keine Module.</p>
) : (
<ul className="curriculum">
{course.modules.map((m) => (
<li key={m.id}>
<div className="module-title">{m.title}</div>
<ul className="curriculum">
{m.lessons.map((l) => (
<li key={l.id}>
<Link href={`/kurse/${course.slug}/lektionen/${l.slug}`}>{l.title}</Link>{" "}
<span className="muted">({l.slug})</span>
</li>
))}
</ul>
</li>
))}
</ul>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,153 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
import { prisma } from "@/lib/prisma";
import { slugify } from "@/lib/slug";
import { BillingInterval } from "@prisma/client";
async function assertAdmin() {
const session = await getServerSession(authOptions);
if (!session?.user?.id || session.user.role !== "ADMIN") {
throw new Error("Keine Berechtigung.");
}
return session;
}
function parseBilling(v: FormDataEntryValue | null): BillingInterval {
const s = String(v ?? "NONE");
if (s === "MONTH" || s === "QUARTER" || s === "YEAR") return s;
return BillingInterval.NONE;
}
export async function createCourseAction(formData: FormData) {
const session = await assertAdmin();
const title = String(formData.get("title") ?? "").trim();
if (!title) redirect("/admin/courses/new?error=title");
let slug = String(formData.get("slug") ?? "").trim();
if (!slug) slug = slugify(title);
const exists = await prisma.course.findUnique({ where: { slug } });
if (exists) redirect(`/admin/courses/new?error=slug`);
const description = String(formData.get("description") ?? "").trim();
const authorName = String(formData.get("authorName") ?? "").trim() || session.user.name || "Admin";
const published = String(formData.get("published") ?? "") === "on";
const priceEuros = Number(String(formData.get("priceEuros") ?? "0").replace(",", "."));
const priceCents = Number.isFinite(priceEuros) ? Math.max(0, Math.round(priceEuros * 100)) : 0;
const course = await prisma.course.create({
data: {
slug,
title,
description,
published,
priceCents,
billingInterval: parseBilling(formData.get("billingInterval")),
authorId: session.user.id,
authorName,
},
});
revalidatePath("/kurse");
revalidatePath("/admin/courses");
redirect(`/admin/courses/${course.id}/edit`);
}
export async function updateCourseAction(courseId: string, formData: FormData) {
const session = await assertAdmin();
const title = String(formData.get("title") ?? "").trim();
if (!title) redirect(`/admin/courses/${courseId}/edit?error=title`);
let slug = String(formData.get("slug") ?? "").trim();
if (!slug) slug = slugify(title);
const clash = await prisma.course.findFirst({
where: { slug, NOT: { id: courseId } },
});
if (clash) redirect(`/admin/courses/${courseId}/edit?error=slug`);
const description = String(formData.get("description") ?? "").trim();
const authorName = String(formData.get("authorName") ?? "").trim() || session.user.name || "Admin";
const published = String(formData.get("published") ?? "") === "on";
const priceEuros = Number(String(formData.get("priceEuros") ?? "0").replace(",", "."));
const priceCents = Number.isFinite(priceEuros) ? Math.max(0, Math.round(priceEuros * 100)) : 0;
await prisma.course.update({
where: { id: courseId },
data: {
slug,
title,
description,
published,
priceCents,
billingInterval: parseBilling(formData.get("billingInterval")),
authorName,
},
});
revalidatePath("/kurse");
revalidatePath("/admin/courses");
revalidatePath(`/kurse/${slug}`);
redirect(`/admin/courses/${courseId}/edit?saved=1`);
}
export async function createModuleAction(courseId: string, formData: FormData) {
await assertAdmin();
const title = String(formData.get("title") ?? "").trim();
if (!title) redirect(`/admin/courses/${courseId}/edit?error=module`);
const max = await prisma.courseModule.aggregate({
where: { courseId },
_max: { sortOrder: true },
});
const sortOrder = (max._max.sortOrder ?? -1) + 1;
await prisma.courseModule.create({
data: { courseId, title, sortOrder },
});
revalidatePath(`/admin/courses/${courseId}/edit`);
redirect(`/admin/courses/${courseId}/edit`);
}
export async function createLessonAction(courseId: string, formData: FormData) {
await assertAdmin();
const moduleId = String(formData.get("moduleId") ?? "").trim();
const title = String(formData.get("lessonTitle") ?? "").trim();
if (!moduleId || !title) redirect(`/admin/courses/${courseId}/edit?error=lesson`);
const mod = await prisma.courseModule.findFirst({
where: { id: moduleId, courseId },
});
if (!mod) redirect(`/admin/courses/${courseId}/edit?error=lesson`);
let slug = String(formData.get("lessonSlug") ?? "").trim();
if (!slug) slug = slugify(title);
const contentHtml = String(formData.get("contentHtml") ?? "").trim();
const max = await prisma.lesson.aggregate({
where: { moduleId },
_max: { sortOrder: true },
});
const sortOrder = (max._max.sortOrder ?? -1) + 1;
const existing = await prisma.lesson.findUnique({
where: { moduleId_slug: { moduleId, slug } },
});
if (existing) redirect(`/admin/courses/${courseId}/edit?error=lessonSlug`);
await prisma.lesson.create({
data: { moduleId, slug, title, contentHtml, sortOrder, published: true },
});
const course = await prisma.course.findUnique({ where: { id: courseId }, select: { slug: true } });
if (course?.slug) revalidatePath(`/kurse/${course.slug}`);
revalidatePath(`/admin/courses/${courseId}/edit`);
redirect(`/admin/courses/${courseId}/edit`);
}

View File

@@ -0,0 +1,54 @@
import Link from "next/link";
import { createCourseAction } from "@/app/admin/courses/actions";
export default function AdminNewCoursePage() {
return (
<div>
<p className="muted">
<Link href="/admin/courses"> Zurück</Link>
</p>
<h1 className="page-title">Neuer Kurs</h1>
<p className="muted subtitle">Nach dem Anlegen kannst du Module und Lektionen hinzufügen.</p>
<form action={createCourseAction} className="panel form">
<label>
Titel
<input name="title" required />
</label>
<label>
Slug (optional, sonst aus Titel)
<input name="slug" placeholder="z.b. modul-3-sicherheit" />
</label>
<label>
Kurzbeschreibung
<textarea name="description" />
</label>
<label>
Anzeige-Autor
<input name="authorName" placeholder="z.B. MatzeFix" />
</label>
<label>
Preis (EUR, 0 = kostenlos)
<input name="priceEuros" type="number" step="0.01" min="0" defaultValue="0" />
</label>
<label>
Abrechnungsintervall
<select name="billingInterval" defaultValue="NONE">
<option value="NONE">Einmal / kein Abo</option>
<option value="MONTH">Monatlich</option>
<option value="QUARTER">Vierteljährlich</option>
<option value="YEAR">Jährlich</option>
</select>
</label>
<label className="stack" style={{ alignItems: "center" }}>
<span style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<input type="checkbox" name="published" /> Veröffentlicht
</span>
</label>
<button type="submit" className="btn btn-primary">
Kurs anlegen
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,64 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { formatMoney, billingLabel } from "@/lib/format";
export default async function AdminCoursesPage() {
const courses = await prisma.course.findMany({
orderBy: { updatedAt: "desc" },
include: { _count: { select: { enrollments: true, modules: true } } },
});
return (
<div>
<div className="stack" style={{ justifyContent: "space-between", marginBottom: "1rem" }}>
<div>
<h1 className="page-title">Kurse</h1>
<p className="muted subtitle">Alle Kurse inkl. Entwürfe</p>
</div>
<Link href="/admin/courses/new" className="btn btn-primary">
Neuer Kurs
</Link>
</div>
<div className="table-wrap">
<table className="simple">
<thead>
<tr>
<th>Titel</th>
<th>Slug</th>
<th>Status</th>
<th>Preis</th>
<th>Module</th>
<th>Einschreibungen</th>
<th />
</tr>
</thead>
<tbody>
{courses.map((c) => (
<tr key={c.id}>
<td>{c.title}</td>
<td className="muted">{c.slug}</td>
<td>{c.published ? "Live" : "Entwurf"}</td>
<td>
{c.priceCents === 0 ? (
"kostenlos"
) : (
<>
{formatMoney(c.priceCents, c.currency)}
{billingLabel(c.billingInterval) ? ` ${billingLabel(c.billingInterval)}` : ""}
</>
)}
</td>
<td>{c._count.modules}</td>
<td>{c._count.enrollments}</td>
<td>
<Link href={`/admin/courses/${c.id}/edit`}>Bearbeiten</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}