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

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.next
.git
*.log
.env

4
.env Normal file
View File

@@ -0,0 +1,4 @@
DATABASE_URL="file:./dev.db"
NEXTAUTH_SECRET="change-me-in-prod"
NEXTAUTH_URL="http://localhost:3000"
ADMIN_EMAILS="admin@example.com"

4
.env.example Normal file
View File

@@ -0,0 +1,4 @@
DATABASE_URL="file:./dev.db"
NEXTAUTH_SECRET="replace-with-strong-secret"
NEXTAUTH_URL="http://localhost:3000"
ADMIN_EMAILS="admin@example.com"

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
v24.13.0

40
AGENTS.md Normal file
View File

@@ -0,0 +1,40 @@
# Repository Guidelines
## Project Structure & Module Organization
This repository is currently empty (no source, tests, or assets are present). When adding code, keep a simple, discoverable layout such as:
- `src/` for application/library code
- `tests/` or `__tests__/` for automated tests
- `assets/` for static files (images, fixtures)
Keep top-level docs like `README.md` and `AGENTS.md` in the root.
## Build, Test, and Development Commands
No build or test scripts are defined yet. When you add tooling, document the exact commands in `README.md` and keep them consistent. Examples you may add later:
- `npm run dev` for local development
- `npm test` or `pytest` for running tests
- `make build` for production builds
## Coding Style & Naming Conventions
No style configuration is present. When initializing the project, standardize and document:
- Indentation (e.g., 2 spaces for JS/TS, 4 for Python)
- Naming patterns (e.g., `camelCase` for functions, `PascalCase` for classes)
- Formatters/linters (e.g., Prettier, ESLint, Black, Ruff)
Add config files (like `.editorconfig`, `.prettierrc`, or `pyproject.toml`) and keep them in version control.
## Testing Guidelines
No testing framework is configured yet. When you add tests:
- Co-locate tests under `tests/` or alongside modules (e.g., `src/foo.test.ts`)
- Use clear, descriptive test names
- Ensure tests run with a single command and include it in `README.md`
## Commit & Pull Request Guidelines
No Git history is available to infer conventions. Use clear, imperative commit messages (e.g., “Add event model”).
For pull requests, include:
- A short summary of changes
- Linked issue or requirement if available
- Screenshots or logs for UI/behavior changes
## Security & Configuration Tips
If you add secrets or environment settings, use a `.env.example` and keep real secrets out of the repo. Document required environment variables in `README.md`.
## Agent Instructions
Update `README.md` whenever you change workflows, setup steps, environment variables, or Docker behavior so future sessions stay aligned. This file (`AGENTS.md`) provides the contributor guide and lightweight rules for agents working in the repo.

26
Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
FROM node:24-alpine AS base
WORKDIR /app
RUN apk add --no-cache openssl
RUN npm install -g npm@11.7.0
ENV NPM_CONFIG_UPDATE_NOTIFIER=false
FROM base AS deps
COPY package.json package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm \
if [ -f package-lock.json ]; then npm ci; else npm install; fi
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM base AS runner
ENV NODE_ENV=production
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/next.config.js ./next.config.js
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["sh", "-c", "npm run prisma:deploy && npm run start"]

93
README.md Normal file
View File

