Initial commit: FL-Akademie LMS mit Docker, Admin, Portal und Dokumentation.
Made-with: Cursor
This commit is contained in:
38
components/complete-lesson-button.tsx
Normal file
38
components/complete-lesson-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
components/course-card.tsx
Normal file
66
components/course-card.tsx
Normal 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 & Kauf
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
36
components/enroll-button.tsx
Normal file
36
components/enroll-button.tsx
Normal 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
64
components/login-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
components/password-change-form.tsx
Normal file
63
components/password-change-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
components/print-button.tsx
Normal file
9
components/print-button.tsx
Normal 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
8
components/providers.tsx
Normal 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>;
|
||||
}
|
||||
34
components/restart-course-button.tsx
Normal file
34
components/restart-course-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
components/site-footer.tsx
Normal file
18
components/site-footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
components/site-header.tsx
Normal file
39
components/site-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user