Initial commit: FL-Akademie LMS mit Docker, Admin, Portal und Dokumentation.
Made-with: Cursor
This commit is contained in:
148
app/admin/courses/[id]/edit/page.tsx
Normal file
148
app/admin/courses/[id]/edit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
153
app/admin/courses/actions.ts
Normal file
153
app/admin/courses/actions.ts
Normal 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`);
|
||||
}
|
||||
54
app/admin/courses/new/page.tsx
Normal file
54
app/admin/courses/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
app/admin/courses/page.tsx
Normal file
64
app/admin/courses/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
app/admin/landing/actions.ts
Normal file
51
app/admin/landing/actions.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth-options";
|
||||
import type { LandingContentV1 } from "@/lib/landing";
|
||||
import { saveLandingContent } from "@/lib/landing";
|
||||
|
||||
async function assertAdmin() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id || session.user.role !== "ADMIN") {
|
||||
throw new Error("Keine Berechtigung.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateLandingAction(formData: FormData) {
|
||||
await assertAdmin();
|
||||
|
||||
const heroTitle = String(formData.get("heroTitle") ?? "").trim();
|
||||
const heroLead = String(formData.get("heroLead") ?? "").trim();
|
||||
const primaryLabel = String(formData.get("primaryLabel") ?? "").trim();
|
||||
const primaryHref = String(formData.get("primaryHref") ?? "").trim();
|
||||
const secondaryLabel = String(formData.get("secondaryLabel") ?? "").trim();
|
||||
const secondaryHref = String(formData.get("secondaryHref") ?? "").trim();
|
||||
const benefitSectionTitle = String(formData.get("benefitSectionTitle") ?? "").trim();
|
||||
|
||||
const benefits: { title: string; body: string }[] = [];
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
const title = String(formData.get(`benefit${i}Title`) ?? "").trim();
|
||||
const body = String(formData.get(`benefit${i}Body`) ?? "").trim();
|
||||
if (title || body) benefits.push({ title, body });
|
||||
}
|
||||
|
||||
const content: LandingContentV1 = {
|
||||
version: 1,
|
||||
heroTitle,
|
||||
heroLead,
|
||||
primaryCta: {
|
||||
label: primaryLabel || "Zu den Kursen",
|
||||
href: primaryHref || "/kurse",
|
||||
},
|
||||
secondaryCta:
|
||||
secondaryLabel && secondaryHref ? { label: secondaryLabel, href: secondaryHref } : undefined,
|
||||
benefitSectionTitle: benefitSectionTitle || "Deine Vorteile",
|
||||
benefits,
|
||||
};
|
||||
|
||||
await saveLandingContent(content);
|
||||
revalidatePath("/");
|
||||
revalidatePath("/admin/landing");
|
||||
}
|
||||
75
app/admin/landing/page.tsx
Normal file
75
app/admin/landing/page.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { getLandingContent } from "@/lib/landing";
|
||||
import { updateLandingAction } from "@/app/admin/landing/actions";
|
||||
|
||||
export default async function AdminLandingPage() {
|
||||
const c = await getLandingContent();
|
||||
const benefits = [...c.benefits];
|
||||
while (benefits.length < 6) benefits.push({ title: "", body: "" });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Startseite bearbeiten</h1>
|
||||
<p className="muted subtitle">
|
||||
Änderungen sind nach dem Speichern sofort auf der öffentlichen Startseite sichtbar.
|
||||
</p>
|
||||
|
||||
<form action={updateLandingAction} className="panel form" style={{ maxWidth: 820 }}>
|
||||
<label>
|
||||
Hero-Titel
|
||||
<input name="heroTitle" defaultValue={c.heroTitle} required />
|
||||
</label>
|
||||
<label>
|
||||
Hero-Text
|
||||
<textarea name="heroLead" defaultValue={c.heroLead} required />
|
||||
</label>
|
||||
|
||||
<div className="stack" style={{ gap: "1rem" }}>
|
||||
<label style={{ flex: "1 1 220px" }}>
|
||||
Primär-Button Text
|
||||
<input name="primaryLabel" defaultValue={c.primaryCta.label} required />
|
||||
</label>
|
||||
<label style={{ flex: "1 1 220px" }}>
|
||||
Primär-Button Link
|
||||
<input name="primaryHref" defaultValue={c.primaryCta.href} required />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="stack" style={{ gap: "1rem" }}>
|
||||
<label style={{ flex: "1 1 220px" }}>
|
||||
Sekundär-Button Text (optional)
|
||||
<input name="secondaryLabel" defaultValue={c.secondaryCta?.label ?? ""} />
|
||||
</label>
|
||||
<label style={{ flex: "1 1 220px" }}>
|
||||
Sekundär-Button Link (optional)
|
||||
<input name="secondaryHref" defaultValue={c.secondaryCta?.href ?? ""} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Abschnittstitel Vorteile
|
||||
<input name="benefitSectionTitle" defaultValue={c.benefitSectionTitle} required />
|
||||
</label>
|
||||
|
||||
{benefits.map((b, idx) => (
|
||||
<div key={idx} className="panel" style={{ padding: "1rem" }}>
|
||||
<div className="muted" style={{ marginBottom: "0.5rem", fontWeight: 700 }}>
|
||||
Vorteil {idx + 1}
|
||||
</div>
|
||||
<label>
|
||||
Titel
|
||||
<input name={`benefit${idx + 1}Title`} defaultValue={b.title} />
|
||||
</label>
|
||||
<label>
|
||||
Text
|
||||
<textarea name={`benefit${idx + 1}Body`} defaultValue={b.body} />
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Speichern
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
app/admin/layout.tsx
Normal file
25
app/admin/layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import Link from "next/link";
|
||||
import { requireAdmin } from "@/lib/session-helpers";
|
||||
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
await requireAdmin();
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="container layout-split">
|
||||
<aside className="side-nav" aria-label="Administration">
|
||||
<strong style={{ display: "block", padding: "0.35rem 0.65rem", marginBottom: "0.35rem" }}>
|
||||
Admin
|
||||
</strong>
|
||||
<Link href="/admin">Übersicht</Link>
|
||||
<Link href="/admin/users">Nutzer</Link>
|
||||
<Link href="/admin/landing">Startseite</Link>
|
||||
<Link href="/admin/courses">Kurse</Link>
|
||||
<Link href="/portal">Mitgliederbereich</Link>
|
||||
<Link href="/">Öffentliche Website</Link>
|
||||
</aside>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
24
app/admin/page.tsx
Normal file
24
app/admin/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function AdminHomePage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Administration</h1>
|
||||
<p className="muted subtitle">Verwaltung der Akademie-Inhalte und Nutzer.</p>
|
||||
<div className="course-grid">
|
||||
<Link href="/admin/courses" className="panel" style={{ display: "block" }}>
|
||||
<h2 style={{ marginTop: 0 }}>Kurse</h2>
|
||||
<p className="muted">Neue Kurse anlegen und bestehende bearbeiten.</p>
|
||||
</Link>
|
||||
<Link href="/admin/landing" className="panel" style={{ display: "block" }}>
|
||||
<h2 style={{ marginTop: 0 }}>Startseite</h2>
|
||||
<p className="muted">Hero, Texte und Vorteils-Kacheln der Landing Page.</p>
|
||||
</Link>
|
||||
<Link href="/admin/users" className="panel" style={{ display: "block" }}>
|
||||
<h2 style={{ marginTop: 0 }}>Nutzer</h2>
|
||||
<p className="muted">Übersicht über registrierte Accounts.</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
app/admin/users/page.tsx
Normal file
50
app/admin/users/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export default async function AdminUsersPage() {
|
||||
const users = await prisma.user.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
_count: { select: { enrollments: true, certificates: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Nutzer</h1>
|
||||
<p className="muted subtitle">{users.length} Accounts</p>
|
||||
<div className="table-wrap">
|
||||
<table className="simple">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Rolle</th>
|
||||
<th>Kurse</th>
|
||||
<th>Zertifikate</th>
|
||||
<th>Seit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>{u.name}</td>
|
||||
<td>{u.email}</td>
|
||||
<td>
|
||||
<span className="badge">{u.role}</span>
|
||||
</td>
|
||||
<td>{u._count.enrollments}</td>
|
||||
<td>{u._count.certificates}</td>
|
||||
<td className="muted">{u.createdAt.toLocaleDateString("de-DE")}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
app/api/auth/[...nextauth]/route.ts
Normal file
6
app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import NextAuth from "next-auth";
|
||||
import { authOptions } from "@/lib/auth-options";
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
38
app/api/enroll/route.ts
Normal file
38
app/api/enroll/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth-options";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await req.json().catch(() => null)) as { courseSlug?: string } | null;
|
||||
const courseSlug = body?.courseSlug?.trim();
|
||||
if (!courseSlug) {
|
||||
return NextResponse.json({ error: "courseSlug fehlt." }, { status: 400 });
|
||||
}
|
||||
|
||||
const course = await prisma.course.findFirst({
|
||||
where: { slug: courseSlug, published: true },
|
||||
});
|
||||
if (!course) {
|
||||
return NextResponse.json({ error: "Kurs nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
if (course.priceCents > 0) {
|
||||
return NextResponse.json({ error: "payment_required" }, { status: 402 });
|
||||
}
|
||||
|
||||
await prisma.enrollment.upsert({
|
||||
where: {
|
||||
userId_courseId: { userId: session.user.id, courseId: course.id },
|
||||
},
|
||||
update: {},
|
||||
create: { userId: session.user.id, courseId: course.id },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
34
app/api/portal/password/route.ts
Normal file
34
app/api/portal/password/route.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { authOptions } from "@/lib/auth-options";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await req.json().catch(() => null)) as {
|
||||
currentPassword?: string;
|
||||
newPassword?: string;
|
||||
} | null;
|
||||
|
||||
const currentPassword = body?.currentPassword ?? "";
|
||||
const newPassword = body?.newPassword ?? "";
|
||||
if (!currentPassword || newPassword.length < 8) {
|
||||
return NextResponse.json({ error: "Ungültige Eingaben." }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
|
||||
if (!user) return NextResponse.json({ error: "Nutzer nicht gefunden." }, { status: 404 });
|
||||
|
||||
const ok = await bcrypt.compare(currentPassword, user.passwordHash);
|
||||
if (!ok) return NextResponse.json({ error: "Aktuelles Passwort ist falsch." }, { status: 403 });
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, 10);
|
||||
await prisma.user.update({ where: { id: user.id }, data: { passwordHash } });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
25
app/api/portal/restart/route.ts
Normal file
25
app/api/portal/restart/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth-options";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clearCourseProgress } from "@/lib/certificates";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await req.json().catch(() => null)) as { courseId?: string } | null;
|
||||
const courseId = body?.courseId?.trim();
|
||||
if (!courseId) return NextResponse.json({ error: "courseId fehlt." }, { status: 400 });
|
||||
|
||||
const enrollment = await prisma.enrollment.findUnique({
|
||||
where: { userId_courseId: { userId: session.user.id, courseId } },
|
||||
});
|
||||
if (!enrollment) return NextResponse.json({ error: "Nicht eingeschrieben." }, { status: 403 });
|
||||
|
||||
await clearCourseProgress(session.user.id, courseId);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
57
app/api/progress/route.ts
Normal file
57
app/api/progress/route.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth-options";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { syncCertificateForCourse } from "@/lib/certificates";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await req.json().catch(() => null)) as {
|
||||
lessonId?: string;
|
||||
completed?: boolean;
|
||||
} | null;
|
||||
|
||||
const lessonId = body?.lessonId?.trim();
|
||||
if (!lessonId) {
|
||||
return NextResponse.json({ error: "lessonId fehlt." }, { status: 400 });
|
||||
}
|
||||
|
||||
const lesson = await prisma.lesson.findUnique({
|
||||
where: { id: lessonId },
|
||||
include: { module: { include: { course: true } } },
|
||||
});
|
||||
if (!lesson) {
|
||||
return NextResponse.json({ error: "Lektion nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
const courseId = lesson.module.course.id;
|
||||
const enrollment = await prisma.enrollment.findUnique({
|
||||
where: { userId_courseId: { userId: session.user.id, courseId } },
|
||||
});
|
||||
if (!enrollment) {
|
||||
return NextResponse.json({ error: "Nicht eingeschrieben." }, { status: 403 });
|
||||
}
|
||||
|
||||
const completed =
|
||||
typeof body?.completed === "boolean" ? body.completed : true;
|
||||
|
||||
if (completed) {
|
||||
await prisma.lessonProgress.upsert({
|
||||
where: { userId_lessonId: { userId: session.user.id, lessonId } },
|
||||
update: { completedAt: new Date() },
|
||||
create: { userId: session.user.id, lessonId, completedAt: new Date() },
|
||||
});
|
||||
await syncCertificateForCourse(session.user.id, courseId);
|
||||
} else {
|
||||
await prisma.lessonProgress.deleteMany({
|
||||
where: { userId: session.user.id, lessonId },
|
||||
});
|
||||
await syncCertificateForCourse(session.user.id, courseId);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
5
app/dashboard/page.tsx
Normal file
5
app/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function LegacyDashboardRedirect() {
|
||||
redirect("/portal");
|
||||
}
|
||||
505
app/globals.css
Normal file
505
app/globals.css
Normal file
@@ -0,0 +1,505 @@
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--bg-muted: #f4f6f8;
|
||||
--bg-elevated: #ffffff;
|
||||
--border: #e2e8f0;
|
||||
--text: #0f172a;
|
||||
--muted: #475569;
|
||||
--accent: #1d4ed8;
|
||||
--accent-soft: #eff6ff;
|
||||
--accent-hover: #1e40af;
|
||||
--radius: 14px;
|
||||
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||
--shadow-md: 0 10px 30px rgba(15, 23, 42, 0.08);
|
||||
--font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Ubuntu, Cantarell, "Noto Sans",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
background: var(--bg-muted);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.container {
|
||||
width: min(1120px, 100% - 2rem);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(10px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.9rem 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.logo span {
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.nav a:not(.btn) {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.nav a:not(.btn):hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.55rem 1.05rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--text);
|
||||
color: #ffffff;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #111c33;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
border-color: var(--border);
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
border-color: #cbd5e1;
|
||||
background: var(--bg-muted);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
border-color: #fecaca;
|
||||
background: #fff1f2;
|
||||
color: #9f1239;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
border-color: #fca5a5;
|
||||
background: #ffe4e6;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 3.25rem 0 2rem;
|
||||
text-align: center;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f4f6f8 100%);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: clamp(2rem, 4vw, 2.65rem);
|
||||
margin: 0 0 1rem;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.hero .lead {
|
||||
color: var(--muted);
|
||||
font-size: 1.12rem;
|
||||
max-width: 44rem;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 2.5rem 0 3rem;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
margin: 0 0 1.25rem;
|
||||
font-size: 1.45rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.course-grid {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.course-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-elevated);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.course-card-body {
|
||||
padding: 1.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.course-card-body h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.course-meta {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.course-rating {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.stars {
|
||||
color: #ca8a04;
|
||||
}
|
||||
|
||||
.course-price {
|
||||
margin: 0.75rem 0 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.course-card-actions {
|
||||
padding: 0 1.25rem 1.25rem;
|
||||
}
|
||||
|
||||
.course-card-actions .btn-primary {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 2rem;
|
||||
padding: 2rem 0;
|
||||
font-size: 0.9rem;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.footer-inner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer-nav .sep {
|
||||
margin: 0 0.5rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-elevated);
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.two-col {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.two-col {
|
||||
grid-template-columns: 280px 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
.curriculum {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.curriculum li {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.curriculum a {
|
||||
display: block;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.curriculum a:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.curriculum a.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-hover);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.module-title {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.09em;
|
||||
color: var(--muted);
|
||||
margin: 1rem 0 0.35rem;
|
||||
}
|
||||
|
||||
.module-title:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.prose {
|
||||
max-width: 65ch;
|
||||
}
|
||||
|
||||
.prose ul {
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form input,
|
||||
.form textarea,
|
||||
.form select {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.form textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form .error,
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
table.simple {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
table.simple th,
|
||||
table.simple td {
|
||||
padding: 0.65rem 0.85rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
table.simple th {
|
||||
background: var(--bg-muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
border: 1px solid var(--border);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.badge-admin {
|
||||
border-color: #bfdbfe;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.layout-split {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.layout-split {
|
||||
grid-template-columns: 240px 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
.side-nav {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: #ffffff;
|
||||
padding: 0.75rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.side-nav a {
|
||||
display: block;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-radius: 10px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.side-nav a:hover {
|
||||
background: var(--bg-muted);
|
||||
}
|
||||
|
||||
.side-nav a.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-hover);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-muted);
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #1d4ed8, #2563eb);
|
||||
}
|
||||
|
||||
.cert-print {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 2.25rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.cert-print h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
font-size: 1.1rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.cert-print .name {
|
||||
font-size: 2rem;
|
||||
margin: 0.75rem 0;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0 0 0.35rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.site-header,
|
||||
.site-footer,
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #ffffff !important;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
81
app/kurse/[slug]/lektionen/[lessonSlug]/page.tsx
Normal file
81
app/kurse/[slug]/lektionen/[lessonSlug]/page.tsx
Normal 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
90
app/kurse/[slug]/page.tsx
Normal 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
35
app/kurse/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
app/layout.tsx
Normal file
24
app/layout.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/components/providers";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Motorrad Akademie – LMS (Dev)",
|
||||
description: "Private Lernplattform – Entwicklungsumgebung",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<body>
|
||||
<Providers>
|
||||
<SiteHeader />
|
||||
<main>{children}</main>
|
||||
<SiteFooter />
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
16
app/login/page.tsx
Normal file
16
app/login/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Suspense } from "react";
|
||||
import { LoginForm } from "@/components/login-form";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<h1>Anmelden</h1>
|
||||
<p className="muted">Entwicklung: Demo-Zugänge siehe docs/PLAN.md</p>
|
||||
<Suspense fallback={<p className="muted">Laden…</p>}>
|
||||
<LoginForm />
|
||||
</Suspense>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
68
app/page.tsx
Normal file
68
app/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import Link from "next/link";
|
||||
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";
|
||||
import { getLandingContent } from "@/lib/landing";
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
const courses = await listPublishedCourses();
|
||||
const landing = await getLandingContent();
|
||||
|
||||
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="hero">
|
||||
<div className="container">
|
||||
<h1>{landing.heroTitle}</h1>
|
||||
<p className="lead">{landing.heroLead}</p>
|
||||
<div className="hero-actions">
|
||||
<Link href={landing.primaryCta.href} className="btn btn-primary">
|
||||
{landing.primaryCta.label}
|
||||
</Link>
|
||||
{landing.secondaryCta ? (
|
||||
<Link href={landing.secondaryCta.href} className="btn btn-ghost">
|
||||
{landing.secondaryCta.label}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<h2>Unsere Kurse & Module</h2>
|
||||
<div className="course-grid">
|
||||
{courses.map((c) => (
|
||||
<CourseCard key={c.id} course={c} enrolled={enrolledIds.has(c.id)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<h2>{landing.benefitSectionTitle}</h2>
|
||||
<div className="course-grid">
|
||||
{landing.benefits.map((b, idx) => (
|
||||
<div key={idx} className="panel">
|
||||
<h3>{b.title}</h3>
|
||||
<p className="muted">{b.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
18
app/portal/account/page.tsx
Normal file
18
app/portal/account/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { PasswordChangeForm } from "@/components/password-change-form";
|
||||
import { requireSession } from "@/lib/session-helpers";
|
||||
|
||||
export default async function PortalAccountPage() {
|
||||
const session = await requireSession();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Konto</h1>
|
||||
<p className="muted subtitle">
|
||||
Angemeldet als <strong>{session.user.email}</strong>
|
||||
</p>
|
||||
|
||||
<h2 style={{ marginTop: "2rem" }}>Passwort ändern</h2>
|
||||
<PasswordChangeForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
app/portal/certificates/page.tsx
Normal file
54
app/portal/certificates/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/session-helpers";
|
||||
|
||||
export default async function PortalCertificatesPage() {
|
||||
const session = await requireSession();
|
||||
|
||||
const rows = await prisma.certificate.findMany({
|
||||
where: { userId: session.user.id },
|
||||
orderBy: { issuedAt: "desc" },
|
||||
include: { course: { select: { title: true, slug: true } } },
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Zertifikate</h1>
|
||||
<p className="muted subtitle">Teilnahmebestätigungen nach vollständigem Kursabschluss.</p>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div className="panel">
|
||||
<p className="muted">Noch keine Zertifikate – schließe zuerst alle Lektionen eines Kurses ab.</p>
|
||||
<Link href="/portal" className="btn btn-primary">
|
||||
Zu deinen Kursen
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table className="simple">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Kurs</th>
|
||||
<th>Ausgestellt</th>
|
||||
<th>Code</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>{r.course.title}</td>
|
||||
<td className="muted">{r.issuedAt.toLocaleDateString("de-DE")}</td>
|
||||
<td className="muted">{r.code}</td>
|
||||
<td>
|
||||
<Link href={`/zertifikat/${r.code}`}>Ansehen / Drucken</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
app/portal/layout.tsx
Normal file
25
app/portal/layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import Link from "next/link";
|
||||
import { requireSession } from "@/lib/session-helpers";
|
||||
|
||||
export default async function PortalLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await requireSession();
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="container layout-split">
|
||||
<aside className="side-nav" aria-label="Mitgliederbereich">
|
||||
<strong style={{ display: "block", padding: "0.35rem 0.65rem", marginBottom: "0.35rem" }}>
|
||||
Mitgliederbereich
|
||||
</strong>
|
||||
<Link href="/portal">Übersicht</Link>
|
||||
<Link href="/portal/account">Konto & Passwort</Link>
|
||||
<Link href="/portal/certificates">Zertifikate</Link>
|
||||
<Link href="/kurse">Kurskatalog</Link>
|
||||
{session.user.role === "ADMIN" ? <Link href="/admin">Administration</Link> : null}
|
||||
<Link href="/">Startseite</Link>
|
||||
</aside>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
93
app/portal/page.tsx
Normal file
93
app/portal/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { requireSession } from "@/lib/session-helpers";
|
||||
import { firstLessonPath } from "@/lib/course-queries";
|
||||
import { getUserCourseProgress } from "@/lib/course-progress";
|
||||
import { RestartCourseButton } from "@/components/restart-course-button";
|
||||
|
||||
export default async function PortalHomePage() {
|
||||
const session = await requireSession();
|
||||
|
||||
const enrollments = await prisma.enrollment.findMany({
|
||||
where: { userId: session.user.id },
|
||||
include: {
|
||||
course: {
|
||||
include: {
|
||||
modules: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
lessons: {
|
||||
where: { published: true },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { slug: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
const certs = await prisma.certificate.findMany({
|
||||
where: { userId: session.user.id },
|
||||
select: { courseId: true, code: true },
|
||||
});
|
||||
const certByCourse = new Map(certs.map((c) => [c.courseId, c.code]));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Hallo{session.user.name ? `, ${session.user.name}` : ""}!</h1>
|
||||
<p className="muted subtitle">Deine Kurse, Fortschritt und nächste Schritte.</p>
|
||||
|
||||
{enrollments.length === 0 ? (
|
||||
<div className="panel">
|
||||
<p>Du bist noch in keinem Kurs eingeschrieben.</p>
|
||||
<Link href="/kurse" className="btn btn-primary">
|
||||
Kurse entdecken
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="course-grid" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))" }}>
|
||||
{await Promise.all(
|
||||
enrollments.map(async (e) => {
|
||||
const p = await getUserCourseProgress(session.user.id, e.course.id);
|
||||
const start = firstLessonPath(e.course);
|
||||
const cert = certByCourse.get(e.course.id);
|
||||
|
||||
return (
|
||||
<div key={e.id} className="panel">
|
||||
<h2 style={{ marginTop: 0, fontSize: "1.15rem" }}>{e.course.title}</h2>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
Fortschritt: {p.completed}/{p.total} Lektionen ({p.percent}%)
|
||||
</p>
|
||||
<div className="progress" aria-hidden="true">
|
||||
<span style={{ width: `${p.percent}%` }} />
|
||||
</div>
|
||||
<div className="stack" style={{ marginTop: "1rem" }}>
|
||||
{start ? (
|
||||
<Link href={start} className="btn btn-primary">
|
||||
{p.percent >= 100 ? "Kurs wiederholen" : "Weiterlernen"}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="muted">Keine Lektionen</span>
|
||||
)}
|
||||
<Link href={`/kurse/${e.course.slug}`} className="btn btn-ghost">
|
||||
Kurrikulum
|
||||
</Link>
|
||||
{cert ? (
|
||||
<Link href={`/zertifikat/${cert}`} className="btn btn-ghost">
|
||||
Zertifikat
|
||||
</Link>
|
||||
) : null}
|
||||
<RestartCourseButton courseId={e.course.id} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
app/zertifikat/[code]/page.tsx
Normal file
46
app/zertifikat/[code]/page.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { PrintButton } from "@/components/print-button";
|
||||
|
||||
type Props = { params: Promise<{ code: string }> };
|
||||
|
||||
export default async function CertificatePublicPage({ params }: Props) {
|
||||
const { code } = await params;
|
||||
const cert = await prisma.certificate.findUnique({
|
||||
where: { code },
|
||||
include: {
|
||||
course: { select: { title: true } },
|
||||
user: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
if (!cert) notFound();
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<div className="cert-print">
|
||||
<p className="muted" style={{ margin: 0 }}>
|
||||
Fahrlässig Motorrad Akademie
|
||||
</p>
|
||||
<h1>Teilnahmebestätigung</h1>
|
||||
<p className="muted" style={{ marginTop: "0.75rem" }}>
|
||||
Hiermit bestätigen wir, dass
|
||||
</p>
|
||||
<div className="name">{cert.user.name}</div>
|
||||
<p style={{ margin: "0.75rem 0 0", fontSize: "1.1rem" }}>
|
||||
den Online-Kurs <strong>{cert.course.title}</strong> vollständig absolviert hat.
|
||||
</p>
|
||||
<p className="muted" style={{ marginTop: "1.25rem" }}>
|
||||
Ausstellungsdatum: {cert.issuedAt.toLocaleDateString("de-DE")}
|
||||
</p>
|
||||
<p className="muted" style={{ marginTop: "0.5rem", fontSize: "0.9rem" }}>
|
||||
Verifikationscode: <span style={{ fontFamily: "ui-monospace, monospace" }}>{cert.code}</span>
|
||||
</p>
|
||||
<div style={{ marginTop: "1.5rem" }}>
|
||||
<PrintButton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user