@@ -0,0 +1,93 @@
# Vereinskalender
State-of-the-art Kalenderapp fuer Vereine mit Admin-Freigaben, persoenlichen Kalenderansichten und iCal-Export. Die App basiert auf Next.js (App Router), Prisma und NextAuth (Credentials).
## Features
- Admins koennen Termine sofort freigeben oder Vorschlaege bestaetigen/ablehnen.
- Mitglieder schlagen Termine vor; Freigaben laufen ueber das Admin-Panel.
- Mehrere Kalenderansichten (Monat, Woche, Liste) mit FullCalendar.
- Eigene Ansichten mit iCal-Abonnement fuer externe Apps (iOS/Android).
## Tech-Stack
- Frontend: Next.js 14 (App Router), React 18, Tailwind CSS, FullCalendar
- Backend: Next.js Route Handlers, Prisma ORM, SQLite
- Auth: NextAuth (Credentials + Prisma Adapter)
- Export: iCal via `ical-generator`
## Projektstruktur
- `app/` - Routen, Layouts und Seiten
- `components/` - UI-Komponenten (Kalender, Admin-Panel, View-Manager)
- `app/api/` - API-Routen (Auth, Events, Views, iCal)
- `prisma/` - Schema und Migrations
- `lib/` - Prisma Client, Auth-Helfer
## Lokale Entwicklung
```bash
npm install
cp .env.example .env
npm run prisma:migrate
npm run dev
```
Open `http://localhost:3000`.
## Konfiguration
In `.env` (lokal) bzw. per Umgebungsvariablen (Docker/Prod):
```
DATABASE_URL="file:./dev.db"
NEXTAUTH_SECRET="replace-with-strong-secret"
NEXTAUTH_URL="http://localhost:3000"
ADMIN_EMAILS="admin@example.com"
```
## Admin-Setup
`ADMIN_EMAILS` (kommagetrennt) steuert, welche Accounts beim Signup als Admin markiert werden.
## Wichtige Befehle
- `npm run dev` - lokale Entwicklung
- `npm run build` - Production Build
- `npm run start` - Server starten (Production)
- `npm run prisma:migrate` - DB Migrationen fuer SQLite (Dev)
- `npm run prisma:deploy` - Schema push (Container-Start)
- `npm run prisma:studio` - Prisma Studio
- `npm run copy:fullcalendar-css` - FullCalendar CSS nach `public/vendor/fullcalendar/` kopieren
## APIs (kurz)
- `POST /api/register` - Registrierung
- `GET /api/events` - Events (Admins sehen alles, User nur eigene + freigegebene)
- `POST /api/events` - Termin vorschlagen/anlegen
- `PATCH /api/events/:id` - Freigeben/Ablehnen (Admin)
- `GET /api/views` - Eigene Ansichten
- `POST /api/views` - Ansicht erstellen
- `POST /api/views/:id/items` - Termin zur Ansicht
- `DELETE /api/views/:id/items` - Termin entfernen
- `GET /api/ical/:token` - iCal Feed der Ansicht
## iCal-Abonnement
Unter "Meine Ansichten" wird fuer jede Ansicht eine iCal-URL erzeugt. Diese kann in Kalender-Apps (iOS/Android) abonniert werden.
## Docker
```bash
cp .env.example .env
docker compose up --build
```
Wichtig fuer persistente Logins:
- `NEXTAUTH_SECRET` in `.env` fix setzen (nicht bei jedem Build wechseln).
- Die SQLite-DB liegt im `data/`-Volume des Containers und bleibt erhalten.
## Schnellere Builds (Best Practices)
- `package-lock.json` committen und im Dockerfile `npm ci` nutzen (bereits vorbereitet).
- BuildKit-Cache nutzen (im Dockerfile aktiv, benoetigt Docker BuildKit).
- Fuer schnelle lokale Iteration: `docker compose -f docker-compose.dev.yml up --build`.
## Sicherheitshinweise
- Keine Secrets committen; `.env` ist in `.dockerignore`.
- Fuer Prod echte Secrets und eine externe DB nutzen.

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 />;
}

90
components/AdminPanel.tsx Normal file
View File

