first commit
This commit is contained in:
16
app/admin/page.tsx
Normal file
16
app/admin/page.tsx
Normal 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 />;
|
||||
}
|
||||
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";
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
28
app/api/events/[id]/route.ts
Normal file
28
app/api/events/[id]/route.ts
Normal 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
63
app/api/events/route.ts
Normal 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 });
|
||||
}
|
||||
42
app/api/ical/[token]/route.ts
Normal file
42
app/api/ical/[token]/route.ts
Normal 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
30
app/api/register/route.ts
Normal 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 });
|
||||
}
|
||||
60
app/api/views/[id]/items/route.ts
Normal file
60
app/api/views/[id]/items/route.ts
Normal 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
43
app/api/views/route.ts
Normal 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
24
app/globals.css
Normal 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
32
app/layout.tsx
Normal 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
57
app/login/page.tsx
Normal 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
24
app/page.tsx
Normal 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
8
app/providers.tsx
Normal 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
77
app/register/page.tsx
Normal 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
16
app/views/page.tsx
Normal 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 />;
|
||||
}
|
||||
Reference in New Issue
Block a user