From 5d2630a02f03dd70c963939e8c6511e73e1f2ddf Mon Sep 17 00:00:00 2001 From: Meik Date: Tue, 13 Jan 2026 18:08:59 +0100 Subject: [PATCH] first commit --- .dockerignore | 5 + .env | 4 + .env.example | 4 + .nvmrc | 1 + AGENTS.md | 40 ++++++ Dockerfile | 26 ++++ README.md | 93 +++++++++++++ app/admin/page.tsx | 16 +++ app/api/auth/[...nextauth]/route.ts | 6 + app/api/events/[id]/route.ts | 28 ++++ app/api/events/route.ts | 63 +++++++++ app/api/ical/[token]/route.ts | 42 ++++++ app/api/register/route.ts | 30 +++++ app/api/views/[id]/items/route.ts | 60 +++++++++ app/api/views/route.ts | 43 ++++++ app/globals.css | 24 ++++ app/layout.tsx | 32 +++++ app/login/page.tsx | 57 ++++++++ app/page.tsx | 24 ++++ app/providers.tsx | 8 ++ app/register/page.tsx | 77 +++++++++++ app/views/page.tsx | 16 +++ components/AdminPanel.tsx | 90 +++++++++++++ components/CalendarBoard.tsx | 116 ++++++++++++++++ components/EventForm.tsx | 85 ++++++++++++ components/NavBar.tsx | 50 +++++++ components/ViewManager.tsx | 199 ++++++++++++++++++++++++++++ docker-compose.dev.yml | 17 +++ docker-compose.yml | 14 ++ lib/auth-helpers.ts | 15 +++ lib/auth.ts | 74 +++++++++++ lib/prisma.ts | 16 +++ next-auth.d.ts | 11 ++ next-env.d.ts | 5 + next.config.js | 6 + package.json | 48 +++++++ postcss.config.js | 6 + prisma/schema.prisma | 91 +++++++++++++ scripts/copy-fullcalendar-css.js | 50 +++++++ tailwind.config.ts | 20 +++ tsconfig.json | 20 +++ 41 files changed, 1632 insertions(+) create mode 100644 .dockerignore create mode 100644 .env create mode 100644 .env.example create mode 100644 .nvmrc create mode 100644 AGENTS.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/admin/page.tsx create mode 100644 app/api/auth/[...nextauth]/route.ts create mode 100644 app/api/events/[id]/route.ts create mode 100644 app/api/events/route.ts create mode 100644 app/api/ical/[token]/route.ts create mode 100644 app/api/register/route.ts create mode 100644 app/api/views/[id]/items/route.ts create mode 100644 app/api/views/route.ts create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/login/page.tsx create mode 100644 app/page.tsx create mode 100644 app/providers.tsx create mode 100644 app/register/page.tsx create mode 100644 app/views/page.tsx create mode 100644 components/AdminPanel.tsx create mode 100644 components/CalendarBoard.tsx create mode 100644 components/EventForm.tsx create mode 100644 components/NavBar.tsx create mode 100644 components/ViewManager.tsx create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.yml create mode 100644 lib/auth-helpers.ts create mode 100644 lib/auth.ts create mode 100644 lib/prisma.ts create mode 100644 next-auth.d.ts create mode 100644 next-env.d.ts create mode 100644 next.config.js create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 prisma/schema.prisma create mode 100644 scripts/copy-fullcalendar-css.js create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json 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

+
+ + + + +