@@ -0,0 +1,90 @@
"use client";
import { useEffect, useState } from "react";
type EventItem = {
id: string;
title: string;
startAt: string;
endAt: string;
status: string;
location?: string | null;
description?: string | null;
};
export default function AdminPanel() {
const [events, setEvents] = useState<EventItem[]>([]);
const [error, setError] = useState<string | null>(null);
const load = async () => {
try {
const response = await fetch("/api/events?status=PENDING");
if (!response.ok) {
throw new Error("Vorschlaege konnten nicht geladen werden.");
}
setEvents(await response.json());
} catch (err) {
setError((err as Error).message);
}
};
useEffect(() => {
load();
}, []);
const updateStatus = async (id: string, status: "APPROVED" | "REJECTED") => {
await fetch(`/api/events/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status })
});
load();
};
return (
<section className="space-y-4">
<h1 className="text-2xl font-semibold">Adminfreigaben</h1>
{error && <p className="text-sm text-red-600">{error}</p>}
{events.length === 0 ? (
<p className="text-slate-600">Keine offenen Vorschlaege.</p>
) : (
<div className="space-y-3">
{events.map((event) => (
<div key={event.id} className="rounded border border-slate-200 bg-white p-4">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-lg font-medium">{event.title}</h2>
<p className="text-sm text-slate-600">
{new Date(event.startAt).toLocaleString()} - {new Date(event.endAt).toLocaleString()}
</p>
{event.location && (
<p className="text-sm text-slate-600">Ort: {event.location}</p>
)}
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => updateStatus(event.id, "APPROVED")}
className="rounded bg-emerald-600 px-3 py-1.5 text-white"
>
Freigeben
</button>
<button
type="button"
onClick={() => updateStatus(event.id, "REJECTED")}
className="rounded bg-red-600 px-3 py-1.5 text-white"
>
Ablehnen
</button>
</div>
</div>
{event.description && (
<p className="mt-2 text-sm text-slate-700">{event.description}</p>
)}
</div>
))}
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,116 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import FullCalendar from "@fullcalendar/react";
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import listPlugin from "@fullcalendar/list";
import interactionPlugin from "@fullcalendar/interaction";
import { useSession } from "next-auth/react";
type EventItem = {
id: string;
title: string;
description?: string | null;
location?: string | null;
startAt: string;
endAt: string;
status: string;
};
export default function CalendarBoard() {
const { data } = useSession();
const [events, setEvents] = useState<EventItem[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const fetchEvents = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch("/api/events");
if (!response.ok) {
throw new Error("Events konnten nicht geladen werden.");
}
const payload = await response.json();
setEvents(payload);
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (data?.user) {
fetchEvents();
}
}, [data?.user]);
const calendarEvents = useMemo(
() =>
events.map((event) => ({
id: event.id,
title: event.title,
start: event.startAt,
end: event.endAt,
extendedProps: {
status: event.status,
location: event.location,
description: event.description
}
})),
[events]
);
if (!data?.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 die Vereinskalender zu sehen.
</p>
</div>
);
}
return (
<section className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Kalender</h2>
<button
type="button"
onClick={fetchEvents}
className="rounded border border-slate-300 px-3 py-1.5 text-sm text-slate-700"
>
Aktualisieren
</button>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
{loading && <p className="text-sm text-slate-500">Lade Termine...</p>}
<div className="rounded bg-white p-4 shadow-sm">
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin, listPlugin, interactionPlugin]}
initialView="dayGridMonth"
headerToolbar={{
left: "prev,next today",
center: "title",
right: "dayGridMonth,timeGridWeek,listWeek"
}}
events={calendarEvents}
eventContent={(arg) => {
const status = arg.event.extendedProps.status;
return (
<div>
<div className="text-sm font-medium">{arg.event.title}</div>
{status !== "APPROVED" && (
<div className="text-xs text-amber-600">{status}</div>
)}
</div>
);
}}
height="auto"
/>
</div>
</section>
);
}

85
components/EventForm.tsx Normal file
View File

@@ -0,0 +1,85 @@
"use client";
import { useState } from "react";
export default function EventForm() {
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setStatus(null);
setError(null);
const formData = new FormData(event.currentTarget);
const payload = {
title: formData.get("title"),
description: formData.get("description"),
location: formData.get("location"),
startAt: formData.get("startAt"),
endAt: formData.get("endAt")
};
try {
const response = await fetch("/api/events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Fehler beim Speichern.");
}
event.currentTarget.reset();
setStatus("Termin vorgeschlagen. Ein Admin bestaetigt ihn.");
} catch (err) {
setError((err as Error).message);
}
};
return (
<section className="rounded bg-white p-4 shadow-sm">
<h2 className="text-lg font-semibold">Termin vorschlagen</h2>
<form onSubmit={onSubmit} className="mt-4 grid gap-3 md:grid-cols-2">
<input
name="title"
placeholder="Titel"
required
className="rounded border border-slate-300 px-3 py-2"
/>
<input
name="location"
placeholder="Ort"
className="rounded border border-slate-300 px-3 py-2"
/>
<input
name="startAt"
type="datetime-local"
required
className="rounded border border-slate-300 px-3 py-2"
/>
<input
name="endAt"
type="datetime-local"
required
className="rounded border border-slate-300 px-3 py-2"
/>
<textarea
name="description"
placeholder="Beschreibung"
className="min-h-[96px] rounded border border-slate-300 px-3 py-2 md:col-span-2"
/>
<button
type="submit"
className="rounded bg-brand-500 px-4 py-2 text-white md:col-span-2"
>
Vorschlag senden
</button>
</form>
{status && <p className="mt-3 text-sm text-emerald-600">{status}</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
</section>
);
}

50
components/NavBar.tsx Normal file
View File

@@ -0,0 +1,50 @@
"use client";
import Link from "next/link";
import { signIn, signOut, useSession } from "next-auth/react";
export default function NavBar() {
const { data } = useSession();
const isAdmin = data?.user?.role === "ADMIN";
return (
<header className="border-b border-slate-200/70 bg-white/80 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
<Link href="/" className="text-lg font-semibold text-slate-900">
Vereinskalender
</Link>
<nav className="flex items-center gap-4 text-sm">
{data?.user && (
<>
<Link href="/views" className="text-slate-700 hover:text-slate-900">
Meine Ansichten
</Link>
{isAdmin && (
<Link href="/admin" className="text-slate-700 hover:text-slate-900">
Admin
</Link>
)}
</>
)}
{data?.user ? (
<button
type="button"
onClick={() => signOut()}
className="rounded bg-slate-900 px-3 py-1.5 text-white"
>
Logout
</button>
) : (
<button
type="button"
onClick={() => signIn()}
className="rounded bg-brand-500 px-3 py-1.5 text-white"
>
Login
</button>
)}
</nav>
</div>
</header>
);
}

199
components/ViewManager.tsx Normal file
View File

@@ -0,0 +1,199 @@
"use client";
import { useEffect, useMemo, useState } from "react";
type EventItem = {
id: string;
title: string;
startAt: string;
endAt: string;
status: string;
};
type ViewItem = {
id: string;
name: string;
token: string;
items: { event: EventItem }[];
};
export default function ViewManager() {
const [views, setViews] = useState<ViewItem[]>([]);
const [events, setEvents] = useState<EventItem[]>([]);
const [selectedView, setSelectedView] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const load = async () => {
try {
const [viewsRes, eventsRes] = await Promise.all([
fetch("/api/views"),
fetch("/api/events?status=APPROVED")
]);
if (!viewsRes.ok || !eventsRes.ok) {
throw new Error("Daten konnten nicht geladen werden.");
}
const viewsData = await viewsRes.json();
const eventsData = await eventsRes.json();
setViews(viewsData);
setEvents(eventsData);
if (viewsData.length > 0 && !selectedView) {
setSelectedView(viewsData[0].id);
}
} catch (err) {
setError((err as Error).message);
}
};
useEffect(() => {
load();
}, []);
const icalBase = typeof window === "undefined" ? "" : window.location.origin;
const activeView = useMemo(
() => views.find((view) => view.id === selectedView) || null,
[views, selectedView]
);
const createView = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const name = formData.get("name");
const response = await fetch("/api/views", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name })
});
if (response.ok) {
event.currentTarget.reset();
load();
}
};
const addItem = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selectedView) return;
const formData = new FormData(event.currentTarget);
const eventId = formData.get("eventId");
await fetch(`/api/views/${selectedView}/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ eventId })
});
load();
};
const removeItem = async (eventId: string) => {
if (!selectedView) return;
await fetch(`/api/views/${selectedView}/items`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ eventId })
});
load();
};
return (
<section className="space-y-6">
<div>
<h1 className="text-2xl font-semibold">Meine Kalenderansichten</h1>
<p className="text-slate-600">
Stelle dir eine persoenliche Terminansicht zusammen und abonnieren sie als iCal.
</p>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-4 rounded bg-white p-4 shadow-sm">
<h2 className="text-lg font-semibold">Neue Ansicht</h2>
<form onSubmit={createView} className="flex gap-2">
<input
name="name"
required
placeholder="z.B. Jugendtermine"
className="flex-1 rounded border border-slate-300 px-3 py-2"
/>
<button className="rounded bg-brand-500 px-3 py-2 text-white" type="submit">
Anlegen
</button>
</form>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">Ansicht waehlen</label>
<select
className="w-full rounded border border-slate-300 px-3 py-2"
value={selectedView || ""}
onChange={(event) => setSelectedView(event.target.value)}
>
{views.map((view) => (
<option key={view.id} value={view.id}>
{view.name}
</option>
))}
</select>
</div>
{activeView && (
<div className="rounded border border-slate-200 bg-slate-50 p-3 text-sm">
<p className="font-medium">iCal URL</p>
<p className="break-all text-slate-700">
{icalBase}/api/ical/{activeView.token}
</p>
</div>
)}
</div>
<div className="space-y-4 rounded bg-white p-4 shadow-sm">
<h2 className="text-lg font-semibold">Termine hinzufuegen</h2>
<form onSubmit={addItem} className="space-y-3">
<select
name="eventId"
className="w-full rounded border border-slate-300 px-3 py-2"
required
>
<option value="" disabled>
Termin waehlen
</option>
{events.map((event) => (
<option key={event.id} value={event.id}>
{event.title} ({new Date(event.startAt).toLocaleDateString()})
</option>
))}
</select>
<button className="w-full rounded bg-slate-900 px-3 py-2 text-white" type="submit">
Zur Ansicht hinzufuegen
</button>
</form>
<div className="space-y-2">
<h3 className="text-sm font-semibold text-slate-700">Inhalte der Ansicht</h3>
{activeView?.items.length ? (
activeView.items.map((item) => (
<div
key={item.event.id}
className="flex items-center justify-between rounded border border-slate-200 px-3 py-2"
>
<span className="text-sm">{item.event.title}</span>
<button
type="button"
className="text-xs text-red-600"
onClick={() => removeItem(item.event.id)}
>
Entfernen
</button>
</div>
))
) : (
<p className="text-sm text-slate-500">Noch keine Termine.</p>
)}
</div>
</div>
</div>
</section>
);
}

