commit 5d2630a02f03dd70c963939e8c6511e73e1f2ddf Author: Meik Date: Tue Jan 13 18:08:59 2026 +0100 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8d3936f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.next +.git +*.log +.env diff --git a/.env b/.env new file mode 100644 index 0000000..d4dcec1 --- /dev/null +++ b/.env @@ -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" diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..08cd101 --- /dev/null +++ b/.env.example @@ -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" diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..e8416a1 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v24.13.0 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c1cd6bd --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..977f9d4 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f9dd08f --- /dev/null +++ b/README.md @@ -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. diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..849c81f --- /dev/null +++ b/app/admin/page.tsx @@ -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 ( +
+

Nur fuer Admins.

+
+ ); + } + + return ; +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..9b5aa4e --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -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 }; diff --git a/app/api/events/[id]/route.ts b/app/api/events/[id]/route.ts new file mode 100644 index 0000000..31d5beb --- /dev/null +++ b/app/api/events/[id]/route.ts @@ -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); +} diff --git a/app/api/events/route.ts b/app/api/events/route.ts new file mode 100644 index 0000000..a3aa3db --- /dev/null +++ b/app/api/events/route.ts @@ -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 }); +} diff --git a/app/api/ical/[token]/route.ts b/app/api/ical/[token]/route.ts new file mode 100644 index 0000000..900b219 --- /dev/null +++ b/app/api/ical/[token]/route.ts @@ -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" + } + }); +} diff --git a/app/api/register/route.ts b/app/api/register/route.ts new file mode 100644 index 0000000..2650bff --- /dev/null +++ b/app/api/register/route.ts @@ -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 }); +} diff --git a/app/api/views/[id]/items/route.ts b/app/api/views/[id]/items/route.ts new file mode 100644 index 0000000..9e51e2b --- /dev/null +++ b/app/api/views/[id]/items/route.ts @@ -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 }); +} diff --git a/app/api/views/route.ts b/app/api/views/route.ts new file mode 100644 index 0000000..3f15b9b --- /dev/null +++ b/app/api/views/route.ts @@ -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 }); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..4379d52 --- /dev/null +++ b/app/globals.css @@ -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; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..a217866 --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + + + + + + + + +
{children}
+
+ + + ); +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..007c04a --- /dev/null +++ b/app/login/page.tsx @@ -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(null); + + const onSubmit = async (event: React.FormEvent) => { + 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 ( +
+

Login

+
+ + + +
+ {error &&

{error}

} +

+ Kein Konto? Registrieren +

+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..aaab392 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,24 @@ +import CalendarBoard from "../components/CalendarBoard"; +import EventForm from "../components/EventForm"; + +export default function HomePage() { + return ( +
+
+
+
+
+

Vereinskalender

+

+ Termine einstellen, abstimmen und als persoenlichen Kalender abonnieren. +

+
+
+ +
+ + +
+
+ ); +} diff --git a/app/providers.tsx b/app/providers.tsx new file mode 100644 index 0000000..3502f27 --- /dev/null +++ b/app/providers.tsx @@ -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 {children}; +} diff --git a/app/register/page.tsx b/app/register/page.tsx new file mode 100644 index 0000000..0558e1a --- /dev/null +++ b/app/register/page.tsx @@ -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(null); + const [done, setDone] = useState(false); + + const onSubmit = async (event: React.FormEvent) => { + 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 ( +
+

Registrieren

+
+ + + + +
+ {done &&

Account erstellt.

} + {error &&

{error}

} +

+ Schon registriert? Login +

+
+ ); +} diff --git a/app/views/page.tsx b/app/views/page.tsx new file mode 100644 index 0000000..0c234c3 --- /dev/null +++ b/app/views/page.tsx @@ -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 ( +
+

Bitte anmelden, um Ansichten zu verwalten.

+
+ ); + } + + return ; +} diff --git a/components/AdminPanel.tsx b/components/AdminPanel.tsx new file mode 100644 index 0000000..7bf1cbb --- /dev/null +++ b/components/AdminPanel.tsx @@ -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([]); + const [error, setError] = useState(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 ( +
+

Adminfreigaben

+ {error &&

{error}

} + {events.length === 0 ? ( +

Keine offenen Vorschlaege.

+ ) : ( +
+ {events.map((event) => ( +
+
+
+

{event.title}

+

+ {new Date(event.startAt).toLocaleString()} - {new Date(event.endAt).toLocaleString()} +

+ {event.location && ( +

Ort: {event.location}

+ )} +
+
+ + +
+
+ {event.description && ( +

{event.description}

+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/components/CalendarBoard.tsx b/components/CalendarBoard.tsx new file mode 100644 index 0000000..ed81336 --- /dev/null +++ b/components/CalendarBoard.tsx @@ -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([]); + const [error, setError] = useState(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 ( +
+

+ Bitte anmelden, um die Vereinskalender zu sehen. +

+
+ ); + } + + return ( +
+
+

Kalender

+ +
+ {error &&

{error}

} + {loading &&

Lade Termine...

} +
+ { + const status = arg.event.extendedProps.status; + return ( +
+
{arg.event.title}
+ {status !== "APPROVED" && ( +
{status}
+ )} +
+ ); + }} + height="auto" + /> +
+
+ ); +} diff --git a/components/EventForm.tsx b/components/EventForm.tsx new file mode 100644 index 0000000..2d5e7d8 --- /dev/null +++ b/components/EventForm.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useState } from "react"; + +export default function EventForm() { + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + + const onSubmit = async (event: React.FormEvent) => { + 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 ( +
+

Termin vorschlagen

+
+ + + + +