first commit

This commit is contained in:
2026-01-13 18:08:59 +01:00
commit 5d2630a02f
41 changed files with 1632 additions and 0 deletions

16
app/admin/page.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { getServerSession } from "next-auth";
import AdminPanel from "../../components/AdminPanel";
import { authOptions } from "../../lib/auth";
export default async function AdminPage() {
const session = await getServerSession(authOptions);
if (session?.user?.role !== "ADMIN") {
return (
<div className="rounded border border-dashed border-slate-300 bg-white p-8 text-center">
<p className="text-slate-700">Nur fuer Admins.</p>
</div>
);
}
return <AdminPanel />;
}

View File

@@ -0,0 +1,6 @@
import NextAuth from "next-auth";
import { authOptions } from "../../../../lib/auth";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../../lib/prisma";
import { isAdminSession, requireSession } from "../../../../lib/auth-helpers";
export async function PATCH(request: Request, context: { params: { id: string } }) {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!isAdminSession(session)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const body = await request.json();
const { status } = body || {};
if (!status || !["APPROVED", "REJECTED"].includes(status)) {
return NextResponse.json({ error: "Status ungueltig." }, { status: 400 });
}
const event = await prisma.event.update({
where: { id: context.params.id },
data: { status }
});
return NextResponse.json(event);
}

63
app/api/events/route.ts Normal file
View File

@@ -0,0 +1,63 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../lib/prisma";
import { isAdminSession, requireSession } from "../../../lib/auth-helpers";
export async function GET(request: Request) {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const status = searchParams.get("status");
const isAdmin = isAdminSession(session);
const where = isAdmin
? status
? { status }
: {}
: {
OR: [
{ status: "APPROVED" },
{ createdBy: { email: session.user?.email || "" } }
]
};
const events = await prisma.event.findMany({
where,
orderBy: { startAt: "asc" }
});
return NextResponse.json(events);
}
export async function POST(request: Request) {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const { title, description, location, startAt, endAt } = body || {};
if (!title || !startAt || !endAt) {
return NextResponse.json(
{ error: "Titel, Start und Ende sind erforderlich." },
{ status: 400 }
);
}
const event = await prisma.event.create({
data: {
title,
description: description || null,
location: location || null,
startAt: new Date(startAt),
endAt: new Date(endAt),
status: isAdminSession(session) ? "APPROVED" : "PENDING",
createdBy: { connect: { email: session.user?.email || "" } }
}
});
return NextResponse.json(event, { status: 201 });
}

View File

@@ -0,0 +1,42 @@
import ical from "ical-generator";
import { NextResponse } from "next/server";
import { prisma } from "../../../../lib/prisma";
export async function GET(
_request: Request,
context: { params: { token: string } }
) {
const view = await prisma.userView.findUnique({
where: { token: context.params.token },
include: { items: { include: { event: true } }, user: true }
});
if (!view) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const calendar = ical({
name: `Vereinskalender - ${view.name}`,
timezone: "Europe/Berlin"
});
view.items
.map((item) => item.event)
.filter((event) => event.status === "APPROVED")
.forEach((event) => {
calendar.createEvent({
id: event.id,
summary: event.title,
description: event.description || undefined,
location: event.location || undefined,
start: event.startAt,
end: event.endAt
});
});
return new NextResponse(calendar.toString(), {
headers: {
"Content-Type": "text/calendar; charset=utf-8"
}
});
}

30
app/api/register/route.ts Normal file
View File

@@ -0,0 +1,30 @@
import bcrypt from "bcryptjs";
import { NextResponse } from "next/server";
import { prisma } from "../../../lib/prisma";
import { isAdminEmail } from "../../../lib/auth";
export async function POST(request: Request) {
const body = await request.json();
const { email, name, password } = body || {};
if (!email || !password) {
return NextResponse.json({ error: "Email und Passwort sind erforderlich." }, { status: 400 });
}
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return NextResponse.json({ error: "Account existiert bereits." }, { status: 409 });
}
const passwordHash = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: {
email,
name: name || null,
passwordHash,
role: isAdminEmail(email) ? "ADMIN" : "USER"
}
});
return NextResponse.json({ id: user.id, email: user.email });
}