17
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,17 @@
services:
app:
build: .
ports:
- "3000:3000"
env_file:
- .env
environment:
DATABASE_URL: "file:./data/dev.db"
volumes:
- ./:/app
- /app/node_modules
- app-data:/app/data
command: npm run dev
volumes:
app-data:

14
docker-compose.yml Normal file
View File

@@ -0,0 +1,14 @@
services:
app:
build: .
ports:
- "3000:3000"
env_file:
- .env
environment:
DATABASE_URL: "file:./data/dev.db"
volumes:
- app-data:/app/data
volumes:
app-data:

15
lib/auth-helpers.ts Normal file
View File

@@ -0,0 +1,15 @@
import { getServerSession } from "next-auth";
import { NextResponse } from "next/server";
import { authOptions } from "./auth";
export async function requireSession() {
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return { session: null, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
}
return { session, response: null };
}
export function isAdminSession(session: { user?: { role?: string } } | null) {
return session?.user?.role === "ADMIN";
}

74
lib/auth.ts Normal file
View File

@@ -0,0 +1,74 @@
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import bcrypt from "bcryptjs";
import type { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { prisma } from "./prisma";
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
providers: [
CredentialsProvider({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
if (!credentials?.email || !credentials.password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: credentials.email }
});
if (!user) {
return null;
}
const valid = await bcrypt.compare(
credentials.password,
user.passwordHash
);
if (!valid) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role
} as any;
}
})
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.role = (user as any).role;
}
return token;
},
async session({ session, token }) {
if (session.user) {
(session.user as any).role = token.role;
}
return session;
}
},
pages: {
signIn: "/login"
}
};
export const isAdminEmail = (email?: string | null) => {
if (!email) return false;
const list = (process.env.ADMIN_EMAILS || "")
.split(",")
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
return list.includes(email.toLowerCase());
};

