Initial commit: FL-Akademie LMS mit Docker, Admin, Portal und Dokumentation.
Made-with: Cursor
This commit is contained in:
148
app/admin/courses/[id]/edit/page.tsx
Normal file
148
app/admin/courses/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
createLessonAction,
|
||||
createModuleAction,
|
||||
updateCourseAction,
|
||||
} from "@/app/admin/courses/actions";
|
||||
|
||||
type Props = { params: Promise<{ id: string }> };
|
||||
|
||||
export default async function AdminEditCoursePage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
const course = await prisma.course.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
modules: {
|
||||
orderBy: { sortOrder: "asc" },
|
||||
include: { lessons: { orderBy: { sortOrder: "asc" } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!course) notFound();
|
||||
|
||||
const priceEuros = (course.priceCents / 100).toFixed(2);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="muted">
|
||||
<Link href="/admin/courses">← Alle Kurse</Link> ·{" "}
|
||||
<Link href={`/kurse/${course.slug}`}>Öffentliche Kursseite</Link>
|
||||
</p>
|
||||
<h1 className="page-title">Kurs bearbeiten</h1>
|
||||
<p className="muted subtitle">{course.title}</p>
|
||||
|
||||
<form action={updateCourseAction.bind(null, course.id)} className="panel form" style={{ maxWidth: 820 }}>
|
||||
<h2 style={{ marginTop: 0 }}>Stammdaten</h2>
|
||||
<label>
|
||||
Titel
|
||||
<input name="title" defaultValue={course.title} required />
|
||||
</label>
|
||||
<label>
|
||||
Slug
|
||||
<input name="slug" defaultValue={course.slug} required />
|
||||
</label>
|
||||
<label>
|
||||
Beschreibung
|
||||
<textarea name="description" defaultValue={course.description} />
|
||||
</label>
|
||||
<label>
|
||||
Anzeige-Autor
|
||||
<input name="authorName" defaultValue={course.authorName} required />
|
||||
</label>
|
||||
<label>
|
||||
Preis (EUR)
|
||||
<input name="priceEuros" type="number" step="0.01" min="0" defaultValue={priceEuros} />
|
||||
</label>
|
||||
<label>
|
||||
Abrechnungsintervall
|
||||
<select name="billingInterval" defaultValue={course.billingInterval}>
|
||||
<option value="NONE">Einmal / kein Abo</option>
|
||||
<option value="MONTH">Monatlich</option>
|
||||
<option value="QUARTER">Vierteljährlich</option>
|
||||
<option value="YEAR">Jährlich</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="stack" style={{ alignItems: "center" }}>
|
||||
<span style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
|
||||
<input type="checkbox" name="published" defaultChecked={course.published} /> Veröffentlicht
|
||||
</span>
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Speichern
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="panel" style={{ marginTop: "1.25rem" }}>
|
||||
<h2 style={{ marginTop: 0 }}>Neues Modul</h2>
|
||||
<form action={createModuleAction.bind(null, course.id)} className="form" style={{ maxWidth: 720 }}>
|
||||
<label>
|
||||
Modultitel
|
||||
<input name="title" required />
|
||||
</label>
|
||||
<button type="submit" className="btn btn-ghost">
|
||||
Modul hinzufügen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="panel" style={{ marginTop: "1.25rem" }}>
|
||||
<h2 style={{ marginTop: 0 }}>Neue Lektion</h2>
|
||||
<form action={createLessonAction.bind(null, course.id)} className="form" style={{ maxWidth: 720 }}>
|
||||
<label>
|
||||
Modul
|
||||
<select name="moduleId" required defaultValue={course.modules[0]?.id ?? ""}>
|
||||
<option value="" disabled>
|
||||
Modul wählen…
|
||||
</option>
|
||||
{course.modules.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Lektionstitel
|
||||
<input name="lessonTitle" required />
|
||||
</label>
|
||||
<label>
|
||||
Slug (optional)
|
||||
<input name="lessonSlug" placeholder="wird sonst aus dem Titel erzeugt" />
|
||||
</label>
|
||||
<label>
|
||||
Inhalt (HTML)
|
||||
<textarea name="contentHtml" placeholder="<p>…</p>" />
|
||||
</label>
|
||||
<button type="submit" className="btn btn-ghost">
|
||||
Lektion hinzufügen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="panel" style={{ marginTop: "1.25rem" }}>
|
||||
<h2 style={{ marginTop: 0 }}>Kurrikulum</h2>
|
||||
{course.modules.length === 0 ? (
|
||||
<p className="muted">Noch keine Module.</p>
|
||||
) : (
|
||||
<ul className="curriculum">
|
||||
{course.modules.map((m) => (
|
||||
<li key={m.id}>
|
||||
<div className="module-title">{m.title}</div>
|
||||
<ul className="curriculum">
|
||||
{m.lessons.map((l) => (
|
||||
<li key={l.id}>
|
||||
<Link href={`/kurse/${course.slug}/lektionen/${l.slug}`}>{l.title}</Link>{" "}
|
||||
<span className="muted">({l.slug})</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
153
app/admin/courses/actions.ts
Normal file
153
app/admin/courses/actions.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth-options";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { slugify } from "@/lib/slug";
|
||||
import { BillingInterval } from "@prisma/client";
|
||||
|
||||
async function assertAdmin() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id || session.user.role !== "ADMIN") {
|
||||
throw new Error("Keine Berechtigung.");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
function parseBilling(v: FormDataEntryValue | null): BillingInterval {
|
||||
const s = String(v ?? "NONE");
|
||||
if (s === "MONTH" || s === "QUARTER" || s === "YEAR") return s;
|
||||
return BillingInterval.NONE;
|
||||
}
|
||||
|
||||
export async function createCourseAction(formData: FormData) {
|
||||
const session = await assertAdmin();
|
||||
const title = String(formData.get("title") ?? "").trim();
|
||||
if (!title) redirect("/admin/courses/new?error=title");
|
||||
|
||||
let slug = String(formData.get("slug") ?? "").trim();
|
||||
if (!slug) slug = slugify(title);
|
||||
|
||||
const exists = await prisma.course.findUnique({ where: { slug } });
|
||||
if (exists) redirect(`/admin/courses/new?error=slug`);
|
||||
|
||||
const description = String(formData.get("description") ?? "").trim();
|
||||
const authorName = String(formData.get("authorName") ?? "").trim() || session.user.name || "Admin";
|
||||
const published = String(formData.get("published") ?? "") === "on";
|
||||
const priceEuros = Number(String(formData.get("priceEuros") ?? "0").replace(",", "."));
|
||||
const priceCents = Number.isFinite(priceEuros) ? Math.max(0, Math.round(priceEuros * 100)) : 0;
|
||||
|
||||
const course = await prisma.course.create({
|
||||
data: {
|
||||
slug,
|
||||
title,
|
||||
description,
|
||||
published,
|
||||
priceCents,
|
||||
billingInterval: parseBilling(formData.get("billingInterval")),
|
||||
authorId: session.user.id,
|
||||
authorName,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/kurse");
|
||||
revalidatePath("/admin/courses");
|
||||
redirect(`/admin/courses/${course.id}/edit`);
|
||||
}
|
||||
|
||||
export async function updateCourseAction(courseId: string, formData: FormData) {
|
||||
const session = await assertAdmin();
|
||||
const title = String(formData.get("title") ?? "").trim();
|
||||
if (!title) redirect(`/admin/courses/${courseId}/edit?error=title`);
|
||||
|
||||
let slug = String(formData.get("slug") ?? "").trim();
|
||||
if (!slug) slug = slugify(title);
|
||||
|
||||
const clash = await prisma.course.findFirst({
|
||||
where: { slug, NOT: { id: courseId } },
|
||||
});
|
||||
if (clash) redirect(`/admin/courses/${courseId}/edit?error=slug`);
|
||||
|
||||
const description = String(formData.get("description") ?? "").trim();
|
||||
const authorName = String(formData.get("authorName") ?? "").trim() || session.user.name || "Admin";
|
||||
const published = String(formData.get("published") ?? "") === "on";
|
||||
const priceEuros = Number(String(formData.get("priceEuros") ?? "0").replace(",", "."));
|
||||
const priceCents = Number.isFinite(priceEuros) ? Math.max(0, Math.round(priceEuros * 100)) : 0;
|
||||
|
||||
await prisma.course.update({
|
||||
where: { id: courseId },
|
||||
data: {
|
||||
slug,
|
||||
title,
|
||||
description,
|
||||
published,
|
||||
priceCents,
|
||||
billingInterval: parseBilling(formData.get("billingInterval")),
|
||||
authorName,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/kurse");
|
||||
revalidatePath("/admin/courses");
|
||||
revalidatePath(`/kurse/${slug}`);
|
||||
redirect(`/admin/courses/${courseId}/edit?saved=1`);
|
||||
}
|
||||
|
||||
export async function createModuleAction(courseId: string, formData: FormData) {
|
||||
await assertAdmin();
|
||||
const title = String(formData.get("title") ?? "").trim();
|
||||
if (!title) redirect(`/admin/courses/${courseId}/edit?error=module`);
|
||||
|
||||
const max = await prisma.courseModule.aggregate({
|
||||
where: { courseId },
|
||||
_max: { sortOrder: true },
|
||||
});
|
||||
const sortOrder = (max._max.sortOrder ?? -1) + 1;
|
||||
|
||||
await prisma.courseModule.create({
|
||||
data: { courseId, title, sortOrder },
|
||||
});
|
||||
|
||||
revalidatePath(`/admin/courses/${courseId}/edit`);
|
||||
redirect(`/admin/courses/${courseId}/edit`);
|
||||
}
|
||||
|
||||
export async function createLessonAction(courseId: string, formData: FormData) {
|
||||
await assertAdmin();
|
||||
const moduleId = String(formData.get("moduleId") ?? "").trim();
|
||||
const title = String(formData.get("lessonTitle") ?? "").trim();
|
||||
if (!moduleId || !title) redirect(`/admin/courses/${courseId}/edit?error=lesson`);
|
||||
|
||||
const mod = await prisma.courseModule.findFirst({
|
||||
where: { id: moduleId, courseId },
|
||||
});
|
||||
if (!mod) redirect(`/admin/courses/${courseId}/edit?error=lesson`);
|
||||
|
||||
let slug = String(formData.get("lessonSlug") ?? "").trim();
|
||||
if (!slug) slug = slugify(title);
|
||||
|
||||
const contentHtml = String(formData.get("contentHtml") ?? "").trim();
|
||||
|
||||
const max = await prisma.lesson.aggregate({
|
||||
where: { moduleId },
|
||||
_max: { sortOrder: true },
|
||||
});
|
||||
const sortOrder = (max._max.sortOrder ?? -1) + 1;
|
||||
|
||||
const existing = await prisma.lesson.findUnique({
|
||||
where: { moduleId_slug: { moduleId, slug } },
|
||||
});
|
||||
if (existing) redirect(`/admin/courses/${courseId}/edit?error=lessonSlug`);
|
||||
|
||||
await prisma.lesson.create({
|
||||
data: { moduleId, slug, title, contentHtml, sortOrder, published: true },
|
||||
});
|
||||
|
||||
const course = await prisma.course.findUnique({ where: { id: courseId }, select: { slug: true } });
|
||||
if (course?.slug) revalidatePath(`/kurse/${course.slug}`);
|
||||
|
||||
revalidatePath(`/admin/courses/${courseId}/edit`);
|
||||
redirect(`/admin/courses/${courseId}/edit`);
|
||||
}
|
||||
54
app/admin/courses/new/page.tsx
Normal file
54
app/admin/courses/new/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import Link from "next/link";
|
||||
import { createCourseAction } from "@/app/admin/courses/actions";
|
||||
|
||||
export default function AdminNewCoursePage() {
|
||||
return (
|
||||
<div>
|
||||
<p className="muted">
|
||||
<Link href="/admin/courses">← Zurück</Link>
|
||||
</p>
|
||||
<h1 className="page-title">Neuer Kurs</h1>
|
||||
<p className="muted subtitle">Nach dem Anlegen kannst du Module und Lektionen hinzufügen.</p>
|
||||
|
||||
<form action={createCourseAction} className="panel form">
|
||||
<label>
|
||||
Titel
|
||||
<input name="title" required />
|
||||
</label>
|
||||
<label>
|
||||
Slug (optional, sonst aus Titel)
|
||||
<input name="slug" placeholder="z.b. modul-3-sicherheit" />
|
||||
</label>
|
||||
<label>
|
||||
Kurzbeschreibung
|
||||
<textarea name="description" />
|
||||
</label>
|
||||
<label>
|
||||
Anzeige-Autor
|
||||
<input name="authorName" placeholder="z.B. MatzeFix" />
|
||||
</label>
|
||||
<label>
|
||||
Preis (EUR, 0 = kostenlos)
|
||||
<input name="priceEuros" type="number" step="0.01" min="0" defaultValue="0" />
|
||||
</label>
|
||||
<label>
|
||||
Abrechnungsintervall
|
||||
<select name="billingInterval" defaultValue="NONE">
|
||||
<option value="NONE">Einmal / kein Abo</option>
|
||||
<option value="MONTH">Monatlich</option>
|
||||
<option value="QUARTER">Vierteljährlich</option>
|
||||
<option value="YEAR">Jährlich</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="stack" style={{ alignItems: "center" }}>
|
||||
<span style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
|
||||
<input type="checkbox" name="published" /> Veröffentlicht
|
||||
</span>
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Kurs anlegen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
app/admin/courses/page.tsx
Normal file
64
app/admin/courses/page.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { formatMoney, billingLabel } from "@/lib/format";
|
||||
|
||||
export default async function AdminCoursesPage() {
|
||||
const courses = await prisma.course.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
include: { _count: { select: { enrollments: true, modules: true } } },
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="stack" style={{ justifyContent: "space-between", marginBottom: "1rem" }}>
|
||||
<div>
|
||||
<h1 className="page-title">Kurse</h1>
|
||||
<p className="muted subtitle">Alle Kurse inkl. Entwürfe</p>
|
||||
</div>
|
||||
<Link href="/admin/courses/new" className="btn btn-primary">
|
||||
Neuer Kurs
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="simple">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Titel</th>
|
||||
<th>Slug</th>
|
||||
<th>Status</th>
|
||||
<th>Preis</th>
|
||||
<th>Module</th>
|
||||
<th>Einschreibungen</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{courses.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td>{c.title}</td>
|
||||
<td className="muted">{c.slug}</td>
|
||||
<td>{c.published ? "Live" : "Entwurf"}</td>
|
||||
<td>
|
||||
{c.priceCents === 0 ? (
|
||||
"kostenlos"
|
||||
) : (
|
||||
<>
|
||||
{formatMoney(c.priceCents, c.currency)}
|
||||
{billingLabel(c.billingInterval) ? ` ${billingLabel(c.billingInterval)}` : ""}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td>{c._count.modules}</td>
|
||||
<td>{c._count.enrollments}</td>
|
||||
<td>
|
||||
<Link href={`/admin/courses/${c.id}/edit`}>Bearbeiten</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
app/admin/landing/actions.ts
Normal file
51
app/admin/landing/actions.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth-options";
|
||||
import type { LandingContentV1 } from "@/lib/landing";
|
||||
import { saveLandingContent } from "@/lib/landing";
|
||||
|
||||
async function assertAdmin() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.id || session.user.role !== "ADMIN") {
|
||||
throw new Error("Keine Berechtigung.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateLandingAction(formData: FormData) {
|
||||
await assertAdmin();
|
||||
|
||||
const heroTitle = String(formData.get("heroTitle") ?? "").trim();
|
||||
const heroLead = String(formData.get("heroLead") ?? "").trim();
|
||||
const primaryLabel = String(formData.get("primaryLabel") ?? "").trim();
|
||||
const primaryHref = String(formData.get("primaryHref") ?? "").trim();
|
||||
const secondaryLabel = String(formData.get("secondaryLabel") ?? "").trim();
|
||||
const secondaryHref = String(formData.get("secondaryHref") ?? "").trim();
|
||||
const benefitSectionTitle = String(formData.get("benefitSectionTitle") ?? "").trim();
|
||||
|
||||
const benefits: { title: string; body: string }[] = [];
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
const title = String(formData.get(`benefit${i}Title`) ?? "").trim();
|
||||
const body = String(formData.get(`benefit${i}Body`) ?? "").trim();
|
||||
if (title || body) benefits.push({ title, body });
|
||||
}
|
||||
|
||||
const content: LandingContentV1 = {
|
||||
version: 1,
|
||||
heroTitle,
|
||||
heroLead,
|
||||
primaryCta: {
|
||||
label: primaryLabel || "Zu den Kursen",
|
||||
href: primaryHref || "/kurse",
|
||||
},
|
||||
secondaryCta:
|
||||
secondaryLabel && secondaryHref ? { label: secondaryLabel, href: secondaryHref } : undefined,
|
||||
benefitSectionTitle: benefitSectionTitle || "Deine Vorteile",
|
||||
benefits,
|
||||
};
|
||||
|
||||
await saveLandingContent(content);
|
||||
revalidatePath("/");
|
||||
revalidatePath("/admin/landing");
|
||||
}
|
||||
75
app/admin/landing/page.tsx
Normal file
75
app/admin/landing/page.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { getLandingContent } from "@/lib/landing";
|
||||
import { updateLandingAction } from "@/app/admin/landing/actions";
|
||||
|
||||
export default async function AdminLandingPage() {
|
||||
const c = await getLandingContent();
|
||||
const benefits = [...c.benefits];
|
||||
while (benefits.length < 6) benefits.push({ title: "", body: "" });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Startseite bearbeiten</h1>
|
||||
<p className="muted subtitle">
|
||||
Änderungen sind nach dem Speichern sofort auf der öffentlichen Startseite sichtbar.
|
||||
</p>
|
||||
|
||||
<form action={updateLandingAction} className="panel form" style={{ maxWidth: 820 }}>
|
||||
<label>
|
||||
Hero-Titel
|
||||
<input name="heroTitle" defaultValue={c.heroTitle} required />
|
||||
</label>
|
||||
<label>
|
||||
Hero-Text
|
||||
<textarea name="heroLead" defaultValue={c.heroLead} required />
|
||||
</label>
|
||||
|
||||
<div className="stack" style={{ gap: "1rem" }}>
|
||||
<label style={{ flex: "1 1 220px" }}>
|
||||
Primär-Button Text
|
||||
<input name="primaryLabel" defaultValue={c.primaryCta.label} required />
|
||||
</label>
|
||||
<label style={{ flex: "1 1 220px" }}>
|
||||
Primär-Button Link
|
||||
<input name="primaryHref" defaultValue={c.primaryCta.href} required />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="stack" style={{ gap: "1rem" }}>
|
||||
<label style={{ flex: "1 1 220px" }}>
|
||||
Sekundär-Button Text (optional)
|
||||
<input name="secondaryLabel" defaultValue={c.secondaryCta?.label ?? ""} />
|
||||
</label>
|
||||
<label style={{ flex: "1 1 220px" }}>
|
||||
Sekundär-Button Link (optional)
|
||||
<input name="secondaryHref" defaultValue={c.secondaryCta?.href ?? ""} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Abschnittstitel Vorteile
|
||||
<input name="benefitSectionTitle" defaultValue={c.benefitSectionTitle} required />
|
||||
</label>
|
||||
|
||||
{benefits.map((b, idx) => (
|
||||
<div key={idx} className="panel" style={{ padding: "1rem" }}>
|
||||
<div className="muted" style={{ marginBottom: "0.5rem", fontWeight: 700 }}>
|
||||
Vorteil {idx + 1}
|
||||
</div>
|
||||
<label>
|
||||
Titel
|
||||
<input name={`benefit${idx + 1}Title`} defaultValue={b.title} />
|
||||
</label>
|
||||
<label>
|
||||
Text
|
||||
<textarea name={`benefit${idx + 1}Body`} defaultValue={b.body} />
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Speichern
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
app/admin/layout.tsx
Normal file
25
app/admin/layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import Link from "next/link";
|
||||
import { requireAdmin } from "@/lib/session-helpers";
|
||||
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
await requireAdmin();
|
||||
|
||||
return (
|
||||
<section className="section">
|
||||
<div className="container layout-split">
|
||||
<aside className="side-nav" aria-label="Administration">
|
||||
<strong style={{ display: "block", padding: "0.35rem 0.65rem", marginBottom: "0.35rem" }}>
|
||||
Admin
|
||||
</strong>
|
||||
<Link href="/admin">Übersicht</Link>
|
||||
<Link href="/admin/users">Nutzer</Link>
|
||||
<Link href="/admin/landing">Startseite</Link>
|
||||
<Link href="/admin/courses">Kurse</Link>
|
||||
<Link href="/portal">Mitgliederbereich</Link>
|
||||
<Link href="/">Öffentliche Website</Link>
|
||||
</aside>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
24
app/admin/page.tsx
Normal file
24
app/admin/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function AdminHomePage() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Administration</h1>
|
||||
<p className="muted subtitle">Verwaltung der Akademie-Inhalte und Nutzer.</p>
|
||||
<div className="course-grid">
|
||||
<Link href="/admin/courses" className="panel" style={{ display: "block" }}>
|
||||
<h2 style={{ marginTop: 0 }}>Kurse</h2>
|
||||
<p className="muted">Neue Kurse anlegen und bestehende bearbeiten.</p>
|
||||
</Link>
|
||||
<Link href="/admin/landing" className="panel" style={{ display: "block" }}>
|
||||
<h2 style={{ marginTop: 0 }}>Startseite</h2>
|
||||
<p className="muted">Hero, Texte und Vorteils-Kacheln der Landing Page.</p>
|
||||
</Link>
|
||||
<Link href="/admin/users" className="panel" style={{ display: "block" }}>
|
||||
<h2 style={{ marginTop: 0 }}>Nutzer</h2>
|
||||
<p className="muted">Übersicht über registrierte Accounts.</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
app/admin/users/page.tsx
Normal file
50
app/admin/users/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export default async function AdminUsersPage() {
|
||||
const users = await prisma.user.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
_count: { select: { enrollments: true, certificates: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="page-title">Nutzer</h1>
|
||||
<p className="muted subtitle">{users.length} Accounts</p>
|
||||
<div className="table-wrap">
|
||||
<table className="simple">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Rolle</th>
|
||||
<th>Kurse</th>
|
||||
<th>Zertifikate</th>
|
||||
<th>Seit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>{u.name}</td>
|
||||
<td>{u.email}</td>
|
||||
<td>
|
||||
<span className="badge">{u.role}</span>
|
||||
</td>
|
||||
<td>{u._count.enrollments}</td>
|
||||
<td>{u._count.certificates}</td>
|
||||
<td className="muted">{u.createdAt.toLocaleDateString("de-DE")}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user