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,38 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function CompleteLessonButton({
lessonId,
initialCompleted,
}: {
lessonId: string;
initialCompleted: boolean;
}) {
const router = useRouter();
const [completed, setCompleted] = useState(initialCompleted);
const [loading, setLoading] = useState(false);
async function toggle() {
setLoading(true);
const res = await fetch("/api/progress", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lessonId, completed: !completed }),
});
setLoading(false);
if (res.ok) {
setCompleted(!completed);
router.refresh();
}
}
return (
<div style={{ marginTop: "2rem" }}>
<button type="button" className="btn btn-ghost" disabled={loading} onClick={toggle}>
{loading ? "…" : completed ? "Als nicht abgeschlossen markieren" : "Lektion abschließen"}
</button>
</div>
);
}

View File

@@ -0,0 +1,66 @@
import Link from "next/link";
import type { Category, Course, CourseCategory } from "@prisma/client";
import { formatMoney, billingLabel } from "@/lib/format";
import { firstLessonPath } from "@/lib/course-queries";
type CourseWithCats = Course & {
categories: (CourseCategory & { category: Category })[];
modules: { lessons: { slug: string }[] }[];
};
export function CourseCard({
course,
enrolled,
}: {
course: CourseWithCats;
enrolled: boolean;
}) {
const cats = course.categories.map((c) => c.category.name).join(", ");
const first = firstLessonPath(course);
const isFree = course.priceCents === 0;
const priceSuffix = billingLabel(course.billingInterval);
return (
<article className="course-card">
<div className="course-card-body">
<h3>{course.title}</h3>
<p className="course-meta">
Von <strong>{course.authorName}</strong>
{cats ? <> · {cats}</> : null}
</p>
<div className="course-rating" aria-label="Bewertung">
{course.ratingCount > 0 ? (
<>
<span className="stars"></span> {course.ratingAverage.toFixed(2)} ({course.ratingCount})
</>
) : (
<span className="muted">Noch keine Bewertungen</span>
)}
</div>
{!isFree && (
<p className="course-price">
{formatMoney(course.priceCents, course.currency)}
{priceSuffix ? ` ${priceSuffix}` : ""}
</p>
)}
</div>
<div className="course-card-actions">
{enrolled && first ? (
<Link href={first} className="btn btn-primary">
Mit dem Lernen beginnen
</Link>
) : enrolled ? (
<span className="muted">Keine Lektionen</span>
) : isFree ? (
<Link href={`/kurse/${course.slug}`} className="btn btn-primary">
In diesen Kurs einschreiben
</Link>
) : (
<Link href={`/kurse/${course.slug}`} className="btn btn-primary">
Details &amp; Kauf
</Link>
)}
</div>
</article>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function EnrollButton({ courseSlug }: { courseSlug: string }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onClick() {
setLoading(true);
setError(null);
const res = await fetch("/api/enroll", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ courseSlug }),
});
const data = (await res.json().catch(() => ({}))) as { error?: string };
setLoading(false);
if (!res.ok) {
setError(data.error ?? "Einschreibung fehlgeschlagen.");
return;
}
router.refresh();
}
return (
<div>
<button type="button" className="btn btn-primary" disabled={loading} onClick={onClick}>
{loading ? "…" : "In diesen Kurs einschreiben"}
</button>
{error ? <p className="error">{error}</p> : null}
</div>
);
}

64
components/login-form.tsx Normal file
View File

@@ -0,0 +1,64 @@
"use client";
import { useSearchParams, useRouter } from "next/navigation";
import { signIn } from "next-auth/react";
import { useState } from "react";
export function LoginForm() {
const search = useSearchParams();
const router = useRouter();
const callbackUrl = search.get("callbackUrl") || "/dashboard";
const [email, setEmail] = useState("lernender@akademie.local");
const [password, setPassword] = useState("devpassword");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
const res = await signIn("credentials", {
email,
password,
redirect: false,
callbackUrl,
});
setLoading(false);
if (res?.error) {
setError("Anmeldung fehlgeschlagen.");
return;
}
router.push(callbackUrl);
router.refresh();
}
return (
<form className="form panel" onSubmit={onSubmit}>
<label>
E-Mail
<input
type="email"
autoComplete="username"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</label>
<label>
Passwort
<input
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
{error ? <p className="error">{error}</p> : null}
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? "…" : "Anmelden"}
</button>
</form>
);
}