16
lib/prisma.ts Normal file
View File

@@ -0,0 +1,16 @@
import { PrismaClient } from "@prisma/client";
declare global {
// eslint-disable-next-line no-var
var prisma: PrismaClient | undefined;
}
export const prisma =
global.prisma ||
new PrismaClient({
log: ["warn", "error"]
});
if (process.env.NODE_ENV !== "production") {
global.prisma = prisma;
}

11
next-auth.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
import NextAuth from "next-auth";
declare module "next-auth" {
interface Session {
user?: {
name?: string | null;
email?: string | null;
role?: "ADMIN" | "USER";
};
}
}

5
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

6
next.config.js Normal file
View File

@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true
};
module.exports = nextConfig;

48
package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "vereinskalender",
"version": "0.1.0",
"private": true,
"engines": {
"node": "24.13.0"
},
"scripts": {
"copy:fullcalendar-css": "node scripts/copy-fullcalendar-css.js",
"dev": "npm run copy:fullcalendar-css && next dev",
"build": "npm run copy:fullcalendar-css && prisma generate && next build",
"start": "next start",
"lint": "next lint",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma db push",
"prisma:studio": "prisma studio"
},
"dependencies": {
"@fullcalendar/core": "^6.1.11",
"@fullcalendar/daygrid": "^6.1.11",
"@fullcalendar/interaction": "^6.1.11",
"@fullcalendar/list": "^6.1.11",
"@fullcalendar/react": "^6.1.11",
"@fullcalendar/timegrid": "^6.1.11",
"@fontsource/space-grotesk": "^5.1.0",
"@next-auth/prisma-adapter": "^1.0.7",
"@prisma/client": "^5.19.1",
"bcryptjs": "^2.4.3",
"ical-generator": "^7.2.0",
"next": "14.2.5",
"next-auth": "^4.24.7",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20.14.10",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.20",
"eslint": "^8.57.0",
"eslint-config-next": "14.2.5",
"postcss": "^8.4.39",
"prisma": "^5.19.1",
"tailwindcss": "^3.4.7",
"typescript": "^5.5.4"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

91
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,91 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
passwordHash String
role String @default("USER")
createdAt DateTime @default(now())
events Event[] @relation("EventCreator")
views UserView[]
accounts Account[]
sessions Session[]
}
model Event {
id String @id @default(cuid())
title String
description String?
location String?
startAt DateTime
endAt DateTime
status String @default("PENDING")
createdAt DateTime @default(now())
createdById String
createdBy User @relation("EventCreator", fields: [createdById], references: [id])
viewItems UserViewItem[]
}
model UserView {
id String @id @default(cuid())
name String
token String @unique
userId String
user User @relation(fields: [userId], references: [id])
items UserViewItem[]
createdAt DateTime @default(now())
}
model UserViewItem {
id String @id @default(cuid())
viewId String
eventId String
view UserView @relation(fields: [viewId], references: [id])
event Event @relation(fields: [eventId], references: [id])
@@unique([viewId, eventId])
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}