View File

@@ -0,0 +1,60 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../../../lib/prisma";
import { requireSession } from "../../../../../lib/auth-helpers";
async function ensureOwner(viewId: string, email: string) {
const view = await prisma.userView.findFirst({
where: { id: viewId, user: { email } }
});
return view;
}
export async function POST(request: Request, context: { params: { id: string } }) {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const email = session.user?.email || "";
const view = await ensureOwner(context.params.id, email);
if (!view) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const body = await request.json();
const { eventId } = body || {};
if (!eventId) {
return NextResponse.json({ error: "Event erforderlich." }, { status: 400 });
}
await prisma.userViewItem.create({
data: { viewId: view.id, eventId }
});
return NextResponse.json({ ok: true }, { status: 201 });
}
export async function DELETE(request: Request, context: { params: { id: string } }) {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const email = session.user?.email || "";
const view = await ensureOwner(context.params.id, email);
if (!view) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const body = await request.json();
const { eventId } = body || {};
if (!eventId) {
return NextResponse.json({ error: "Event erforderlich." }, { status: 400 });
}
await prisma.userViewItem.deleteMany({
where: { viewId: view.id, eventId }
});
return NextResponse.json({ ok: true });
}

43
app/api/views/route.ts Normal file
View File

@@ -0,0 +1,43 @@
import { randomUUID } from "crypto";
import { NextResponse } from "next/server";
import { prisma } from "../../../lib/prisma";
import { requireSession } from "../../../lib/auth-helpers";
export async function GET() {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const views = await prisma.userView.findMany({
where: { user: { email: session.user?.email || "" } },
include: { items: { include: { event: true } } },
orderBy: { createdAt: "desc" }
});
return NextResponse.json(views);
}
export async function POST(request: Request) {
const { session } = await requireSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const { name } = body || {};
if (!name) {
return NextResponse.json({ error: "Name erforderlich." }, { status: 400 });
}
const view = await prisma.userView.create({
data: {
name,
token: randomUUID(),
user: { connect: { email: session.user?.email || "" } }
}
});
return NextResponse.json(view, { status: 201 });
}

24
app/globals.css Normal file
View File