View File

@@ -0,0 +1,63 @@
"use client";
import { useState } from "react";
export function PasswordChangeForm() {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [msg, setMsg] = useState<string | null>(null);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setErr(null);
setMsg(null);
const res = await fetch("/api/portal/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ currentPassword, newPassword }),
});
const data = (await res.json().catch(() => ({}))) as { error?: string };
setLoading(false);
if (!res.ok) {
setErr(data.error ?? "Änderung fehlgeschlagen.");
return;
}
setMsg("Passwort wurde aktualisiert.");
setCurrentPassword("");
setNewPassword("");
}
return (
<form className="panel form" onSubmit={onSubmit}>
<label>
Aktuelles Passwort
<input
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
/>
</label>
<label>
Neues Passwort (mind. 8 Zeichen)
<input
type="password"
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
/>
</label>
{err ? <p className="error">{err}</p> : null}
{msg ? <p className="muted">{msg}</p> : null}
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? "…" : "Passwort speichern"}
</button>
</form>
);
}

View File

@@ -0,0 +1,9 @@
"use client";
export function PrintButton() {
return (
<button type="button" className="btn btn-primary no-print" onClick={() => window.print()}>
Drucken / PDF speichern
</button>
);
}

8
components/providers.tsx Normal file
View File

@@ -0,0 +1,8 @@
"use client";
import { SessionProvider } from "next-auth/react";
import type { ReactNode } from "react";
export function Providers({ children }: { children: ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}

View File

@@ -0,0 +1,34 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function RestartCourseButton({ courseId }: { courseId: string }) {
const router = useRouter();
const [loading, setLoading] = useState(false);
async function onClick() {
if (
!confirm(
"Fortschritt und Zertifikat für diesen Kurs zurücksetzen? Du kannst danach von vorn beginnen.",
)
) {
return;
}
setLoading(true);
const res = await fetch("/api/portal/restart", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ courseId }),
});
setLoading(false);
if (!res.ok) return;
router.refresh();
}
return (
<button type="button" className="btn btn-danger" disabled={loading} onClick={onClick}>
{loading ? "…" : "Fortschritt zurücksetzen"}
</button>
);
}

View File

@@ -0,0 +1,18 @@
import Link from "next/link";
export function SiteFooter() {
return (
<footer className="site-footer">
<div className="container footer-inner">
<p className="muted">Entwicklungsumgebung Akademie LMS (Tutor-Ersatz).</p>
<nav className="footer-nav">
<Link href="/kurse">Kurse</Link>
<span className="sep">·</span>
<a href="https://akademie.fahrlaessig.com/" target="_blank" rel="noreferrer">
Live-Akademie
</a>
</nav>
</div>
</footer>
);
}

View File

@@ -0,0 +1,39 @@
import Link from "next/link";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
export async function SiteHeader() {
const session = await getServerSession(authOptions);
const isAdmin = session?.user?.role === "ADMIN";
return (
<header className="site-header">
<div className="container header-inner">
<Link href="/" className="logo">
Fahrlässig <span>Motorrad Akademie</span>
</Link>
<nav className="nav">
<Link href="/">Startseite</Link>
<Link href="/kurse">Kurse</Link>
{session ? (
<>
<Link href="/portal">Mitgliederbereich</Link>
{isAdmin ? (
<Link href="/admin" className="badge badge-admin" style={{ padding: "0.45rem 0.75rem" }}>
Admin
</Link>
) : null}
<a href="/api/auth/signout?callbackUrl=/" className="btn btn-ghost">
Abmelden
</a>
</>
) : (
<Link href="/login" className="btn btn-primary">
Login
</Link>
)}
</nav>
</div>
</header>
);
}