View File

@@ -0,0 +1,50 @@
const fs = require("fs");
const path = require("path");
const mappings = [
{ pkg: "@fullcalendar/core", target: "fullcalendar-core.css" },
{ pkg: "@fullcalendar/daygrid", target: "fullcalendar-daygrid.css" },
{ pkg: "@fullcalendar/timegrid", target: "fullcalendar-timegrid.css" },
{ pkg: "@fullcalendar/list", target: "fullcalendar-list.css" }
];
const candidates = [
"main.css",
"index.css",
"style.css",
"styles.css",
"main.min.css",
"index.min.css",
"dist/main.css",
"dist/index.css"
];
const root = path.resolve(__dirname, "..");
const targetDir = path.join(root, "public", "vendor", "fullcalendar");
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
let missing = 0;
mappings.forEach(({ pkg, target }) => {
const basePath = path.join(root, "node_modules", pkg);
const sourcePath =
candidates
.map((candidate) => path.join(basePath, candidate))
.find((candidatePath) => fs.existsSync(candidatePath)) || null;
const targetPath = path.join(targetDir, target);
if (!sourcePath) {
console.error(`Missing FullCalendar CSS for ${pkg}`);
missing += 1;
return;
}
fs.copyFileSync(sourcePath, targetPath);
});
if (missing > 0) {
process.exit(1);
}

20
tailwind.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
brand: {
50: "#fff1ea",
100: "#ffd8cb",
500: "#ff6b4a",
700: "#e24a2b"
}
}
}
},
plugins: []
};
export default config;

20
tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}