Initial commit: FL-Akademie LMS mit Docker, Admin, Portal und Dokumentation.
Made-with: Cursor
This commit is contained in:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
DATABASE_URL="postgresql://akademie:devsecret@localhost:5433/akademie"
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="replace-with-random-32-chars-minimum-in-production"
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.next
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
||||
FROM node:20-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update -y && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --ignore-scripts
|
||||
|
||||
COPY . .
|
||||
RUN npx prisma generate
|
||||
|
||||
RUN chmod +x scripts/docker-entrypoint.sh
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT ["/app/scripts/docker-entrypoint.sh"]
|
||||
100
README.md
Normal file
100
README.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# FL-Akademie (Motorrad-Akademie LMS)
|
||||
|
||||
Private Lernplattform als **Ersatz für Tutor LMS** für die [Fahrlässig Motorrad Akademie](https://akademie.fahrlaessig.com/) – Next.js, PostgreSQL, Prisma, NextAuth. Ziel: eigene Kontrolle über Kurse, Nutzer, Landing Page und Zertifikate ohne WordPress-Plugin-Abhängigkeit.
|
||||
|
||||
**Remote-Repository:** `https://git.loepperts.com/loepperts/FL-Akademie.git`
|
||||
|
||||
---
|
||||
|
||||
## Schnellstart (Docker)
|
||||
|
||||
Voraussetzung: Docker & Docker Compose.
|
||||
|
||||
```bash
|
||||
git clone https://git.loepperts.com/loepperts/FL-Akademie.git
|
||||
cd FL-Akademie
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
- **App:** [http://localhost:3000](http://localhost:3000)
|
||||
- **PostgreSQL (vom Host):** `localhost:5433` → Container-Port 5432 (weniger Konflikte mit lokalem Postgres)
|
||||
|
||||
Beim Start führt der Web-Container aus: `prisma generate`, `prisma migrate deploy`, `prisma db seed`, danach **`next dev`**.
|
||||
|
||||
---
|
||||
|
||||
## Umgebungsvariablen
|
||||
|
||||
Siehe `.env.example`. In `docker-compose.yml` sind für die Entwicklung bereits Werte gesetzt (`DATABASE_URL`, `NEXTAUTH_URL`, `NEXTAUTH_SECRET`). Für Produktion **eigenes starkes `NEXTAUTH_SECRET`** und korrekte **`NEXTAUTH_URL`** (öffentliche HTTPS-URL) setzen.
|
||||
|
||||
---
|
||||
|
||||
## Demo-Zugänge (Seed)
|
||||
|
||||
| Rolle | E-Mail | Passwort |
|
||||
|------------|---------------------------|--------------|
|
||||
| Admin | `admin@akademie.local` | `devpassword` |
|
||||
| Dozent | `matze@akademie.local` | `devpassword` |
|
||||
| Lernender | `lernender@akademie.local`| `devpassword` |
|
||||
|
||||
Der Lernende ist im Demo-Kurs „Modul 1 – Die Fahrschule“ eingeschrieben.
|
||||
|
||||
---
|
||||
|
||||
## Wichtige Routen
|
||||
|
||||
| Bereich | Pfad | Beschreibung |
|
||||
|--------|------|----------------|
|
||||
| Öffentlich | `/` | Startseite (Inhalte aus DB, bearbeitbar im Admin) |
|
||||
| Kurse | `/kurse`, `/kurse/[slug]` | Katalog, Kurssdetail, Einschreibung (kostenlose Kurse) |
|
||||
| Lektionen | `/kurse/[slug]/lektionen/[lessonSlug]` | Lernansicht (Login + Einschreibung nötig) |
|
||||
| Login | `/login` | Anmeldung |
|
||||
| Mitgliederbereich | `/portal` | Fortschritt, Kurse wiederholen, Link zu Zertifikaten |
|
||||
| Konto | `/portal/account` | Passwort ändern |
|
||||
| Zertifikate (Liste) | `/portal/certificates` | Eigene Teilnahmebestätigungen |
|
||||
| Zertifikat (öffentlich) | `/zertifikat/[code]` | Anzeige/Druck mit Verifikationscode |
|
||||
| Administration | `/admin` | Nur Rolle `ADMIN`: Kurse, Nutzer, Landing Page |
|
||||
|
||||
`/dashboard` leitet nach `/portal` um.
|
||||
|
||||
---
|
||||
|
||||
## Dokumentation im Repo
|
||||
|
||||
| Datei | Inhalt |
|
||||
|--------|--------|
|
||||
| [docs/PLAN.md](docs/PLAN.md) | Produktvision, Architektur, Phasen, Sicherheit/Monitoring |
|
||||
| [docs/HANDBUCH.md](docs/HANDBUCH.md) | Betrieb: Admin, Portal, Zertifikate, Datenmodell, Docker-Details |
|
||||
|
||||
---
|
||||
|
||||
## Projektstruktur (kurz)
|
||||
|
||||
```
|
||||
app/ # Next.js App Router (Seiten, API-Routen)
|
||||
components/ # UI-Komponenten
|
||||
lib/ # Prisma-Client, Auth, Landing-Parsing, Zertifikate, Fortschritt
|
||||
prisma/ # schema.prisma, Migrationen, seed.ts
|
||||
scripts/ # docker-entrypoint.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## NPM-Skripte (ohne Docker)
|
||||
|
||||
Node 20+, lokales PostgreSQL, `.env` aus `.env.example`:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx prisma generate
|
||||
npx prisma migrate dev
|
||||
npm run dev
|
||||
```
|
||||
|
||||
`postinstall` führt kein `prisma generate` aus (Docker/CI-freundlich); nach `npm install` immer **`npx prisma generate`** ausführen.
|
||||
|
||||
---
|
||||
|
||||
## Lizenz / Nutzung
|
||||
|
||||
Internes Projekt für die Akademie – keine allgemeine Open-Source-Lizenz festgelegt, sofern nicht separat ergänzt.
|
||||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
32
docker-compose.yml
Normal file
32
docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: akademie
|
||||
POSTGRES_PASSWORD: devsecret
|
||||
POSTGRES_DB: akademie
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U akademie -d akademie"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://akademie:devsecret@db:5432/akademie
|
||||
NEXTAUTH_URL: http://localhost:3000
|
||||
NEXTAUTH_SECRET: dev-secret-min-32-chars-for-next-auth-docker-compose
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
108
docs/HANDBUCH.md
Normal file
108
docs/HANDBUCH.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Handbuch – FL-Akademie LMS
|
||||
|
||||
Technische und organisatorische Dokumentation des **implementierten Stands**. Ergänzt [PLAN.md](PLAN.md) (Vision & Roadmap).
|
||||
|
||||
---
|
||||
|
||||
## 1. Rollen (NextAuth + Prisma)
|
||||
|
||||
| Rolle (`Role`) | Zweck |
|
||||
|----------------|--------|
|
||||
| `LEARNER` | Standard-Lernende |
|
||||
| `INSTRUCTOR` | vorbereitet für spätere Dozenten-Features |
|
||||
| `ADMIN` | voller Zugriff auf `/admin` |
|
||||
|
||||
Die Rolle liegt im **JWT** und wird über `middleware.ts` für `/admin` geprüft. `/portal` ist für **alle angemeldeten** Nutzer.
|
||||
|
||||
---
|
||||
|
||||
## 2. Öffentliche Website
|
||||
|
||||
- **Startseite `/`:** Inhalte (Hero, CTAs, Vorteils-Kacheln) kommen aus der Tabelle **`LandingPage`** (`id = "default"`, Feld `content` als JSON). Fallback-Logik in `lib/landing.ts`, wenn keine oder ungültige Daten vorliegen.
|
||||
- **Kurse:** Veröffentlichte Kurse (`published = true`), Einschreibung über API nur bei **Preis 0** (kostenpflichtige Kurse: Hinweis in UI, Zahlung noch nicht angebunden).
|
||||
|
||||
---
|
||||
|
||||
## 3. Mitgliederbereich (`/portal`)
|
||||
|
||||
- **Übersicht:** Eingeschriebene Kurse, Fortschrittsbalken (abgeschlossene vs. gesamte veröffentlichte Lektionen), Links zum Lernen, zum Kurrikulum, zum Zertifikat (falls vorhanden).
|
||||
- **Kurs wiederholen:** Link startet wieder bei der ersten Lektion; optional **„Fortschritt zurücksetzen“** (löscht `LessonProgress` und ggf. `Certificate` für diesen Kurs).
|
||||
- **Konto `/portal/account`:** Passwort ändern (`POST /api/portal/password` – aktuelles Passwort prüfen, neues hashen mit bcrypt).
|
||||
- **Zertifikate `/portal/certificates`:** Liste ausgestellter Urkunden mit Link zur öffentlichen Ansicht.
|
||||
|
||||
---
|
||||
|
||||
## 4. Administration (`/admin`)
|
||||
|
||||
Nur mit Rolle **`ADMIN`** erreichbar (sonst Redirect nach `/portal`).
|
||||
|
||||
| Route | Funktion |
|
||||
|-------|----------|
|
||||
| `/admin` | Übersicht mit Verweisen |
|
||||
| `/admin/courses` | Alle Kurse (inkl. Entwürfe), Link „Neuer Kurs“ |
|
||||
| `/admin/courses/new` | Neuanlage: Titel, Slug, Beschreibung, Autor-Name, Preis (EUR), Abo-Intervall, Veröffentlicht |
|
||||
| `/admin/courses/[id]/edit` | Stammdaten bearbeiten; **Module** und **Lektionen** hinzufügen (Lektion: optionaler Slug, HTML-Inhalt) |
|
||||
| `/admin/users` | Nutzerliste (ohne Passwort): E-Mail, Name, Rolle, Anzahl Einschreibungen & Zertifikate |
|
||||
| `/admin/landing` | Startseiten-Texte und bis zu 6 Vorteils-Kacheln; **Speichern wirkt sofort** auf `/` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Zertifikate (Certificate of completion)
|
||||
|
||||
- **Ausstellung:** Sobald ein Nutzer **alle veröffentlichten Lektionen** eines Kurses als abgeschlossen markiert hat, legt `lib/certificates.ts` (`syncCertificateForCourse`) einen **`Certificate`**-Datensatz mit eindeutigem **`code`** an (Format z. B. `FA-…`).
|
||||
- **Widerruf:** Entfernen einzelner Lektions-Abschlüsse oder „Fortschritt zurücksetzen“ löscht das Zertifikat wieder, sobald der Kurs nicht mehr vollständig ist.
|
||||
- **Öffentliche Seite:** `/zertifikat/[code]` – Name, Kurstitel, Datum, Verifikationscode; Drucklayout blendet Kopf-/Fußzeile aus (`@media print` in `app/globals.css`).
|
||||
|
||||
Auslöser nach Abschluss: `POST /api/progress` ruft `syncCertificateForCourse` auf.
|
||||
|
||||
---
|
||||
|
||||
## 6. Datenmodell (Prisma)
|
||||
|
||||
Wesentliche Modelle:
|
||||
|
||||
- `User`, `Course`, `CourseModule`, `Lesson`, `Enrollment`, `LessonProgress`
|
||||
- `Category`, `CourseCategory` (Seed / spätere Filter)
|
||||
- **`LandingPage`** – eine Zeile `default` für Startseiten-JSON
|
||||
- **`Certificate`** – `@@unique([userId, courseId])`, `code` global eindeutig
|
||||
|
||||
Migrationen unter `prisma/migrations/`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Docker Compose
|
||||
|
||||
| Service | Beschreibung |
|
||||
|---------|----------------|
|
||||
| `db` | `postgres:16-alpine`, Volume `postgres_data` |
|
||||
| `web` | Build aus `Dockerfile`, Entrypoint `scripts/docker-entrypoint.sh` |
|
||||
|
||||
Umgebungsvariablen im Compose u. a. `NEXT_TELEMETRY_DISABLED=1`.
|
||||
|
||||
**Befehle:**
|
||||
|
||||
```bash
|
||||
docker compose up --build -d # im Hintergrund
|
||||
docker compose logs -f web # Logs
|
||||
docker compose down # stoppen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Sicherheitshinweise (Produktion)
|
||||
|
||||
- `NEXTAUTH_SECRET` und `NEXTAUTH_URL` korrekt setzen.
|
||||
- Credentials-Login ist für Dev/MVP gedacht – für Produktion OAuth oder stärkere Passwortrichtlinien erwägen.
|
||||
- Admin-Oberfläche nicht ohne Absicherung (TLS, Netzwerk) exponieren.
|
||||
|
||||
---
|
||||
|
||||
## 9. Git-Remote
|
||||
|
||||
Standard-Remote nach Klon/Push:
|
||||
|
||||
```text
|
||||
https://git.loepperts.com/loepperts/FL-Akademie.git
|
||||
```
|
||||
|
||||
Branch: `main` (empfohlen).
|
||||
125
docs/PLAN.md
Normal file
125
docs/PLAN.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Motorrad-Akademie LMS – Ersatz für Tutor LMS
|
||||
|
||||
Dieses Dokument ist die **Produkt- und Architekturgrundlage** für eine private, zielorientierte Lernplattform. Sie ersetzt schrittweise [Tutor LMS](https://tutorlms.com/) auf [akademie.fahrlaessig.com](https://akademie.fahrlaessig.com/).
|
||||
|
||||
---
|
||||
|
||||
## 1. Zielbild
|
||||
|
||||
- **Eigentümerschaft:** Code und Daten bei euch, keine Plugin-Lizenzkosten für das LMS-Kernstück.
|
||||
- **Bedienung:** Reduzierte Admin-Oberfläche auf echte Workflows (Kurs anlegen, Lektionen, Zugang, Zahlung).
|
||||
- **Kontinuität:** URLs, Kurslogik und Look sollen zur bestehenden Akademie passen (Migration/Redirects später).
|
||||
- **Skalierung:** Produktionsfähige Basis (API, DB, Jobs), ohne WordPress als Laufzeit für den Lernbereich.
|
||||
|
||||
---
|
||||
|
||||
## 2. Ist-Analyse Akademie (öffentlich)
|
||||
|
||||
| Bereich | Anforderung |
|
||||
|--------|-------------|
|
||||
| Kurse | Karten mit Titel, Autor, Kategorien (kostenlos, Module, Übung der Woche). |
|
||||
| Kurrikulum | `/kurse/.../lektionen/...` – Module/Themen und Lektionen. |
|
||||
| Zugang | Einschreiben vs. „Mit dem Lernen beginnen“. |
|
||||
| Monetarisierung | Abo-Preise, Warenkorb/Demo, kostenpflichtig vs. kostenlos. |
|
||||
| Vertrauen | Bewertungen, Dozentenprofile. |
|
||||
| Marketing | Startseite, Roadmap, Blog/Shop teils außerhalb LMS. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Tutor-LMS-Feature-Parität (phasenweise)
|
||||
|
||||
### Phase A – MVP (Betrieb Akademie)
|
||||
|
||||
- Nutzer: Registrierung, Login, Passwort, Rollen (Lernender, Admin).
|
||||
- Kurse: Slug, Titel, Beschreibung, Thumbnail, Kategorien, veröffentlicht.
|
||||
- Kurrikulum: Kurs → Modul → Lektion (Reihenfolge), Rich-Text-Inhalt.
|
||||
- Einschreibung + Fortschritt (Lektion abgeschlossen).
|
||||
- Zahlungen: vorbereitet (Stripe o. Ä.); **Dev:** kostenlose Einschreibung + Preisfelder in DB.
|
||||
- Öffentliche Kursliste + Kurssicht + Lektionsplayer-Layout.
|
||||
|
||||
### Phase B – später
|
||||
|
||||
- Quizze, Aufgaben, Zertifikate, Prerequisites, Content Drip, Gradebook, erweiterte Analytics, Bundles, Live-Sessions, KI-Inhalte – nur bei konkretem Bedarf.
|
||||
|
||||
---
|
||||
|
||||
## 4. Technische Architektur (Umsetzung in diesem Repo)
|
||||
|
||||
| Komponente | Wahl |
|
||||
|------------|------|
|
||||
| App | Next.js (App Router), TypeScript |
|
||||
| Datenbank | PostgreSQL 16 |
|
||||
| ORM | Prisma |
|
||||
| Auth | NextAuth.js (Credentials, erweiterbar auf OAuth) |
|
||||
| Deployment lokal | Docker Compose (`web` + `db`) |
|
||||
|
||||
Verzeichnisüberblick:
|
||||
|
||||
- `app/` – Routen: Marketing (`/`), Kurse, Lektionen, Login, **Mitgliederbereich `/portal`**, **Admin `/admin`**, öffentliche Zertifikatsseite `/zertifikat/[code]`, API (`/api/...`).
|
||||
- `components/` – UI-Bausteine (Header, Kurskarten, Formulare).
|
||||
- `lib/` – Prisma-Client, Auth, Landing-Content, Zertifikate, Fortschritt, Slugs.
|
||||
- `prisma/schema.prisma` – Datenmodell inkl. `LandingPage`, `Certificate`.
|
||||
- `prisma/seed.ts` – Demo-Daten + Standard-Landing.
|
||||
- `docker-compose.yml` – Postgres (Hostport **5433**) + Web.
|
||||
- `Dockerfile` + `scripts/docker-entrypoint.sh` – Migration, Seed, `next dev`.
|
||||
- `README.md` – Einstieg & Schnellstart.
|
||||
- `docs/HANDBUCH.md` – **implementierter Stand** (Admin, Portal, Zertifikate, Docker).
|
||||
|
||||
---
|
||||
|
||||
## 5. Migration (später, produktiv)
|
||||
|
||||
- Export aus WordPress/Tutor → Import-Skripte ins Zielschema.
|
||||
- Nutzer: Einladung/Passwort-Reset statt unsicherer Passwort-Übernahme.
|
||||
- SEO: 301 von alten `/kurse/`-URLs.
|
||||
- Zahlungen: Abgleich mit Payment-Provider (Kunden-IDs).
|
||||
|
||||
---
|
||||
|
||||
## 6. Sicherheit (Produktion)
|
||||
|
||||
- TLS, Security-Header (CSP iterativ), Rate-Limits auf Login.
|
||||
- OWASP-Basics, keine Secrets im Image, regelmäßige Updates.
|
||||
- RBAC für Admin/Dozent/Support.
|
||||
|
||||
---
|
||||
|
||||
## 7. Monitoring (sekundär, aber vorgesehen)
|
||||
|
||||
- Strukturierte Logs, Metriken (Latenz, 5xx, Jobs), Alerts bei Zahlungs-Webhooks.
|
||||
- Healthchecks für App, DB, Queue.
|
||||
|
||||
---
|
||||
|
||||
## 8. Ausführung – Entwicklung mit Docker Compose
|
||||
|
||||
Voraussetzung: Docker und Docker Compose installiert.
|
||||
|
||||
```bash
|
||||
cd /home/lo/Dokumente/real-akademie-replacement
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
- App: **http://localhost:3000**
|
||||
- Demo-Login (Seed): `admin@akademie.local` / `devpassword`
|
||||
Lernender: `lernender@akademie.local` / `devpassword`
|
||||
|
||||
Umgebungsvariablen siehe `.env.example`. Für reine lokale Entwicklung ohne Docker: Node 20+, `npm install`, Postgres starten, `DATABASE_URL` setzen, `npx prisma generate`, `npx prisma migrate dev`, `npm run dev`.
|
||||
|
||||
**Hinweis Next.js:** Unter demselben dynamischen Pfadsegment (z. B. `/kurse/[slug]/…`) müssen die Parameternamen in allen verschachtelten Routen übereinstimmen (hier durchgängig `slug` für den Kurs).
|
||||
|
||||
---
|
||||
|
||||
## 9. Roadmap dieses Repos
|
||||
|
||||
1. ~~MVP-UI und Datenmodell~~; ~~Admin-UI (Kurse, Nutzer, Landing Page)~~; ~~Portal mit Passwort & Zertifikat~~ – umgesetzt (siehe `docs/HANDBUCH.md`).
|
||||
2. Stripe/Webhooks + echte Abos.
|
||||
3. Migrationstools von Tutor-Export (Inhalte, Nutzer, Zahlungen).
|
||||
4. Phase-B-Features nach Priorität (Quizze, Drip, …).
|
||||
|
||||
---
|
||||
|
||||
## 10. Repository
|
||||
|
||||
- **Git:** `https://git.loepperts.com/loepperts/FL-Akademie.git`
|
||||
- Einstieg für neue Entwickler: zuerst **`README.md`**, dann **`docs/HANDBUCH.md`** und dieses **`docs/PLAN.md`**.
|
||||
54
lib/auth-options.ts
Normal file
54
lib/auth-options.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { NextAuthOptions } from "next-auth";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
trustHost: true,
|
||||
session: { strategy: "jwt", maxAge: 60 * 60 * 24 * 14 },
|
||||
pages: { signIn: "/login" },
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: "E-Mail",
|
||||
credentials: {
|
||||
email: { label: "E-Mail", type: "email" },
|
||||
password: { label: "Passwort", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
const email = credentials?.email?.trim().toLowerCase();
|
||||
const password = credentials?.password;
|
||||
if (!email || !password) return null;
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user) return null;
|
||||
|
||||
const ok = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!ok) return null;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
token.role = (user as { role?: string }).role;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session.user) {
|
||||
session.user.id = token.id as string;
|
||||
session.user.role = (token.role as string) ?? "LEARNER";
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
};
|
||||
50
lib/certificates.ts
Normal file
50
lib/certificates.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getUserCourseProgress } from "@/lib/course-progress";
|
||||
|
||||
function makeCode() {
|
||||
return `FA-${randomBytes(5).toString("hex").toUpperCase()}`;
|
||||
}
|
||||
|
||||
export async function syncCertificateForCourse(userId: string, courseId: string) {
|
||||
const { total, completed } = await getUserCourseProgress(userId, courseId);
|
||||
|
||||
const existing = await prisma.certificate.findUnique({
|
||||
where: { userId_courseId: { userId, courseId } },
|
||||
});
|
||||
|
||||
if (total === 0 || completed < total) {
|
||||
if (existing) {
|
||||
await prisma.certificate.delete({ where: { id: existing.id } });
|
||||
}
|
||||
return { issued: false as const };
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
return { issued: true as const, code: existing.code };
|
||||
}
|
||||
|
||||
let code = makeCode();
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const clash = await prisma.certificate.findUnique({ where: { code } });
|
||||
if (!clash) break;
|
||||
code = makeCode();
|
||||
}
|
||||
|
||||
await prisma.certificate.create({
|
||||
data: { userId, courseId, code },
|
||||
});
|
||||
|
||||
return { issued: true as const, code };
|
||||
}
|
||||
|
||||
export async function clearCourseProgress(userId: string, courseId: string) {
|
||||
const lessons = await prisma.lesson.findMany({
|
||||
where: { module: { courseId } },
|
||||
select: { id: true },
|
||||
});
|
||||
await prisma.lessonProgress.deleteMany({
|
||||
where: { userId, lessonId: { in: lessons.map((l) => l.id) } },
|
||||
});
|
||||
await prisma.certificate.deleteMany({ where: { userId, courseId } });
|
||||
}
|
||||
28
lib/course-progress.ts
Normal file
28
lib/course-progress.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function getCourseLessonStats(courseId: string) {
|
||||
const lessons = await prisma.lesson.count({
|
||||
where: { published: true, module: { courseId } },
|
||||
});
|
||||
return { totalLessons: lessons };
|
||||
}
|
||||
|
||||
export async function getUserCourseProgress(userId: string, courseId: string) {
|
||||
const lessons = await prisma.lesson.findMany({
|
||||
where: { published: true, module: { courseId } },
|
||||
select: { id: true },
|
||||
});
|
||||
const total = lessons.length;
|
||||
if (total === 0) return { total: 0, completed: 0, percent: 0 };
|
||||
|
||||
const completed = await prisma.lessonProgress.count({
|
||||
where: {
|
||||
userId,
|
||||
completedAt: { not: null },
|
||||
lessonId: { in: lessons.map((l) => l.id) },
|
||||
},
|
||||
});
|
||||
|
||||
const percent = Math.round((completed / total) * 100);
|
||||
return { total, completed, percent };
|
||||
}
|
||||
68
lib/course-queries.ts
Normal file
68
lib/course-queries.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function listPublishedCourses() {
|
||||
return prisma.course.findMany({
|
||||
where: { published: true },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
include: {
|
||||
categories: { include: { category: true } },
|
||||
modules: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
lessons: {
|
||||
where: { published: true },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { slug: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCourseBySlug(slug: string) {
|
||||
return prisma.course.findFirst({
|
||||
where: { slug, published: true },
|
||||
include: {
|
||||
categories: { include: { category: true } },
|
||||
modules: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
lessons: { where: { published: true }, orderBy: { sortOrder: "asc" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getLessonContext(courseSlug: string, lessonSlug: string) {
|
||||
const course = await prisma.course.findFirst({
|
||||
where: { slug: courseSlug, published: true },
|
||||
include: {
|
||||
modules: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: {
|
||||
lessons: { where: { published: true }, orderBy: { sortOrder: "asc" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!course) return null;
|
||||
|
||||
for (const mod of course.modules) {
|
||||
const lesson = mod.lessons.find((l) => l.slug === lessonSlug);
|
||||
if (lesson) return { course, module: mod, lesson };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function firstLessonPath(course: {
|
||||
slug: string;
|
||||
modules: { lessons: { slug: string }[] }[];
|
||||
}): string | null {
|
||||
for (const m of course.modules) {
|
||||
const first = m.lessons[0];
|
||||
if (first) return `/kurse/${course.slug}/lektionen/${first.slug}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
21
lib/format.ts
Normal file
21
lib/format.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { BillingInterval } from "@prisma/client";
|
||||
|
||||
export function formatMoney(cents: number, currency: string): string {
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: currency || "EUR",
|
||||
}).format(cents / 100);
|
||||
}
|
||||
|
||||
export function billingLabel(interval: BillingInterval): string | null {
|
||||
switch (interval) {
|
||||
case "MONTH":
|
||||
return "/ Monat";
|
||||
case "QUARTER":
|
||||
return "/ 4 Monate";
|
||||
case "YEAR":
|
||||
return "/ Jahr";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
95
lib/landing.ts
Normal file
95
lib/landing.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export type LandingContentV1 = {
|
||||
version: 1;
|
||||
heroTitle: string;
|
||||
heroLead: string;
|
||||
primaryCta: { label: string; href: string };
|
||||
secondaryCta?: { label: string; href: string };
|
||||
benefitSectionTitle: string;
|
||||
benefits: { title: string; body: string }[];
|
||||
};
|
||||
|
||||
export const defaultLandingContent = (): LandingContentV1 => ({
|
||||
version: 1,
|
||||
heroTitle: "Motorrad fahren von A–Z",
|
||||
heroLead:
|
||||
"Von der Fahrschule zur ersten Serpentine. Tipps, Übungen und Kurse – strukturiert auf einer Plattform.",
|
||||
primaryCta: { label: "Zu den Kursen", href: "/kurse" },
|
||||
secondaryCta: { label: "Mitgliederbereich", href: "/portal" },
|
||||
benefitSectionTitle: "Deine Vorteile",
|
||||
benefits: [
|
||||
{
|
||||
title: "Praxisorientiert",
|
||||
body: "Inhalte, die du auf den Platz und in den Alltag übernehmen kannst – Schritt für Schritt.",
|
||||
},
|
||||
{
|
||||
title: "Von A bis Z",
|
||||
body: "Klare Module statt Wildwuchs: vom ersten Gedanken ans Motorradfahren bis zu gezielten Übungen.",
|
||||
},
|
||||
{
|
||||
title: "Fortschritt & Zertifikat",
|
||||
body: "Behalte deinen Lernstand im Blick und sichere dir nach Abschluss eine Teilnahmebestätigung.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === "object" && v !== null;
|
||||
}
|
||||
|
||||
export function parseLandingContent(raw: unknown): LandingContentV1 {
|
||||
if (!isRecord(raw) || raw.version !== 1) return defaultLandingContent();
|
||||
const heroTitle = typeof raw.heroTitle === "string" ? raw.heroTitle : "";
|
||||
const heroLead = typeof raw.heroLead === "string" ? raw.heroLead : "";
|
||||
const benefitSectionTitle =
|
||||
typeof raw.benefitSectionTitle === "string" ? raw.benefitSectionTitle : "Deine Vorteile";
|
||||
const primaryCta = isRecord(raw.primaryCta)
|
||||
? {
|
||||
label: typeof raw.primaryCta.label === "string" ? raw.primaryCta.label : "Mehr",
|
||||
href: typeof raw.primaryCta.href === "string" ? raw.primaryCta.href : "/kurse",
|
||||
}
|
||||
: { label: "Zu den Kursen", href: "/kurse" };
|
||||
let secondaryCta: LandingContentV1["secondaryCta"];
|
||||
if (isRecord(raw.secondaryCta)) {
|
||||
const l = typeof raw.secondaryCta.label === "string" ? raw.secondaryCta.label : "";
|
||||
const h = typeof raw.secondaryCta.href === "string" ? raw.secondaryCta.href : "";
|
||||
if (l && h) secondaryCta = { label: l, href: h };
|
||||
}
|
||||
const benefitsRaw = Array.isArray(raw.benefits) ? raw.benefits : [];
|
||||
const benefits = benefitsRaw
|
||||
.map((b) => {
|
||||
if (!isRecord(b)) return null;
|
||||
const title = typeof b.title === "string" ? b.title : "";
|
||||
const body = typeof b.body === "string" ? b.body : "";
|
||||
if (!title && !body) return null;
|
||||
return { title, body };
|
||||
})
|
||||
.filter(Boolean) as { title: string; body: string }[];
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
heroTitle: heroTitle || defaultLandingContent().heroTitle,
|
||||
heroLead: heroLead || defaultLandingContent().heroLead,
|
||||
primaryCta,
|
||||
secondaryCta,
|
||||
benefitSectionTitle,
|
||||
benefits: benefits.length ? benefits : defaultLandingContent().benefits,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLandingContent(): Promise<LandingContentV1> {
|
||||
const row = await prisma.landingPage.findUnique({ where: { id: "default" } });
|
||||
if (!row) return defaultLandingContent();
|
||||
return parseLandingContent(row.content);
|
||||
}
|
||||
|
||||
export async function saveLandingContent(content: LandingContentV1) {
|
||||
const json = content as unknown as Prisma.InputJsonValue;
|
||||
await prisma.landingPage.upsert({
|
||||
where: { id: "default" },
|
||||
create: { id: "default", content: json },
|
||||
update: { content: json },
|
||||
});
|
||||
}
|
||||
11
lib/prisma.ts
Normal file
11
lib/prisma.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
15
lib/session-helpers.ts
Normal file
15
lib/session-helpers.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { authOptions } from "@/lib/auth-options";
|
||||
|
||||
export async function requireSession() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id) redirect("/login");
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function requireAdmin() {
|
||||
const session = await requireSession();
|
||||
if (session.user.role !== "ADMIN") redirect("/portal");
|
||||
return session;
|
||||
}
|
||||
12
lib/slug.ts
Normal file
12
lib/slug.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function slugify(input: string): string {
|
||||
const s = input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "");
|
||||
const slug = s
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 96);
|
||||
return slug || "kurs";
|
||||
}
|
||||
47
middleware.ts
Normal file
47
middleware.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
|
||||
export async function middleware(req: NextRequest) {
|
||||
const { pathname } = req.nextUrl;
|
||||
const secret = process.env.NEXTAUTH_SECRET;
|
||||
if (!secret) return NextResponse.next();
|
||||
|
||||
const token = await getToken({ req, secret });
|
||||
|
||||
if (pathname.startsWith("/admin")) {
|
||||
if (!token) {
|
||||
const url = new URL("/login", req.url);
|
||||
url.searchParams.set("callbackUrl", pathname);
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
if (token.role !== "ADMIN") {
|
||||
return NextResponse.redirect(new URL("/portal", req.url));
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/portal")) {
|
||||
if (!token) {
|
||||
const url = new URL("/login", req.url);
|
||||
url.searchParams.set("callbackUrl", pathname);
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/dashboard")) {
|
||||
if (!token) {
|
||||
const url = new URL("/login", req.url);
|
||||
url.searchParams.set("callbackUrl", "/portal");
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
return NextResponse.redirect(new URL("/portal", req.url));
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/admin/:path*", "/portal/:path*", "/dashboard/:path*"],
|
||||
};
|
||||
2
next-env.d.ts
vendored
Normal file
2
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
36
package.json
Normal file
36
package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "akademie-lms",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"db:migrate": "prisma migrate deploy",
|
||||
"db:seed": "prisma db seed",
|
||||
"db:studio": "prisma studio"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.3.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"next": "^15.1.6",
|
||||
"next-auth": "^4.24.11",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^22.13.1",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-config-next": "^15.1.6",
|
||||
"prisma": "^6.3.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
141
prisma/migrations/20250413120000_init/migration.sql
Normal file
141
prisma/migrations/20250413120000_init/migration.sql
Normal file
@@ -0,0 +1,141 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Role" AS ENUM ('LEARNER', 'INSTRUCTOR', 'ADMIN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BillingInterval" AS ENUM ('NONE', 'MONTH', 'QUARTER', 'YEAR');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"role" "Role" NOT NULL DEFAULT 'LEARNER',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Category" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Course" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL DEFAULT '',
|
||||
"thumbnailUrl" TEXT,
|
||||
"published" BOOLEAN NOT NULL DEFAULT false,
|
||||
"priceCents" INTEGER NOT NULL DEFAULT 0,
|
||||
"currency" TEXT NOT NULL DEFAULT 'EUR',
|
||||
"billingInterval" "BillingInterval" NOT NULL DEFAULT 'NONE',
|
||||
"ratingAverage" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||
"ratingCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"authorId" TEXT NOT NULL,
|
||||
"authorName" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Course_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CourseCategory" (
|
||||
"courseId" TEXT NOT NULL,
|
||||
"categoryId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "CourseCategory_pkey" PRIMARY KEY ("courseId","categoryId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CourseModule" (
|
||||
"id" TEXT NOT NULL,
|
||||
"courseId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "CourseModule_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Lesson" (
|
||||
"id" TEXT NOT NULL,
|
||||
"moduleId" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"contentHtml" TEXT NOT NULL DEFAULT '',
|
||||
"videoUrl" TEXT,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"published" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "Lesson_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Enrollment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"courseId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Enrollment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LessonProgress" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"lessonId" TEXT NOT NULL,
|
||||
"completedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "LessonProgress_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Course_slug_key" ON "Course"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Lesson_moduleId_slug_key" ON "Lesson"("moduleId", "slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Enrollment_userId_courseId_key" ON "Enrollment"("userId", "courseId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "LessonProgress_userId_lessonId_key" ON "LessonProgress"("userId", "lessonId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CourseCategory" ADD CONSTRAINT "CourseCategory_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CourseCategory" ADD CONSTRAINT "CourseCategory_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CourseModule" ADD CONSTRAINT "CourseModule_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Lesson" ADD CONSTRAINT "Lesson_moduleId_fkey" FOREIGN KEY ("moduleId") REFERENCES "CourseModule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Enrollment" ADD CONSTRAINT "Enrollment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Enrollment" ADD CONSTRAINT "Enrollment_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LessonProgress" ADD CONSTRAINT "LessonProgress_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LessonProgress" ADD CONSTRAINT "LessonProgress_lessonId_fkey" FOREIGN KEY ("lessonId") REFERENCES "Lesson"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- LandingPage (singleton-style id)
|
||||
CREATE TABLE "LandingPage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"content" JSONB NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "LandingPage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Certificate
|
||||
CREATE TABLE "Certificate" (
|
||||
"id" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"courseId" TEXT NOT NULL,
|
||||
"issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Certificate_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "Certificate_code_key" ON "Certificate"("code");
|
||||
|
||||
CREATE UNIQUE INDEX "Certificate_userId_courseId_key" ON "Certificate"("userId", "courseId");
|
||||
|
||||
ALTER TABLE "Certificate" ADD CONSTRAINT "Certificate_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "Certificate" ADD CONSTRAINT "Certificate_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
||||
135
prisma/schema.prisma
Normal file
135
prisma/schema.prisma
Normal file
@@ -0,0 +1,135 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum Role {
|
||||
LEARNER
|
||||
INSTRUCTOR
|
||||
ADMIN
|
||||
}
|
||||
|
||||
enum BillingInterval {
|
||||
NONE
|
||||
MONTH
|
||||
QUARTER
|
||||
YEAR
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
name String
|
||||
role Role @default(LEARNER)
|
||||
createdAt DateTime @default(now())
|
||||
enrollments Enrollment[]
|
||||
progress LessonProgress[]
|
||||
certificates Certificate[]
|
||||
}
|
||||
|
||||
model Category {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
name String
|
||||
courses CourseCategory[]
|
||||
}
|
||||
|
||||
model Course {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
title String
|
||||
description String @default("")
|
||||
thumbnailUrl String?
|
||||
published Boolean @default(false)
|
||||
priceCents Int @default(0)
|
||||
currency String @default("EUR")
|
||||
billingInterval BillingInterval @default(NONE)
|
||||
ratingAverage Float @default(0)
|
||||
ratingCount Int @default(0)
|
||||
authorId String
|
||||
authorName String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
modules CourseModule[]
|
||||
enrollments Enrollment[]
|
||||
categories CourseCategory[]
|
||||
certificates Certificate[]
|
||||
}
|
||||
|
||||
model LandingPage {
|
||||
id String @id
|
||||
content Json
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Certificate {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
userId String
|
||||
courseId String
|
||||
issuedAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, courseId])
|
||||
}
|
||||
|
||||
model CourseCategory {
|
||||
courseId String
|
||||
categoryId String
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([courseId, categoryId])
|
||||
}
|
||||
|
||||
model CourseModule {
|
||||
id String @id @default(cuid())
|
||||
courseId String
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
title String
|
||||
sortOrder Int @default(0)
|
||||
lessons Lesson[]
|
||||
}
|
||||
|
||||
model Lesson {
|
||||
id String @id @default(cuid())
|
||||
moduleId String
|
||||
module CourseModule @relation(fields: [moduleId], references: [id], onDelete: Cascade)
|
||||
slug String
|
||||
title String
|
||||
contentHtml String @default("")
|
||||
videoUrl String?
|
||||
sortOrder Int @default(0)
|
||||
published Boolean @default(true)
|
||||
progress LessonProgress[]
|
||||
|
||||
@@unique([moduleId, slug])
|
||||
}
|
||||
|
||||
model Enrollment {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
courseId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([userId, courseId])
|
||||
}
|
||||
|
||||
model LessonProgress {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
lessonId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
|
||||
completedAt DateTime?
|
||||
|
||||
@@unique([userId, lessonId])
|
||||
}
|
||||
203
prisma/seed.ts
Normal file
203
prisma/seed.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { PrismaClient, BillingInterval, Role } from "@prisma/client";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { defaultLandingContent } from "../lib/landing";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const passwordHash = await bcrypt.hash("devpassword", 10);
|
||||
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { email: "admin@akademie.local" },
|
||||
update: {},
|
||||
create: {
|
||||
email: "admin@akademie.local",
|
||||
name: "MatzeFix",
|
||||
passwordHash,
|
||||
role: Role.ADMIN,
|
||||
},
|
||||
});
|
||||
|
||||
const instructor = await prisma.user.upsert({
|
||||
where: { email: "matze@akademie.local" },
|
||||
update: {},
|
||||
create: {
|
||||
email: "matze@akademie.local",
|
||||
name: "MatzeFix",
|
||||
passwordHash,
|
||||
role: Role.INSTRUCTOR,
|
||||
},
|
||||
});
|
||||
|
||||
const learner = await prisma.user.upsert({
|
||||
where: { email: "lernender@akademie.local" },
|
||||
update: {},
|
||||
create: {
|
||||
email: "lernender@akademie.local",
|
||||
name: "Demo Lernender",
|
||||
passwordHash,
|
||||
role: Role.LEARNER,
|
||||
},
|
||||
});
|
||||
|
||||
const catKostenlos = await prisma.category.upsert({
|
||||
where: { slug: "kostenlos" },
|
||||
update: {},
|
||||
create: { slug: "kostenlos", name: "kostenlos" },
|
||||
});
|
||||
const catModul1 = await prisma.category.upsert({
|
||||
where: { slug: "modul-1" },
|
||||
update: {},
|
||||
create: { slug: "modul-1", name: "Modul 1" },
|
||||
});
|
||||
const catUebung = await prisma.category.upsert({
|
||||
where: { slug: "uebung-der-woche" },
|
||||
update: {},
|
||||
create: { slug: "uebung-der-woche", name: "Übung der Woche" },
|
||||
});
|
||||
|
||||
const courseSlug = "modul-1-die-fahrschule";
|
||||
let course = await prisma.course.findUnique({ where: { slug: courseSlug } });
|
||||
|
||||
if (!course) {
|
||||
course = await prisma.course.create({
|
||||
data: {
|
||||
slug: courseSlug,
|
||||
title: "Modul 1 – Die Fahrschule",
|
||||
description:
|
||||
"Von der Fahrschulauswahl bis zu den ersten Übungen – strukturiert und praxisnah.",
|
||||
published: true,
|
||||
priceCents: 0,
|
||||
billingInterval: BillingInterval.NONE,
|
||||
ratingAverage: 4.8,
|
||||
ratingCount: 12,
|
||||
authorId: instructor.id,
|
||||
authorName: "MatzeFix",
|
||||
categories: {
|
||||
create: [
|
||||
{ categoryId: catKostenlos.id },
|
||||
{ categoryId: catModul1.id },
|
||||
],
|
||||
},
|
||||
modules: {
|
||||
create: [
|
||||
{
|
||||
title: "Einstieg",
|
||||
sortOrder: 0,
|
||||
lessons: {
|
||||
create: [
|
||||
{
|
||||
slug: "wie-waehle-ich-die-richtige-fahrschule-aus",
|
||||
title: "Wie wähle ich die richtige Fahrschule aus?",
|
||||
sortOrder: 0,
|
||||
contentHtml: `
|
||||
<p>Die Wahl der Fahrschule prägt deinen gesamten Lernweg. Achte auf:</p>
|
||||
<ul>
|
||||
<li>Kommunikation und Transparenz bei Kosten</li>
|
||||
<li>Motorrad-spezifische Erfahrung der Trainer</li>
|
||||
<li>Übungsflächen und realistische Sonderfahrten</li>
|
||||
</ul>
|
||||
`,
|
||||
},
|
||||
{
|
||||
slug: "ausruestung-und-erste-schritte",
|
||||
title: "Ausrüstung und erste Schritte",
|
||||
sortOrder: 1,
|
||||
contentHtml:
|
||||
"<p>Helm, Handschuhe, Protektoren – passend zur Jahreszeit und zum Übungsplatz.</p>",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Grundlagen auf dem Platz",
|
||||
sortOrder: 1,
|
||||
lessons: {
|
||||
create: [
|
||||
{
|
||||
slug: "langsames-fahren-und-balance",
|
||||
title: "Langsames Fahren und Balance",
|
||||
sortOrder: 0,
|
||||
contentHtml:
|
||||
"<p>Übe Schrittgeschwindigkeit, Kupplungsfeinfühlen und Blickführung.</p>",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const demoPaidSlug = "demo-ubung-der-woche";
|
||||
let paid = await prisma.course.findUnique({ where: { slug: demoPaidSlug } });
|
||||
if (!paid) {
|
||||
paid = await prisma.course.create({
|
||||
data: {
|
||||
slug: demoPaidSlug,
|
||||
title: "Demo – Übung der Woche",
|
||||
description: "Beispiel für wiederkehrende Inhalte (Preisfeld für späteres Abo).",
|
||||
published: true,
|
||||
priceCents: 2396,
|
||||
billingInterval: BillingInterval.QUARTER,
|
||||
ratingAverage: 4,
|
||||
ratingCount: 1,
|
||||
authorId: instructor.id,
|
||||
authorName: "MatzeFix",
|
||||
categories: {
|
||||
create: [
|
||||
{ categoryId: catKostenlos.id },
|
||||
{ categoryId: catUebung.id },
|
||||
],
|
||||
},
|
||||
modules: {
|
||||
create: [
|
||||
{
|
||||
title: "Woche 1",
|
||||
sortOrder: 0,
|
||||
lessons: {
|
||||
create: [
|
||||
{
|
||||
slug: "uebung-saubere-blickfuehrung",
|
||||
title: "Übung: Saubere Blickführung",
|
||||
sortOrder: 0,
|
||||
contentHtml:
|
||||
"<p>Blick früh in die Kurve, Kurveneinlage planen, ruhige Handgelenke.</p>",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.enrollment.upsert({
|
||||
where: {
|
||||
userId_courseId: { userId: learner.id, courseId: course.id },
|
||||
},
|
||||
update: {},
|
||||
create: { userId: learner.id, courseId: course.id },
|
||||
});
|
||||
|
||||
await prisma.landingPage.upsert({
|
||||
where: { id: "default" },
|
||||
update: {},
|
||||
create: {
|
||||
id: "default",
|
||||
content: defaultLandingContent() as object,
|
||||
},
|
||||
});
|
||||
|
||||
console.log("Seed OK:", { admin: admin.email, learner: learner.email });
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => prisma.$disconnect())
|
||||
.catch(async (e) => {
|
||||
console.error(e);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
8
scripts/docker-entrypoint.sh
Normal file
8
scripts/docker-entrypoint.sh
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
cd /app
|
||||
echo "Waiting for database..."
|
||||
npx prisma generate
|
||||
npx prisma migrate deploy
|
||||
npx prisma db seed
|
||||
exec npm run dev -- --hostname 0.0.0.0 --port 3000
|
||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
21
types/next-auth.d.ts
vendored
Normal file
21
types/next-auth.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { DefaultSession } from "next-auth";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: DefaultSession["user"] & {
|
||||
id: string;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface User {
|
||||
role?: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
id?: string;
|
||||
role?: string;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user