@@ -0,0 +1,24 @@
@import "@fontsource/space-grotesk/variable.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: light;
--surface: #ffffff;
--ink: #1f1a17;
--muted: #f7efe4;
--line: #e6dccf;
--accent: #ff6b4a;
--accent-strong: #e24a2b;
}
body {
color: var(--ink);
background:
radial-gradient(65% 80% at 0% 0%, #fff1df 0%, transparent 60%),
radial-gradient(65% 80% at 100% 0%, #e9f0ff 0%, transparent 60%),
#f8f2e9;
font-family: "Space Grotesk", "Segoe UI", sans-serif;
}

32
app/layout.tsx Normal file
View File

@@ -0,0 +1,32 @@
import type { Metadata } from "next";
import "./globals.css";
import Providers from "./providers";
import NavBar from "../components/NavBar";
export const metadata: Metadata = {
title: "Vereinskalender",
description: "Kalenderapp fuer Vereine"
};
export default function RootLayout({
children
}: {
children: React.ReactNode;
}) {
return (
<html lang="de">
<head>
<link rel="stylesheet" href="/vendor/fullcalendar/fullcalendar-core.css" />
<link rel="stylesheet" href="/vendor/fullcalendar/fullcalendar-daygrid.css" />
<link rel="stylesheet" href="/vendor/fullcalendar/fullcalendar-timegrid.css" />
<link rel="stylesheet" href="/vendor/fullcalendar/fullcalendar-list.css" />
</head>
<body>
<Providers>
<NavBar />
<main className="mx-auto max-w-6xl px-4 py-8">{children}</main>
</Providers>
</body>
</html>
);
}

57
app/login/page.tsx Normal file
View File

@@ -0,0 +1,57 @@
"use client";
import { signIn } from "next-auth/react";
import Link from "next/link";
import { useState } from "react";
export default function LoginPage() {
const [error, setError] = useState<string | null>(null);
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
const formData = new FormData(event.currentTarget);
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const result = await signIn("credentials", {
email,
password,
redirect: true,
callbackUrl: "/"
});
if (result?.error) {
setError("Login fehlgeschlagen.");
}
};
return (
<div className="mx-auto max-w-md rounded bg-white p-6 shadow-sm">
<h1 className="text-2xl font-semibold">Login</h1>
<form onSubmit={onSubmit} className="mt-4 space-y-3">
<input
name="email"
type="email"
placeholder="E-Mail"
required
className="w-full rounded border border-slate-300 px-3 py-2"
/>
<input
name="password"
type="password"
placeholder="Passwort"
required
className="w-full rounded border border-slate-300 px-3 py-2"
/>
<button type="submit" className="w-full rounded bg-brand-500 px-4 py-2 text-white">
Anmelden
</button>
</form>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<p className="mt-4 text-sm text-slate-600">
Kein Konto? <Link href="/register" className="text-brand-700">Registrieren</Link>
</p>
</div>
);
}

24
app/page.tsx Normal file
View File

@@ -0,0 +1,24 @@
import CalendarBoard from "../components/CalendarBoard";
import EventForm from "../components/EventForm";
export default function HomePage() {
return (
<div className="space-y-8">
<section className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-brand-500 via-brand-700 to-slate-900 p-8 text-white shadow-lg">
<div className="absolute -right-16 -top-16 h-40 w-40 rounded-full bg-white/20 blur-2xl" />
<div className="absolute -bottom-20 left-10 h-56 w-56 rounded-full bg-white/10 blur-3xl" />
<div className="relative">
<h1 className="text-3xl font-semibold">Vereinskalender</h1>
<p className="mt-2 max-w-2xl text-sm text-white/90">
Termine einstellen, abstimmen und als persoenlichen Kalender abonnieren.
</p>
</div>
</section>
<div className="grid gap-8 lg:grid-cols-[1.2fr_0.8fr]">
<CalendarBoard />
<EventForm />
</div>
</div>
);
}

8
app/providers.tsx Normal file
View File

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

77
app/register/page.tsx Normal file
View File

@@ -0,0 +1,77 @@
"use client";
import Link from "next/link";
import { signIn } from "next-auth/react";
import { useState } from "react";
export default function RegisterPage() {
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
const formData = new FormData(event.currentTarget);
const payload = {
name: formData.get("name"),
email: formData.get("email"),
password: formData.get("password")
};
const response = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!response.ok) {
const data = await response.json();
setError(data.error || "Registrierung fehlgeschlagen.");
return;
}
setDone(true);
await signIn("credentials", {
email: payload.email,
password: payload.password,
callbackUrl: "/"
});
};
return (
<div className="mx-auto max-w-md rounded bg-white p-6 shadow-sm">
<h1 className="text-2xl font-semibold">Registrieren</h1>
<form onSubmit={onSubmit} className="mt-4 space-y-3">
<input
name="name"
type="text"
placeholder="Name"
className="w-full rounded border border-slate-300 px-3 py-2"
/>
<input
name="email"
type="email"
placeholder="E-Mail"
required
className="w-full rounded border border-slate-300 px-3 py-2"
/>
<input
name="password"
type="password"
placeholder="Passwort"
required
className="w-full rounded border border-slate-300 px-3 py-2"
/>
<button type="submit" className="w-full rounded bg-brand-500 px-4 py-2 text-white">
Konto anlegen
</button>
</form>
{done && <p className="mt-3 text-sm text-emerald-600">Account erstellt.</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<p className="mt-4 text-sm text-slate-600">
Schon registriert? <Link href="/login" className="text-brand-700">Login</Link>
</p>
</div>
);
}

16
app/views/page.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { getServerSession } from "next-auth";
import ViewManager from "../../components/ViewManager";
import { authOptions } from "../../lib/auth";
export default async function ViewsPage() {
const session = await getServerSession(authOptions);
if (!session?.user) {
return (
<div className="rounded border border-dashed border-slate-300 bg-white p-8 text-center">
<p className="text-slate-700">Bitte anmelden, um Ansichten zu verwalten.</p>
</div>
);
}
return <ViewManager />;
}