From 46eae2a2a9097b491e5d4e07e33af6308608f406 Mon Sep 17 00:00:00 2001 From: Meik Date: Thu, 15 Jan 2026 16:24:09 +0100 Subject: [PATCH] Aktueller Stand --- .env | 6 +- .env.example | 12 +- AGENTS.md | 7 +- Dockerfile | 2 + README.md | 108 +- app/admin/page.tsx | 14 +- app/admin/settings/page.tsx | 5 + app/admin/users/page.tsx | 16 + app/api/branding/logo/route.ts | 67 + app/api/categories/route.ts | 120 ++ app/api/events/[id]/route.ts | 68 +- app/api/events/route.ts | 59 +- app/api/ical/[token]/route.ts | 36 +- app/api/ical/import/route.ts | 143 ++ app/api/password-reset/confirm/route.ts | 36 + app/api/password-reset/request/route.ts | 41 + app/api/places/autocomplete/route.ts | 83 + app/api/places/details/route.ts | 71 + app/api/places/reverse/route.ts | 70 + app/api/profile/route.ts | 91 + app/api/register/route.ts | 69 +- app/api/settings/google-places/route.ts | 76 + app/api/settings/logo/route.ts | 118 ++ app/api/users/route.ts | 303 +++ app/api/verify-email/confirm/route.ts | 38 + app/api/verify-email/request/route.ts | 44 + app/api/views/[id]/categories/route.ts | 65 + app/api/views/[id]/items/route.ts | 55 +- app/api/views/default/rotate/route.ts | 27 + app/api/views/default/route.ts | 57 + app/api/views/route.ts | 6 +- app/globals.css | 322 ++- app/layout.tsx | 19 +- app/login/page.tsx | 45 +- app/page.tsx | 29 +- app/register/page.tsx | 13 +- app/reset/confirm/page.tsx | 19 + app/reset/confirm/reset-confirm-client.tsx | 69 + app/reset/page.tsx | 54 + app/settings/page.tsx | 327 +++ app/verify/confirm/page.tsx | 19 + app/verify/confirm/verify-confirm-client.tsx | 55 + app/verify/page.tsx | 54 + app/views/page.tsx | 2 +- components/AdminPanel.tsx | 599 +++++- components/AdminSystemSettings.tsx | 202 ++ components/AdminUserApprovals.tsx | 482 +++++ components/CalendarBoard.tsx | 1021 +++++++++- components/EventForm.tsx | 446 ++++- components/MapPicker.tsx | 65 + components/NavBar.tsx | 67 +- components/ViewManager.tsx | 194 +- docker-compose.dev.yml | 2 +- docker-compose.yml | 4 +- fullcalendar-6.1.20.tgz | Bin 0 -> 230821 bytes fullcalendar-core-5.11.5.tgz | Bin 0 -> 198415 bytes fullcalendar-core-6.1.11.tgz | Bin 0 -> 418743 bytes fullcalendar-core-6.1.20.tgz | Bin 0 -> 425024 bytes fullcalendar-daygrid-6.1.11.tgz | Bin 0 -> 44652 bytes lib/auth-helpers.ts | 18 +- lib/auth.ts | 132 +- lib/mailer.ts | 40 + next-auth.d.ts | 5 +- package.json | 30 +- prisma/schema.prisma | 74 +- public/favicon.svg | 1 + public/vendor/fullcalendar/fullcalendar.css | 1878 ++++++++++++++++++ scripts/build-fullcalendar-css.sh | 55 + scripts/copy-fullcalendar-css.js | 50 - tailwind.config.ts | 8 +- 70 files changed, 7866 insertions(+), 447 deletions(-) create mode 100644 app/admin/settings/page.tsx create mode 100644 app/admin/users/page.tsx create mode 100644 app/api/branding/logo/route.ts create mode 100644 app/api/categories/route.ts create mode 100644 app/api/ical/import/route.ts create mode 100644 app/api/password-reset/confirm/route.ts create mode 100644 app/api/password-reset/request/route.ts create mode 100644 app/api/places/autocomplete/route.ts create mode 100644 app/api/places/details/route.ts create mode 100644 app/api/places/reverse/route.ts create mode 100644 app/api/profile/route.ts create mode 100644 app/api/settings/google-places/route.ts create mode 100644 app/api/settings/logo/route.ts create mode 100644 app/api/users/route.ts create mode 100644 app/api/verify-email/confirm/route.ts create mode 100644 app/api/verify-email/request/route.ts create mode 100644 app/api/views/[id]/categories/route.ts create mode 100644 app/api/views/default/rotate/route.ts create mode 100644 app/api/views/default/route.ts create mode 100644 app/reset/confirm/page.tsx create mode 100644 app/reset/confirm/reset-confirm-client.tsx create mode 100644 app/reset/page.tsx create mode 100644 app/settings/page.tsx create mode 100644 app/verify/confirm/page.tsx create mode 100644 app/verify/confirm/verify-confirm-client.tsx create mode 100644 app/verify/page.tsx create mode 100644 components/AdminSystemSettings.tsx create mode 100644 components/AdminUserApprovals.tsx create mode 100644 components/MapPicker.tsx create mode 100644 fullcalendar-6.1.20.tgz create mode 100644 fullcalendar-core-5.11.5.tgz create mode 100644 fullcalendar-core-6.1.11.tgz create mode 100644 fullcalendar-core-6.1.20.tgz create mode 100644 fullcalendar-daygrid-6.1.11.tgz create mode 100644 lib/mailer.ts create mode 100644 public/favicon.svg create mode 100644 public/vendor/fullcalendar/fullcalendar.css create mode 100755 scripts/build-fullcalendar-css.sh delete mode 100644 scripts/copy-fullcalendar-css.js diff --git a/.env b/.env index d4dcec1..1bdbab6 100644 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ -DATABASE_URL="file:./dev.db" +DATABASE_URL="file:./data/dev.db" NEXTAUTH_SECRET="change-me-in-prod" -NEXTAUTH_URL="http://localhost:3000" -ADMIN_EMAILS="admin@example.com" +NEXTAUTH_URL="http://docker:3101" +SUPERADMIN_EMAILS="meikdre@gmx.de" diff --git a/.env.example b/.env.example index 08cd101..11686d3 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,12 @@ -DATABASE_URL="file:./dev.db" +DATABASE_URL="file:./data/dev.db" NEXTAUTH_SECRET="replace-with-strong-secret" -NEXTAUTH_URL="http://localhost:3000" +NEXTAUTH_URL="http://localhost:3101" ADMIN_EMAILS="admin@example.com" +SUPERADMIN_EMAILS="superadmin@example.com" +SMTP_HOST="smtp.example.com" +SMTP_PORT="587" +SMTP_USER="user@example.com" +SMTP_PASS="password" +SMTP_SECURE="false" +SMTP_FROM="Vereinskalender " +NOMINATIM_USER_AGENT="vereinskalender/1.0 (mailto:admin@example.com)" diff --git a/AGENTS.md b/AGENTS.md index c1cd6bd..211d947 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,11 +14,8 @@ No build or test scripts are defined yet. When you add tooling, document the exa - `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. +Use consistent formatting for TS/JS (2 spaces) and follow the existing component and API patterns in `app/` and `components/`. +For user-facing German copy, always use proper umlauts (äöüß) instead of ASCII replacements (ae/oe/ue/ss). Keep wording concise and consistent across UI and emails. ## Testing Guidelines No testing framework is configured yet. When you add tests: diff --git a/Dockerfile b/Dockerfile index 977f9d4..3d4e776 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,8 @@ RUN --mount=type=cache,target=/root/.npm \ FROM base AS builder COPY --from=deps /app/node_modules ./node_modules COPY . . +RUN --mount=type=cache,target=/root/.npm \ + if [ ! -d node_modules/@fullcalendar/core ]; then npm install; fi RUN npm run build FROM base AS runner diff --git a/README.md b/README.md index f9dd08f..46b1ad4 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,18 @@ # 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). +State-of-the-art Kalenderapp für Vereine mit Admin-Freigaben, persönlichen 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. +- Admins können Termine sofort freigeben oder Vorschläge bestätigen/ablehnen. +- Mitglieder schlagen Termine vor; Freigaben laufen über das Admin-Panel. +- Neue Registrierungen müssen durch Admins freigeschaltet werden. - Mehrere Kalenderansichten (Monat, Woche, Liste) mit FullCalendar. -- Eigene Ansichten mit iCal-Abonnement fuer externe Apps (iOS/Android). +- Eine Standardansicht pro Benutzer mit iCal-Abonnement für externe Apps (iOS/Android). +- Kategorien zur Strukturierung (nur Admins dürfen Kategorien anlegen). +- Kategorien können abonniert werden, damit neue und bestehende Termine automatisch in die Ansicht fallen. ## Tech-Stack -- Frontend: Next.js 14 (App Router), React 18, Tailwind CSS, FullCalendar +- Frontend: Next.js 14 (App Router), React 18, Tailwind CSS, FullCalendar (v6.1.20 CSS lokal) - Backend: Next.js Route Handlers, Prisma ORM, SQLite - Auth: NextAuth (Credentials + Prisma Adapter) - Export: iCal via `ical-generator` @@ -30,47 +33,114 @@ npm run prisma:migrate npm run dev ``` -Open `http://localhost:3000`. +Öffne `http://localhost:3000`. ## Konfiguration In `.env` (lokal) bzw. per Umgebungsvariablen (Docker/Prod): ``` -DATABASE_URL="file:./dev.db" +DATABASE_URL="file:./data/dev.db" NEXTAUTH_SECRET="replace-with-strong-secret" -NEXTAUTH_URL="http://localhost:3000" +NEXTAUTH_URL="http://localhost:3101" ADMIN_EMAILS="admin@example.com" +SUPERADMIN_EMAILS="superadmin@example.com" +SMTP_HOST="smtp.example.com" +SMTP_PORT="587" +SMTP_USER="user@example.com" +SMTP_PASS="password" +SMTP_SECURE="false" +SMTP_FROM="Vereinskalender " +NOMINATIM_USER_AGENT="vereinskalender/1.0 (mailto:admin@example.com)" ``` ## Admin-Setup `ADMIN_EMAILS` (kommagetrennt) steuert, welche Accounts beim Signup als Admin markiert werden. +Admin-Konten werden sofort freigeschaltet, normale Mitglieder bleiben auf `PENDING`. +`SUPERADMIN_EMAILS` (kommagetrennt) steuert Superadmins, die System-Einstellungen verwalten dürfen. ## 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:migrate` - DB Migrationen für 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/categories` - Kategorien anzeigen +- `POST /api/categories` - Kategorie anlegen (Admin) +- `GET /api/users?status=PENDING` - Offene Registrierungen (Admin) +- `PATCH /api/users` - Benutzer freischalten/ändern (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/views/default` - Standardansicht laden/erstellen - `GET /api/ical/:token` - iCal Feed der Ansicht +- `POST /api/views/default/rotate` - iCal-Link erneuern +- `POST /api/views/:id/categories` - Kategorie abonnieren +- `DELETE /api/views/:id/categories` - Kategorie-Abo entfernen +- `PATCH /api/profile` - E-Mail/Passwort aktualisieren +- `POST /api/password-reset/request` - Passwort-Reset Link anfordern +- `POST /api/password-reset/confirm` - Passwort setzen (mit Token) +- `POST /api/verify-email/request` - Verifizierungslink senden +- `POST /api/verify-email/confirm` - E-Mail verifizieren +- `GET /api/settings/google-places` - Ortsanbieter + API Key abrufen (Login) +- `POST /api/settings/google-places` - Ortsanbieter/Key speichern (Superadmin) +- `GET /api/places/autocomplete` - Places Autocomplete (Login) +- `GET /api/places/details` - Places Details (Login) +- `GET /api/places/reverse` - Reverse-Geocoding (Login) ## iCal-Abonnement -Unter "Meine Ansichten" wird fuer jede Ansicht eine iCal-URL erzeugt. Diese kann in Kalender-Apps (iOS/Android) abonniert werden. +Unter `/settings` wird die iCal-URL angezeigt. Diese kann in Kalender-Apps (iOS/Android) abonniert werden. + +## Standardansicht + +- Jeder Benutzer hat eine Standardansicht (`/api/views/default`). +- Termine können direkt im Kalender oder in der Listenansicht ein- oder ausgeblendet werden. +- Kategorien lassen sich abonnieren, um künftige Termine automatisch einzublenden. + +## Einstellungen + +Unter `/settings` können Nutzer ihre E-Mail oder ihr Passwort ändern und den iCal-Link erneuern. + +## Superadmin + +Superadmins sehen unter `/admin` zusätzlich die System-Einstellungen und können den Ortsanbieter (Google oder OpenStreetMap/Nominatim) konfigurieren. Außerdem kann die öffentliche Registrierung deaktiviert werden. + +## Orte & Karten + +- Der Ort wird per Google Places oder OpenStreetMap (Nominatim) vorgeschlagen und mit `placeId` sowie Koordinaten gespeichert. +- Der Ort kann zusätzlich direkt auf der Karte ausgewählt werden (Reverse-Geocoding füllt den Namen). +- In den Termin-Details wird abhängig vom Anbieter eine Karte (Google Maps Embed oder OpenStreetMap) angezeigt. + +## Passwort-Reset + +Unter `/reset` kann ein Passwort-Reset angefordert werden. Der Link ist 60 Minuten gültig. +Wenn keine SMTP-Umgebung gesetzt ist, wird der Link im Server-Log ausgegeben. + +## E-Mail-Verifizierung + +- Nach der Registrierung wird eine Verifizierungs-Mail gesendet (Token ist 24h gültig). +- Verifizierungslink erneut senden unter `/verify`. + +## Termin-Logik + +- Startdatum und Startzeit sind Pflichtfelder. +- Endzeit ist optional; falls nicht gesetzt, wird für Kalenderausgaben automatisch +3 Stunden angenommen. + +## Registrierungen + +- Neue Mitglieder sind standardmäßig `PENDING`. +- Admins schalten unter `/admin/users` frei. ## Docker @@ -79,15 +149,21 @@ cp .env.example .env docker compose up --build ``` -Wichtig fuer persistente Logins: +Wichtig für persistente Logins und Daten: - `NEXTAUTH_SECRET` in `.env` fix setzen (nicht bei jedem Build wechseln). -- Die SQLite-DB liegt im `data/`-Volume des Containers und bleibt erhalten. +- Die SQLite-DB liegt im Host-Verzeichnis `/opt/docker/vereinskalender/app-data` und wird nach `/app/prisma/data` gemountet. Sie bleibt über Rebuilds erhalten. +- `docker compose down -v` löscht keine Bind-Mount-Daten, aber ein Entfernen von `/opt/docker/vereinskalender/app-data` löscht alles. ## 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`. +- BuildKit-Cache nutzen (im Dockerfile aktiv, benötigt Docker BuildKit). +- Für schnelle lokale Iteration: `docker compose -f docker-compose.dev.yml up --build`. + +## FullCalendar CSS + +FullCalendar v6 liefert keine fertigen CSS-Dateien auf npm aus. Daher liegt eine gebündelte CSS-Datei in `public/vendor/fullcalendar/fullcalendar.css`, erzeugt aus den offiziellen Stylesheets des FullCalendar-Repos (Version v6.1.20). Bei einem Upgrade die CSS-Datei via `scripts/build-fullcalendar-css.sh` neu generieren. ## Sicherheitshinweise - Keine Secrets committen; `.env` ist in `.dockerignore`. -- Fuer Prod echte Secrets und eine externe DB nutzen. +- Für Prod echte Secrets und eine externe DB nutzen. +- Login-Versuche sind gedrosselt (IP + E-Mail), nach mehreren Fehlversuchen erfolgt eine temporäre Sperre. diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 849c81f..b660318 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,16 +1,22 @@ import { getServerSession } from "next-auth"; import AdminPanel from "../../components/AdminPanel"; +import AdminSystemSettings from "../../components/AdminSystemSettings"; import { authOptions } from "../../lib/auth"; export default async function AdminPage() { const session = await getServerSession(authOptions); - if (session?.user?.role !== "ADMIN") { + if (session?.user?.role !== "ADMIN" && session?.user?.role !== "SUPERADMIN") { return ( -
-

Nur fuer Admins.

+
+

Nur für Admins.

); } - return ; + return ( +
+ + {session?.user?.role === "SUPERADMIN" && } +
+ ); } diff --git a/app/admin/settings/page.tsx b/app/admin/settings/page.tsx new file mode 100644 index 0000000..fba1152 --- /dev/null +++ b/app/admin/settings/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default async function AdminSettingsPage() { + redirect("/admin"); +} diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx new file mode 100644 index 0000000..d17d0c3 --- /dev/null +++ b/app/admin/users/page.tsx @@ -0,0 +1,16 @@ +import { getServerSession } from "next-auth"; +import AdminUserApprovals from "../../../components/AdminUserApprovals"; +import { authOptions } from "../../../lib/auth"; + +export default async function AdminUsersPage() { + const session = await getServerSession(authOptions); + if (session?.user?.role !== "ADMIN" && session?.user?.role !== "SUPERADMIN") { + return ( +
+

Nur für Admins.

+
+ ); + } + + return ; +} diff --git a/app/api/branding/logo/route.ts b/app/api/branding/logo/route.ts new file mode 100644 index 0000000..2272355 --- /dev/null +++ b/app/api/branding/logo/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server"; +import { promises as fs } from "fs"; +import path from "path"; +import { prisma } from "../../../../lib/prisma"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +const DATA_DIR = path.join(process.cwd(), "prisma", "data"); + +const resolveLogoPath = (relativePath: string) => { + const absolutePath = path.join(DATA_DIR, relativePath); + if (!absolutePath.startsWith(DATA_DIR)) { + throw new Error("Ungültiger Pfad."); + } + return absolutePath; +}; + +const getLogoSettings = async () => { + const pathSetting = await prisma.setting.findUnique({ + where: { key: "app_logo_path" } + }); + const typeSetting = await prisma.setting.findUnique({ + where: { key: "app_logo_type" } + }); + + if (!pathSetting?.value || !typeSetting?.value) { + return null; + } + + return { path: pathSetting.value, type: typeSetting.value }; +}; + +export async function GET() { + const settings = await getLogoSettings(); + if (!settings) { + return NextResponse.json({ error: "Kein Logo vorhanden." }, { status: 404 }); + } + + try { + const absolutePath = resolveLogoPath(settings.path); + const file = await fs.readFile(absolutePath); + return new NextResponse(file, { + headers: { + "Content-Type": settings.type, + "Cache-Control": "no-store" + } + }); + } catch { + return NextResponse.json({ error: "Logo konnte nicht geladen werden." }, { status: 404 }); + } +} + +export async function HEAD() { + const settings = await getLogoSettings(); + if (!settings) { + return new NextResponse(null, { status: 404 }); + } + + return new NextResponse(null, { + status: 200, + headers: { + "Content-Type": settings.type, + "Cache-Control": "no-store" + } + }); +} diff --git a/app/api/categories/route.ts b/app/api/categories/route.ts new file mode 100644 index 0000000..aae1aa4 --- /dev/null +++ b/app/api/categories/route.ts @@ -0,0 +1,120 @@ +import { NextResponse } from "next/server"; +import { prisma } from "../../../lib/prisma"; +import { isAdminSession, requireSession } from "../../../lib/auth-helpers"; + +export async function GET() { + const { session } = await requireSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const categories = await prisma.category.findMany({ + orderBy: { name: "asc" } + }); + + return NextResponse.json(categories); +} + +export async function POST(request: Request) { + 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 { name } = body || {}; + + if (!name) { + return NextResponse.json({ error: "Name erforderlich." }, { status: 400 }); + } + + const existing = await prisma.category.findUnique({ where: { name } }); + if (existing) { + return NextResponse.json({ error: "Kategorie existiert bereits." }, { status: 409 }); + } + + const category = await prisma.category.create({ + data: { name } + }); + + const views = await prisma.userView.findMany({ + select: { id: true } + }); + + if (views.length > 0) { + await prisma.userViewCategory.createMany({ + data: views.map((view) => ({ + viewId: view.id, + categoryId: category.id + })) + }); + } + + return NextResponse.json(category, { status: 201 }); +} + +export async function PATCH(request: Request) { + 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 { id, name } = body || {}; + + if (!id || !name) { + return NextResponse.json({ error: "ID und Name erforderlich." }, { status: 400 }); + } + + const trimmed = String(name).trim(); + if (!trimmed) { + return NextResponse.json({ error: "Name erforderlich." }, { status: 400 }); + } + + const existing = await prisma.category.findUnique({ where: { name: trimmed } }); + if (existing && existing.id !== id) { + return NextResponse.json({ error: "Kategorie existiert bereits." }, { status: 409 }); + } + + const category = await prisma.category.update({ + where: { id }, + data: { name: trimmed } + }); + + return NextResponse.json(category); +} + +export async function DELETE(request: Request) { + const { session } = await requireSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (!isAdminSession(session)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + + if (!id) { + return NextResponse.json({ error: "ID erforderlich." }, { status: 400 }); + } + + await prisma.event.updateMany({ + where: { categoryId: id }, + data: { categoryId: null } + }); + + await prisma.category.delete({ where: { id } }); + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/events/[id]/route.ts b/app/api/events/[id]/route.ts index 31d5beb..3f5fd65 100644 --- a/app/api/events/[id]/route.ts +++ b/app/api/events/[id]/route.ts @@ -13,16 +13,76 @@ export async function PATCH(request: Request, context: { params: { id: string } } const body = await request.json(); - const { status } = body || {}; + const { + status, + title, + description, + location, + locationPlaceId, + locationLat, + locationLng, + startAt, + endAt, + categoryId + } = body || {}; - if (!status || !["APPROVED", "REJECTED"].includes(status)) { - return NextResponse.json({ error: "Status ungueltig." }, { status: 400 }); + if (status && ["APPROVED", "REJECTED"].includes(status)) { + const event = await prisma.event.update({ + where: { id: context.params.id }, + data: { status } + }); + + return NextResponse.json(event); } + if (!title || !startAt || !categoryId) { + return NextResponse.json( + { error: "Titel, Start und Kategorie sind erforderlich." }, + { status: 400 } + ); + } + + const startDate = new Date(startAt); + const endDate = endAt ? new Date(endAt) : null; + const event = await prisma.event.update({ where: { id: context.params.id }, - data: { status } + data: { + title, + description: description || null, + location: location || null, + locationPlaceId: locationPlaceId || null, + locationLat: locationLat ? Number(locationLat) : null, + locationLng: locationLng ? Number(locationLng) : null, + startAt: startDate, + endAt: endDate, + category: { connect: { id: categoryId } } + } }); return NextResponse.json(event); } + +export async function DELETE( + _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 }); + } + + await prisma.userViewItem.deleteMany({ + where: { eventId: context.params.id } + }); + + await prisma.event.delete({ + where: { id: context.params.id } + }); + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/events/route.ts b/app/api/events/route.ts index a3aa3db..8c39f26 100644 --- a/app/api/events/route.ts +++ b/app/api/events/route.ts @@ -25,7 +25,8 @@ export async function GET(request: Request) { const events = await prisma.event.findMany({ where, - orderBy: { startAt: "asc" } + orderBy: { startAt: "asc" }, + include: { category: true } }); return NextResponse.json(events); @@ -38,24 +39,68 @@ export async function POST(request: Request) { } const body = await request.json(); - const { title, description, location, startAt, endAt } = body || {}; + const { + title, + description, + location, + locationPlaceId, + locationLat, + locationLng, + startAt, + endAt, + categoryId + } = body || {}; - if (!title || !startAt || !endAt) { + if (!title || !startAt) { return NextResponse.json( - { error: "Titel, Start und Ende sind erforderlich." }, + { error: "Titel und Start sind erforderlich." }, { status: 400 } ); } + if (!categoryId) { + return NextResponse.json( + { error: "Kategorie ist erforderlich." }, + { status: 400 } + ); + } + + const startDate = new Date(startAt); + const endDate = endAt + ? new Date(endAt) + : new Date(startDate.getTime() + 3 * 60 * 60 * 1000); + const creatorEmail = session.user?.email || ""; + + const existing = await prisma.event.findFirst({ + where: { + title, + startAt: startDate, + location: location || null, + categoryId, + createdBy: { email: creatorEmail } + } + }); + + if (existing) { + return NextResponse.json( + { error: "Ein identischer Termin existiert bereits." }, + { status: 409 } + ); + } + const event = await prisma.event.create({ data: { title, description: description || null, location: location || null, - startAt: new Date(startAt), - endAt: new Date(endAt), + locationPlaceId: locationPlaceId || null, + locationLat: locationLat ? Number(locationLat) : null, + locationLng: locationLng ? Number(locationLng) : null, + startAt: startDate, + endAt: endDate, status: isAdminSession(session) ? "APPROVED" : "PENDING", - createdBy: { connect: { email: session.user?.email || "" } } + createdBy: { connect: { email: creatorEmail } }, + category: { connect: { id: categoryId } } } }); diff --git a/app/api/ical/[token]/route.ts b/app/api/ical/[token]/route.ts index 900b219..e5ee980 100644 --- a/app/api/ical/[token]/route.ts +++ b/app/api/ical/[token]/route.ts @@ -8,7 +8,12 @@ export async function GET( ) { const view = await prisma.userView.findUnique({ where: { token: context.params.token }, - include: { items: { include: { event: true } }, user: true } + include: { + items: { include: { event: true } }, + categories: true, + exclusions: true, + user: true + } }); if (!view) { @@ -20,17 +25,36 @@ export async function GET( timezone: "Europe/Berlin" }); - view.items + const excludedIds = new Set(view.exclusions.map((item) => item.eventId)); + const explicitEvents = view.items .map((item) => item.event) - .filter((event) => event.status === "APPROVED") - .forEach((event) => { + .filter((event) => event.status === "APPROVED"); + + const categoryIds = view.categories.map((item) => item.categoryId); + const categoryEvents = + categoryIds.length > 0 + ? await prisma.event.findMany({ + where: { categoryId: { in: categoryIds }, status: "APPROVED" } + }) + : []; + + const combined = [...explicitEvents, ...categoryEvents].filter( + (event, index, all) => + all.findIndex((item) => item.id === event.id) === index && + !excludedIds.has(event.id) + ); + + combined.forEach((event) => { + const start = event.startAt; + const end = + event.endAt || new Date(event.startAt.getTime() + 3 * 60 * 60 * 1000); calendar.createEvent({ id: event.id, summary: event.title, description: event.description || undefined, location: event.location || undefined, - start: event.startAt, - end: event.endAt + start, + end }); }); diff --git a/app/api/ical/import/route.ts b/app/api/ical/import/route.ts new file mode 100644 index 0000000..ce15f10 --- /dev/null +++ b/app/api/ical/import/route.ts @@ -0,0 +1,143 @@ +import { NextResponse } from "next/server"; +import { parseICS } from "node-ical"; +import { isAdminSession, requireSession } from "../../../../lib/auth-helpers"; +import { prisma } from "../../../../lib/prisma"; + +const MAX_FILE_SIZE = 5 * 1024 * 1024; + +const asText = (value: unknown) => { + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value.trim(); + return String(value).trim(); +}; + +export async function POST(request: Request) { + const { session } = await requireSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!isAdminSession(session)) { + return NextResponse.json({ error: "Nur für Admins." }, { status: 403 }); + } + + const formData = await request.formData(); + const file = formData.get("file"); + const categoryId = asText(formData.get("categoryId")); + + if (!(file instanceof File)) { + return NextResponse.json( + { error: "Bitte eine iCal-Datei hochladen." }, + { status: 400 } + ); + } + + if (!categoryId) { + return NextResponse.json( + { error: "Bitte eine Kategorie auswählen." }, + { status: 400 } + ); + } + + const category = await prisma.category.findUnique({ + where: { id: categoryId } + }); + if (!category) { + return NextResponse.json( + { error: "Kategorie nicht gefunden." }, + { status: 404 } + ); + } + + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json( + { error: "Datei ist zu groß (max. 5 MB)." }, + { status: 400 } + ); + } + + let parsed: Record; + try { + const raw = await file.text(); + parsed = parseICS(raw); + } catch (err) { + return NextResponse.json( + { error: "iCal-Datei konnte nicht gelesen werden." }, + { status: 400 } + ); + } + + const entries = Object.values(parsed).filter( + (entry) => entry && entry.type === "VEVENT" + ); + + if (entries.length === 0) { + return NextResponse.json( + { error: "Keine Termine in der iCal-Datei gefunden." }, + { status: 400 } + ); + } + + let created = 0; + let duplicates = 0; + let skipped = 0; + let recurringSkipped = 0; + + const creatorEmail = session.user?.email || ""; + + for (const entry of entries) { + if (entry.rrule) { + recurringSkipped += 1; + continue; + } + + const title = asText(entry.summary); + const start = entry.start instanceof Date ? entry.start : null; + if (!title || !start || Number.isNaN(start.getTime())) { + skipped += 1; + continue; + } + + const end = + entry.end instanceof Date && !Number.isNaN(entry.end.getTime()) + ? entry.end + : new Date(start.getTime() + 3 * 60 * 60 * 1000); + const location = asText(entry.location) || null; + const description = asText(entry.description) || null; + + const existing = await prisma.event.findFirst({ + where: { + title, + startAt: start, + location, + categoryId + } + }); + + if (existing) { + duplicates += 1; + continue; + } + + await prisma.event.create({ + data: { + title, + description, + location, + startAt: start, + endAt: end, + status: "APPROVED", + createdBy: { connect: { email: creatorEmail } }, + category: { connect: { id: categoryId } } + } + }); + + created += 1; + } + + return NextResponse.json({ + created, + duplicates, + skipped, + recurringSkipped + }); +} diff --git a/app/api/password-reset/confirm/route.ts b/app/api/password-reset/confirm/route.ts new file mode 100644 index 0000000..74d9ee6 --- /dev/null +++ b/app/api/password-reset/confirm/route.ts @@ -0,0 +1,36 @@ +import bcrypt from "bcryptjs"; +import { NextResponse } from "next/server"; +import { prisma } from "../../../../lib/prisma"; + +export async function POST(request: Request) { + const body = await request.json(); + const { token, newPassword } = body || {}; + + if (!token || !newPassword) { + return NextResponse.json( + { error: "Token und neues Passwort erforderlich." }, + { status: 400 } + ); + } + + const resetToken = await prisma.passwordResetToken.findUnique({ + where: { token } + }); + + if (!resetToken || resetToken.expiresAt < new Date()) { + return NextResponse.json({ error: "Token ungültig." }, { status: 400 }); + } + + const passwordHash = await bcrypt.hash(newPassword, 10); + + await prisma.user.update({ + where: { id: resetToken.userId }, + data: { passwordHash } + }); + + await prisma.passwordResetToken.deleteMany({ + where: { userId: resetToken.userId } + }); + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/password-reset/request/route.ts b/app/api/password-reset/request/route.ts new file mode 100644 index 0000000..62255ae --- /dev/null +++ b/app/api/password-reset/request/route.ts @@ -0,0 +1,41 @@ +import { randomUUID } from "crypto"; +import { NextResponse } from "next/server"; +import { prisma } from "../../../../lib/prisma"; +import { sendMail } from "../../../../lib/mailer"; + +export async function POST(request: Request) { + const body = await request.json(); + const { email } = body || {}; + + if (!email) { + return NextResponse.json({ error: "E-Mail erforderlich." }, { status: 400 }); + } + + const user = await prisma.user.findUnique({ where: { email } }); + + if (user) { + await prisma.passwordResetToken.deleteMany({ where: { userId: user.id } }); + + const token = randomUUID(); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); + + await prisma.passwordResetToken.create({ + data: { + userId: user.id, + token, + expiresAt + } + }); + + const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000"; + const resetUrl = `${baseUrl}/reset/confirm?token=${token}`; + + await sendMail({ + to: email, + subject: "Passwort zurücksetzen", + text: `Passwort zurücksetzen: ${resetUrl}` + }); + } + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/places/autocomplete/route.ts b/app/api/places/autocomplete/route.ts new file mode 100644 index 0000000..d807142 --- /dev/null +++ b/app/api/places/autocomplete/route.ts @@ -0,0 +1,83 @@ +import { NextResponse } from "next/server"; +import { prisma } from "../../../../lib/prisma"; +import { 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 input = searchParams.get("input") || ""; + const countries = searchParams.get("countries") || "de,fr,ch,at"; + const countryList = countries + .split(",") + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean) + .slice(0, 5); + const countryParam = countryList.join(","); + + if (input.trim().length < 3) { + return NextResponse.json({ predictions: [] }); + } + + const apiKeySetting = await prisma.setting.findUnique({ + where: { key: "google_places_api_key" } + }); + const providerSetting = await prisma.setting.findUnique({ + where: { key: "geocoding_provider" } + }); + const provider = + providerSetting?.value || (apiKeySetting?.value ? "google" : "osm"); + + if (provider === "google") { + if (!apiKeySetting?.value) { + return NextResponse.json({ predictions: [] }); + } + + const apiUrl = new URL("https://maps.googleapis.com/maps/api/place/autocomplete/json"); + apiUrl.searchParams.set("input", input); + apiUrl.searchParams.set("key", apiKeySetting.value); + apiUrl.searchParams.set("language", "de"); + apiUrl.searchParams.set("types", "geocode"); + if (countryParam) { + apiUrl.searchParams.set("components", `country:${countryParam}`); + } + + const response = await fetch(apiUrl.toString()); + if (!response.ok) { + return NextResponse.json({ predictions: [] }); + } + + const payload = await response.json(); + return NextResponse.json({ predictions: payload.predictions || [] }); + } + + const apiUrl = new URL("https://nominatim.openstreetmap.org/search"); + apiUrl.searchParams.set("format", "jsonv2"); + apiUrl.searchParams.set("q", input); + apiUrl.searchParams.set("addressdetails", "1"); + apiUrl.searchParams.set("limit", "5"); + apiUrl.searchParams.set("accept-language", "de"); + if (countryParam) { + apiUrl.searchParams.set("countrycodes", countryParam); + } + + const userAgent = process.env.NOMINATIM_USER_AGENT || "vereinskalender/1.0"; + const response = await fetch(apiUrl.toString(), { + headers: { "User-Agent": userAgent } + }); + if (!response.ok) { + return NextResponse.json({ predictions: [] }); + } + + const payload = await response.json(); + const predictions = Array.isArray(payload) + ? payload.map((item: any) => ({ + description: item.display_name, + place_id: String(item.place_id) + })) + : []; + return NextResponse.json({ predictions }); +} diff --git a/app/api/places/details/route.ts b/app/api/places/details/route.ts new file mode 100644 index 0000000..92f44c8 --- /dev/null +++ b/app/api/places/details/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server"; +import { prisma } from "../../../../lib/prisma"; +import { 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 placeId = searchParams.get("placeId") || ""; + + if (!placeId) { + return NextResponse.json({ error: "PlaceId erforderlich." }, { status: 400 }); + } + + const apiKeySetting = await prisma.setting.findUnique({ + where: { key: "google_places_api_key" } + }); + const providerSetting = await prisma.setting.findUnique({ + where: { key: "geocoding_provider" } + }); + const provider = + providerSetting?.value || (apiKeySetting?.value ? "google" : "osm"); + + if (provider === "google") { + if (!apiKeySetting?.value) { + return NextResponse.json({ error: "API-Key fehlt." }, { status: 400 }); + } + + const apiUrl = new URL("https://maps.googleapis.com/maps/api/place/details/json"); + apiUrl.searchParams.set("place_id", placeId); + apiUrl.searchParams.set("fields", "geometry/location"); + apiUrl.searchParams.set("key", apiKeySetting.value); + + const response = await fetch(apiUrl.toString()); + if (!response.ok) { + return NextResponse.json({ error: "Fehler beim Laden." }, { status: 400 }); + } + + const payload = await response.json(); + const location = payload?.result?.geometry?.location; + return NextResponse.json({ + lat: location?.lat ?? null, + lng: location?.lng ?? null + }); + } + + const apiUrl = new URL("https://nominatim.openstreetmap.org/details"); + apiUrl.searchParams.set("place_id", placeId); + apiUrl.searchParams.set("format", "json"); + + const userAgent = process.env.NOMINATIM_USER_AGENT || "vereinskalender/1.0"; + const response = await fetch(apiUrl.toString(), { + headers: { "User-Agent": userAgent } + }); + if (!response.ok) { + return NextResponse.json({ error: "Fehler beim Laden." }, { status: 400 }); + } + + const payload = await response.json(); + const coords = payload?.centroid?.coordinates; + const lat = Array.isArray(coords) ? Number(coords[1]) : Number(payload?.lat); + const lng = Array.isArray(coords) ? Number(coords[0]) : Number(payload?.lon); + + return NextResponse.json({ + lat: Number.isFinite(lat) ? lat : null, + lng: Number.isFinite(lng) ? lng : null + }); +} diff --git a/app/api/places/reverse/route.ts b/app/api/places/reverse/route.ts new file mode 100644 index 0000000..1e431f5 --- /dev/null +++ b/app/api/places/reverse/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from "next/server"; +import { prisma } from "../../../../lib/prisma"; +import { 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 lat = searchParams.get("lat"); + const lng = searchParams.get("lng"); + + if (!lat || !lng) { + return NextResponse.json({ error: "Koordinaten erforderlich." }, { status: 400 }); + } + + const apiKeySetting = await prisma.setting.findUnique({ + where: { key: "google_places_api_key" } + }); + const providerSetting = await prisma.setting.findUnique({ + where: { key: "geocoding_provider" } + }); + const provider = + providerSetting?.value || (apiKeySetting?.value ? "google" : "osm"); + + if (provider === "google") { + if (!apiKeySetting?.value) { + return NextResponse.json({ error: "API-Key fehlt." }, { status: 400 }); + } + + const apiUrl = new URL("https://maps.googleapis.com/maps/api/geocode/json"); + apiUrl.searchParams.set("latlng", `${lat},${lng}`); + apiUrl.searchParams.set("key", apiKeySetting.value); + apiUrl.searchParams.set("language", "de"); + + const response = await fetch(apiUrl.toString()); + if (!response.ok) { + return NextResponse.json({ error: "Fehler beim Laden." }, { status: 400 }); + } + + const payload = await response.json(); + const result = payload?.results?.[0]; + return NextResponse.json({ + label: result?.formatted_address || null, + placeId: result?.place_id || null + }); + } + + const apiUrl = new URL("https://nominatim.openstreetmap.org/reverse"); + apiUrl.searchParams.set("format", "jsonv2"); + apiUrl.searchParams.set("lat", lat); + apiUrl.searchParams.set("lon", lng); + apiUrl.searchParams.set("addressdetails", "1"); + + const userAgent = process.env.NOMINATIM_USER_AGENT || "vereinskalender/1.0"; + const response = await fetch(apiUrl.toString(), { + headers: { "User-Agent": userAgent } + }); + if (!response.ok) { + return NextResponse.json({ error: "Fehler beim Laden." }, { status: 400 }); + } + + const payload = await response.json(); + return NextResponse.json({ + label: payload?.display_name || null, + placeId: payload?.place_id ? String(payload.place_id) : null + }); +} diff --git a/app/api/profile/route.ts b/app/api/profile/route.ts new file mode 100644 index 0000000..b05bb75 --- /dev/null +++ b/app/api/profile/route.ts @@ -0,0 +1,91 @@ +import bcrypt from "bcryptjs"; +import crypto from "crypto"; +import { NextResponse } from "next/server"; +import { prisma } from "../../../lib/prisma"; +import { requireSession } from "../../../lib/auth-helpers"; +import { sendMail } from "../../../lib/mailer"; + +export async function PATCH(request: Request) { + const { session } = await requireSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json(); + const { currentPassword, newPassword, newEmail } = body || {}; + const normalizedEmail = newEmail ? String(newEmail).trim().toLowerCase() : ""; + + if (!currentPassword) { + return NextResponse.json( + { error: "Aktuelles Passwort erforderlich." }, + { status: 400 } + ); + } + + const user = await prisma.user.findUnique({ + where: { email: session.user?.email || "" } + }); + + if (!user) { + return NextResponse.json({ error: "User nicht gefunden." }, { status: 404 }); + } + + const valid = await bcrypt.compare(currentPassword, user.passwordHash); + if (!valid) { + return NextResponse.json({ error: "Passwort ungültig." }, { status: 401 }); + } + + const data: { email?: string; passwordHash?: string; emailVerified?: boolean } = {}; + + if (normalizedEmail && normalizedEmail !== user.email) { + const existing = await prisma.user.findUnique({ where: { email: normalizedEmail } }); + if (existing) { + return NextResponse.json( + { error: "E-Mail bereits vergeben." }, + { status: 409 } + ); + } + data.email = normalizedEmail; + data.emailVerified = false; + } + + if (newPassword) { + data.passwordHash = await bcrypt.hash(newPassword, 10); + } + + if (Object.keys(data).length === 0) { + return NextResponse.json({ error: "Keine Änderungen." }, { status: 400 }); + } + + const updated = await prisma.user.update({ + where: { id: user.id }, + data + }); + + if (data.email) { + const token = crypto.randomUUID(); + const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); + await prisma.verificationToken.create({ + data: { + identifier: data.email, + token, + expires + } + }); + + const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000"; + const verifyUrl = `${baseUrl}/verify/confirm?token=${token}`; + await sendMail({ + to: data.email, + subject: "E-Mail verifizieren", + text: `Bitte verifiziere deine E-Mail: ${verifyUrl}` + }); + } + + return NextResponse.json({ + id: updated.id, + email: updated.email, + changedEmail: Boolean(data.email), + changedPassword: Boolean(data.passwordHash) + }); +} diff --git a/app/api/register/route.ts b/app/api/register/route.ts index 2650bff..c018201 100644 --- a/app/api/register/route.ts +++ b/app/api/register/route.ts @@ -1,30 +1,89 @@ import bcrypt from "bcryptjs"; import { NextResponse } from "next/server"; +import { randomUUID } from "crypto"; import { prisma } from "../../../lib/prisma"; -import { isAdminEmail } from "../../../lib/auth"; +import { isAdminEmail, isSuperAdminEmail } from "../../../lib/auth"; +import { sendMail } from "../../../lib/mailer"; export async function POST(request: Request) { + const registrationSetting = await prisma.setting.findUnique({ + where: { key: "registration_enabled" } + }); + if (registrationSetting?.value === "false") { + return NextResponse.json( + { error: "Registrierung ist derzeit deaktiviert." }, + { status: 403 } + ); + } + const body = await request.json(); const { email, name, password } = body || {}; + const normalizedEmail = String(email || "").trim().toLowerCase(); - if (!email || !password) { + if (!normalizedEmail || !password) { return NextResponse.json({ error: "Email und Passwort sind erforderlich." }, { status: 400 }); } - const existing = await prisma.user.findUnique({ where: { email } }); + const existing = await prisma.user.findUnique({ where: { email: normalizedEmail } }); if (existing) { return NextResponse.json({ error: "Account existiert bereits." }, { status: 409 }); } const passwordHash = await bcrypt.hash(password, 10); + const superAdmin = isSuperAdminEmail(normalizedEmail); + const admin = isAdminEmail(normalizedEmail) || superAdmin; + const user = await prisma.user.create({ data: { - email, + email: normalizedEmail, name: name || null, passwordHash, - role: isAdminEmail(email) ? "ADMIN" : "USER" + role: superAdmin ? "SUPERADMIN" : admin ? "ADMIN" : "USER", + status: admin ? "ACTIVE" : "PENDING", + emailVerified: admin } }); + const categories = await prisma.category.findMany({ + select: { id: true } + }); + + const view = await prisma.userView.create({ + data: { + name: "Meine Ansicht", + token: randomUUID(), + user: { connect: { id: user.id } } + } + }); + + if (categories.length > 0) { + await prisma.userViewCategory.createMany({ + data: categories.map((category) => ({ + viewId: view.id, + categoryId: category.id + })) + }); + } + + if (!admin) { + const token = randomUUID(); + const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); + await prisma.verificationToken.create({ + data: { + identifier: normalizedEmail, + token, + expires + } + }); + + const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000"; + const verifyUrl = `${baseUrl}/verify/confirm?token=${token}`; + await sendMail({ + to: normalizedEmail, + subject: "E-Mail verifizieren", + text: `Bitte verifiziere deine E-Mail: ${verifyUrl}` + }); + } + return NextResponse.json({ id: user.id, email: user.email }); } diff --git a/app/api/settings/google-places/route.ts b/app/api/settings/google-places/route.ts new file mode 100644 index 0000000..9c22d04 --- /dev/null +++ b/app/api/settings/google-places/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server"; +import { prisma } from "../../../../lib/prisma"; +import { isSuperAdminSession, requireSession } from "../../../../lib/auth-helpers"; + +export async function GET() { + const { session } = await requireSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const apiKeySetting = await prisma.setting.findUnique({ + where: { key: "google_places_api_key" } + }); + const providerSetting = await prisma.setting.findUnique({ + where: { key: "geocoding_provider" } + }); + const registrationSetting = await prisma.setting.findUnique({ + where: { key: "registration_enabled" } + }); + + const apiKey = apiKeySetting?.value || ""; + const provider = + providerSetting?.value || (apiKey ? "google" : "osm"); + const registrationEnabled = registrationSetting?.value !== "false"; + + return NextResponse.json({ apiKey, provider, registrationEnabled }); +} + +export async function POST(request: Request) { + const { session } = await requireSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (!isSuperAdminSession(session)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const body = await request.json(); + const { apiKey, provider, registrationEnabled } = body || {}; + + if (!provider || !["google", "osm"].includes(provider)) { + return NextResponse.json({ error: "Provider erforderlich." }, { status: 400 }); + } + + if (provider === "google" && !apiKey) { + return NextResponse.json({ error: "API-Key erforderlich." }, { status: 400 }); + } + + const apiKeyValue = provider === "google" ? apiKey : ""; + + const apiKeySetting = await prisma.setting.upsert({ + where: { key: "google_places_api_key" }, + update: { value: apiKeyValue }, + create: { key: "google_places_api_key", value: apiKeyValue } + }); + + const providerSetting = await prisma.setting.upsert({ + where: { key: "geocoding_provider" }, + update: { value: provider }, + create: { key: "geocoding_provider", value: provider } + }); + + const registrationValue = registrationEnabled === false ? "false" : "true"; + await prisma.setting.upsert({ + where: { key: "registration_enabled" }, + update: { value: registrationValue }, + create: { key: "registration_enabled", value: registrationValue } + }); + + return NextResponse.json({ + apiKey: apiKeySetting.value, + provider: providerSetting.value, + registrationEnabled: registrationValue !== "false" + }); +} diff --git a/app/api/settings/logo/route.ts b/app/api/settings/logo/route.ts new file mode 100644 index 0000000..5a1ac38 --- /dev/null +++ b/app/api/settings/logo/route.ts @@ -0,0 +1,118 @@ +import { NextResponse } from "next/server"; +import { promises as fs } from "fs"; +import path from "path"; +import { prisma } from "../../../../lib/prisma"; +import { isSuperAdminSession, requireSession } from "../../../../lib/auth-helpers"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +const DATA_DIR = path.join(process.cwd(), "prisma", "data"); +const UPLOADS_DIR = path.join(DATA_DIR, "uploads"); + +const MIME_TO_EXT: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/webp": "webp", + "image/svg+xml": "svg" +}; + +const resolveLogoPath = (relativePath: string) => { + const absolutePath = path.join(DATA_DIR, relativePath); + if (!absolutePath.startsWith(DATA_DIR)) { + throw new Error("Ungültiger Pfad."); + } + return absolutePath; +}; + +const getLogoSetting = async () => + prisma.setting.findUnique({ where: { key: "app_logo_path" } }); + +const getLogoTypeSetting = async () => + prisma.setting.findUnique({ where: { key: "app_logo_type" } }); + +export async function POST(request: Request) { + const { session } = await requireSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!isSuperAdminSession(session)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const formData = await request.formData(); + const file = formData.get("file"); + + if (!file || !(file instanceof File)) { + return NextResponse.json({ error: "Datei fehlt." }, { status: 400 }); + } + + const extension = MIME_TO_EXT[file.type]; + if (!extension) { + return NextResponse.json({ error: "Dateityp nicht unterstützt." }, { status: 400 }); + } + + await fs.mkdir(UPLOADS_DIR, { recursive: true }); + + const previousSetting = await getLogoSetting(); + const previousTypeSetting = await getLogoTypeSetting(); + + const filename = `app-logo.${extension}`; + const relativePath = path.join("uploads", filename); + const absolutePath = resolveLogoPath(relativePath); + + const buffer = Buffer.from(await file.arrayBuffer()); + await fs.writeFile(absolutePath, buffer); + + if (previousSetting?.value && previousSetting.value !== relativePath) { + try { + await fs.unlink(resolveLogoPath(previousSetting.value)); + } catch { + // ignore missing old file + } + } + + await prisma.setting.upsert({ + where: { key: "app_logo_path" }, + update: { value: relativePath }, + create: { key: "app_logo_path", value: relativePath } + }); + + await prisma.setting.upsert({ + where: { key: "app_logo_type" }, + update: { value: file.type }, + create: { key: "app_logo_type", value: file.type } + }); + + return NextResponse.json({ ok: true }); +} + +export async function DELETE() { + const { session } = await requireSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!isSuperAdminSession(session)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const logoSetting = await getLogoSetting(); + const typeSetting = await getLogoTypeSetting(); + + if (logoSetting?.value) { + try { + await fs.unlink(resolveLogoPath(logoSetting.value)); + } catch { + // ignore missing file + } + } + + if (logoSetting) { + await prisma.setting.delete({ where: { key: "app_logo_path" } }); + } + if (typeSetting) { + await prisma.setting.delete({ where: { key: "app_logo_type" } }); + } + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/users/route.ts b/app/api/users/route.ts new file mode 100644 index 0000000..93ec82c --- /dev/null +++ b/app/api/users/route.ts @@ -0,0 +1,303 @@ +import { NextResponse } from "next/server"; +import bcrypt from "bcryptjs"; +import { prisma } from "../../../lib/prisma"; +import { + isAdminSession, + isSuperAdminSession, + requireSession +} from "../../../lib/auth-helpers"; + +export async function GET(request: Request) { + const { session } = await requireSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (!isAdminSession(session)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { searchParams } = new URL(request.url); + const status = searchParams.get("status"); + + const users = await prisma.user.findMany({ + where: status ? { status } : undefined, + orderBy: { createdAt: "desc" }, + select: { + id: true, + email: true, + name: true, + status: true, + role: true, + emailVerified: true, + createdAt: true + } + }); + + if (!isSuperAdminSession(session)) { + return NextResponse.json(users); + } + + const emails = users.map((user) => user.email).filter(Boolean); + const attempts = emails.length + ? await prisma.loginAttempt.findMany({ + where: { email: { in: emails } } + }) + : []; + + const stats = attempts.reduce>((acc, attempt) => { + const current = acc[attempt.email] || { + attempts: 0, + lastAttempt: null, + lockedUntil: null + }; + current.attempts += attempt.attempts; + if (!current.lastAttempt || attempt.lastAttempt > current.lastAttempt) { + current.lastAttempt = attempt.lastAttempt; + } + if (!current.lockedUntil || (attempt.lockedUntil && attempt.lockedUntil > current.lockedUntil)) { + current.lockedUntil = attempt.lockedUntil; + } + acc[attempt.email] = current; + return acc; + }, {}); + + const enriched = users.map((user) => ({ + ...user, + loginStats: stats[user.email] || { + attempts: 0, + lastAttempt: null, + lockedUntil: null + } + })); + + return NextResponse.json(enriched); +} + +export async function POST(request: Request) { + 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 { + email, + name, + password, + role, + status, + emailVerified + } = body || {}; + + if (!email || !password) { + return NextResponse.json( + { error: "E-Mail und Passwort sind erforderlich." }, + { status: 400 } + ); + } + + const normalizedEmail = String(email).trim().toLowerCase(); + const allowedRoles = ["USER", "ADMIN", "SUPERADMIN"]; + const allowedStatuses = ["ACTIVE", "PENDING", "DISABLED"]; + const isSuperAdmin = isSuperAdminSession(session); + const nextRole = isSuperAdmin && allowedRoles.includes(role) ? role : "USER"; + const nextStatus = allowedStatuses.includes(status) ? status : "PENDING"; + + if (!normalizedEmail) { + return NextResponse.json({ error: "Ungültige E-Mail." }, { status: 400 }); + } + + const passwordHash = await bcrypt.hash(String(password), 10); + + try { + const user = await prisma.user.create({ + data: { + email: normalizedEmail, + name: name ? String(name).trim() : null, + passwordHash, + role: nextRole, + status: nextStatus, + emailVerified: Boolean(emailVerified) + }, + select: { + id: true, + email: true, + name: true, + role: true, + status: true, + emailVerified: true, + createdAt: true + } + }); + + return NextResponse.json(user, { status: 201 }); + } catch (err) { + return NextResponse.json( + { error: "Benutzer konnte nicht angelegt werden." }, + { status: 400 } + ); + } +} + +export async function PATCH(request: Request) { + 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 { + userId, + status, + role, + name, + email, + password, + emailVerified, + resetLoginAttempts + } = body || {}; + + if (!userId) { + return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 }); + } + + const target = await prisma.user.findUnique({ + where: { id: userId }, + select: { role: true } + }); + + if (!target) { + return NextResponse.json({ error: "Benutzer nicht gefunden." }, { status: 404 }); + } + + if (resetLoginAttempts) { + if (!isSuperAdminSession(session)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + const userRecord = await prisma.user.findUnique({ + where: { id: userId }, + select: { email: true } + }); + if (userRecord?.email) { + await prisma.loginAttempt.deleteMany({ where: { email: userRecord.email } }); + } + return NextResponse.json({ ok: true }); + } + + if (target.role === "SUPERADMIN" && !isSuperAdminSession(session)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const allowedStatuses = ["ACTIVE", "PENDING", "DISABLED"]; + const data: Record = {}; + + if (status) { + if (!allowedStatuses.includes(status)) { + return NextResponse.json({ error: "Ungültiger Status." }, { status: 400 }); + } + data.status = status; + } + + if (name !== undefined) { + data.name = name ? String(name).trim() : null; + } + + if (email) { + data.email = String(email).trim().toLowerCase(); + } + + if (emailVerified !== undefined) { + data.emailVerified = Boolean(emailVerified); + } + + if (password) { + data.passwordHash = await bcrypt.hash(String(password), 10); + } + + if (role) { + if (!isSuperAdminSession(session)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + if (!["USER", "ADMIN", "SUPERADMIN"].includes(role)) { + return NextResponse.json({ error: "Ungültige Rolle." }, { status: 400 }); + } + data.role = role; + } + + const user = await prisma.user.update({ + where: { id: userId }, + data, + select: { + id: true, + email: true, + name: true, + role: true, + status: true, + emailVerified: true, + createdAt: true + } + }); + + return NextResponse.json({ id: user.id, status: user.status }); +} + +export async function DELETE(request: Request) { + const { session } = await requireSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (!isAdminSession(session)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { searchParams } = new URL(request.url); + const userId = searchParams.get("id"); + + if (!userId) { + return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 }); + } + + if (session.user?.id === userId) { + return NextResponse.json( + { error: "Eigenes Konto kann nicht gelöscht werden." }, + { status: 400 } + ); + } + + const target = await prisma.user.findUnique({ + where: { id: userId }, + select: { role: true } + }); + + if (!target) { + return NextResponse.json({ error: "Benutzer nicht gefunden." }, { status: 404 }); + } + + if (target.role !== "USER" && !isSuperAdminSession(session)) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + await prisma.session.deleteMany({ where: { userId } }); + await prisma.account.deleteMany({ where: { userId } }); + + await prisma.user.update({ + where: { id: userId }, + data: { status: "DISABLED", emailVerified: false } + }); + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/verify-email/confirm/route.ts b/app/api/verify-email/confirm/route.ts new file mode 100644 index 0000000..4b31039 --- /dev/null +++ b/app/api/verify-email/confirm/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { prisma } from "../../../../lib/prisma"; + +export async function POST(request: Request) { + const body = await request.json(); + const { token } = body || {}; + + if (!token) { + return NextResponse.json({ error: "Token erforderlich." }, { status: 400 }); + } + + const record = await prisma.verificationToken.findUnique({ + where: { token } + }); + + if (!record || record.expires < new Date()) { + return NextResponse.json({ error: "Token ungültig." }, { status: 400 }); + } + + const user = await prisma.user.findUnique({ + where: { email: record.identifier } + }); + + if (!user) { + return NextResponse.json({ error: "User nicht gefunden." }, { status: 404 }); + } + + await prisma.user.update({ + where: { id: user.id }, + data: { emailVerified: true } + }); + + await prisma.verificationToken.deleteMany({ + where: { identifier: record.identifier } + }); + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/verify-email/request/route.ts b/app/api/verify-email/request/route.ts new file mode 100644 index 0000000..2df63cc --- /dev/null +++ b/app/api/verify-email/request/route.ts @@ -0,0 +1,44 @@ +import { randomUUID } from "crypto"; +import { NextResponse } from "next/server"; +import { prisma } from "../../../../lib/prisma"; +import { sendMail } from "../../../../lib/mailer"; + +export async function POST(request: Request) { + const body = await request.json(); + const { email } = body || {}; + + if (!email) { + return NextResponse.json({ error: "E-Mail erforderlich." }, { status: 400 }); + } + + const user = await prisma.user.findUnique({ where: { email } }); + if (!user) { + return NextResponse.json({ ok: true }); + } + + if (user.emailVerified) { + return NextResponse.json({ ok: true }); + } + + await prisma.verificationToken.deleteMany({ where: { identifier: email } }); + + const token = randomUUID(); + const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); + await prisma.verificationToken.create({ + data: { + identifier: email, + token, + expires + } + }); + + const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000"; + const verifyUrl = `${baseUrl}/verify/confirm?token=${token}`; + await sendMail({ + to: email, + subject: "E-Mail verifizieren", + text: `Bitte verifiziere deine E-Mail: ${verifyUrl}` + }); + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/views/[id]/categories/route.ts b/app/api/views/[id]/categories/route.ts new file mode 100644 index 0000000..74fd1a9 --- /dev/null +++ b/app/api/views/[id]/categories/route.ts @@ -0,0 +1,65 @@ +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 { categoryId } = body || {}; + if (!categoryId) { + return NextResponse.json({ error: "Kategorie erforderlich." }, { status: 400 }); + } + + await prisma.userViewCategory.upsert({ + where: { viewId_categoryId: { viewId: view.id, categoryId } }, + update: {}, + create: { viewId: view.id, categoryId } + }); + + 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 { categoryId } = body || {}; + if (!categoryId) { + return NextResponse.json({ error: "Kategorie erforderlich." }, { status: 400 }); + } + + await prisma.userViewCategory.deleteMany({ + where: { viewId: view.id, categoryId } + }); + + return NextResponse.json({ ok: true }); +} diff --git a/app/api/views/[id]/items/route.ts b/app/api/views/[id]/items/route.ts index 9e51e2b..d9ad5fd 100644 --- a/app/api/views/[id]/items/route.ts +++ b/app/api/views/[id]/items/route.ts @@ -27,8 +27,34 @@ export async function POST(request: Request, context: { params: { id: string } } return NextResponse.json({ error: "Event erforderlich." }, { status: 400 }); } - await prisma.userViewItem.create({ - data: { viewId: view.id, eventId } + const event = await prisma.event.findUnique({ + where: { id: eventId }, + select: { categoryId: true } + }); + + if (!event) { + return NextResponse.json({ error: "Event nicht gefunden." }, { status: 404 }); + } + + await prisma.userViewExclusion.deleteMany({ + where: { viewId: view.id, eventId } + }); + + if (event.categoryId) { + const subscribed = await prisma.userViewCategory.findUnique({ + where: { + viewId_categoryId: { viewId: view.id, categoryId: event.categoryId } + } + }); + if (subscribed) { + return NextResponse.json({ ok: true }, { status: 201 }); + } + } + + await prisma.userViewItem.upsert({ + where: { viewId_eventId: { viewId: view.id, eventId } }, + update: {}, + create: { viewId: view.id, eventId } }); return NextResponse.json({ ok: true }, { status: 201 }); @@ -52,6 +78,31 @@ export async function DELETE(request: Request, context: { params: { id: string } return NextResponse.json({ error: "Event erforderlich." }, { status: 400 }); } + const event = await prisma.event.findUnique({ + where: { id: eventId }, + select: { categoryId: true } + }); + + if (!event) { + return NextResponse.json({ error: "Event nicht gefunden." }, { status: 404 }); + } + + if (event.categoryId) { + const subscribed = await prisma.userViewCategory.findUnique({ + where: { + viewId_categoryId: { viewId: view.id, categoryId: event.categoryId } + } + }); + if (subscribed) { + await prisma.userViewExclusion.upsert({ + where: { viewId_eventId: { viewId: view.id, eventId } }, + update: {}, + create: { viewId: view.id, eventId } + }); + return NextResponse.json({ ok: true }); + } + } + await prisma.userViewItem.deleteMany({ where: { viewId: view.id, eventId } }); diff --git a/app/api/views/default/rotate/route.ts b/app/api/views/default/rotate/route.ts new file mode 100644 index 0000000..e9db1ba --- /dev/null +++ b/app/api/views/default/rotate/route.ts @@ -0,0 +1,27 @@ +import { randomUUID } from "crypto"; +import { NextResponse } from "next/server"; +import { prisma } from "../../../../../lib/prisma"; +import { requireSession } from "../../../../../lib/auth-helpers"; + +export async function POST() { + const { session } = await requireSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const email = session.user?.email || ""; + const view = await prisma.userView.findFirst({ + where: { user: { email } } + }); + + if (!view) { + return NextResponse.json({ error: "Keine Ansicht gefunden." }, { status: 404 }); + } + + const updated = await prisma.userView.update({ + where: { id: view.id }, + data: { token: randomUUID() } + }); + + return NextResponse.json({ id: updated.id, token: updated.token }); +} diff --git a/app/api/views/default/route.ts b/app/api/views/default/route.ts new file mode 100644 index 0000000..8409c08 --- /dev/null +++ b/app/api/views/default/route.ts @@ -0,0 +1,57 @@ +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 email = session.user?.email || ""; + const existing = await prisma.userView.findFirst({ + where: { user: { email } }, + include: { + items: { include: { event: true } }, + categories: { include: { category: true } }, + exclusions: true + } + }); + + if (existing) { + return NextResponse.json(existing); + } + + const view = await prisma.userView.create({ + data: { + name: "Meine Ansicht", + token: randomUUID(), + user: { connect: { email } } + } + }); + + const categories = await prisma.category.findMany({ + select: { id: true } + }); + + if (categories.length > 0) { + await prisma.userViewCategory.createMany({ + data: categories.map((category) => ({ + viewId: view.id, + categoryId: category.id + })) + }); + } + + const hydrated = await prisma.userView.findUnique({ + where: { id: view.id }, + include: { + items: { include: { event: true } }, + categories: { include: { category: true } }, + exclusions: true + } + }); + + return NextResponse.json(hydrated, { status: 201 }); +} diff --git a/app/api/views/route.ts b/app/api/views/route.ts index 3f15b9b..9f4759b 100644 --- a/app/api/views/route.ts +++ b/app/api/views/route.ts @@ -11,7 +11,11 @@ export async function GET() { const views = await prisma.userView.findMany({ where: { user: { email: session.user?.email || "" } }, - include: { items: { include: { event: true } } }, + include: { + items: { include: { event: true } }, + categories: { include: { category: true } }, + exclusions: true + }, orderBy: { createdAt: "desc" } }); diff --git a/app/globals.css b/app/globals.css index 4379d52..4569817 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,4 +1,5 @@ -@import "@fontsource/space-grotesk/variable.css"; + +@import "leaflet/dist/leaflet.css"; @tailwind base; @tailwind components; @@ -7,18 +8,319 @@ :root { color-scheme: light; --surface: #ffffff; - --ink: #1f1a17; - --muted: #f7efe4; - --line: #e6dccf; - --accent: #ff6b4a; - --accent-strong: #e24a2b; + --ink: #0f0f10; + --muted: #f2f2ee; + --line: #deded6; + --accent: #6f7a4f; + --accent-strong: #4e5837; + --accent-glow: #aab790; + --cool: #2f3b2a; } 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; + radial-gradient(60% 80% at 0% 0%, rgba(170, 183, 144, 0.25) 0%, transparent 60%), + radial-gradient(60% 70% at 100% 0%, rgba(15, 15, 16, 0.08) 0%, transparent 60%), + linear-gradient(180deg, rgba(255, 255, 255, 0.7), transparent 25%), + #f4f4f0; + background-repeat: no-repeat; + background-size: cover; + font-family: "Sora", "Segoe UI", "Helvetica Neue", Arial, sans-serif; +} + +html[data-theme="dark"] { + color-scheme: dark; + --surface: #0f1110; + --ink: #f8f7f2; + --muted: #161a18; + --line: #2b322c; + --accent: #8e9b6b; + --accent-strong: #a3b37a; + --accent-glow: #3b4a2a; + --cool: #c9d4b4; +} + +html[data-theme="dark"] body { + color: var(--ink); + background: + radial-gradient(70% 90% at 0% 0%, rgba(142, 155, 107, 0.22) 0%, transparent 60%), + radial-gradient(80% 70% at 100% 0%, rgba(255, 255, 255, 0.06) 0%, transparent 60%), + linear-gradient(180deg, rgba(15, 17, 16, 0.95), transparent 25%), + #0b0d0c; + background-repeat: no-repeat; + background-size: cover; +} + +@keyframes fadeUp { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes glow { + 0%, + 100% { + box-shadow: 0 0 0 rgba(255, 155, 130, 0); + } + 50% { + box-shadow: 0 0 40px rgba(255, 155, 130, 0.35); + } +} + +@layer components { + .card { + @apply rounded-2xl border border-slate-200/70 bg-white/90 p-5 shadow-[0_20px_40px_rgba(15,15,16,0.08)]; + } + .card-muted { + @apply rounded-2xl border border-slate-200/70 bg-white/70 p-5; + } + .btn-primary { + @apply rounded-full bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:-translate-y-0.5 hover:bg-slate-800; + } + .btn-accent { + @apply rounded-full bg-brand-500 px-4 py-2 text-sm font-semibold text-white transition hover:-translate-y-0.5 hover:bg-brand-700; + } + .btn-ghost { + @apply rounded-full border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 transition hover:-translate-y-0.5 hover:bg-slate-100; + } + .fade-up { + animation: fadeUp 0.7s ease both; + } + .fade-up-delay { + animation: fadeUp 0.7s ease 0.15s both; + } + .glow { + animation: glow 5s ease-in-out infinite; + } +} + +html[data-theme="dark"] .card { + border-color: rgba(71, 85, 105, 0.35); + background: rgba(15, 17, 16, 0.9); + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.45); +} + +html[data-theme="dark"] .card-muted { + border-color: rgba(71, 85, 105, 0.3); + background: rgba(15, 17, 16, 0.7); +} + +html[data-theme="dark"] .btn-primary { + background: #f8f7f2; + color: #0f1110; +} + +html[data-theme="dark"] .btn-primary:hover { + background: #e7e4da; +} + +html[data-theme="dark"] .btn-accent { + background: var(--accent); +} + +html[data-theme="dark"] .btn-accent:hover { + background: var(--accent-strong); +} + +html[data-theme="dark"] .btn-ghost { + border-color: rgba(71, 85, 105, 0.6); + color: #e2e8f0; + background: rgba(15, 17, 16, 0.35); +} + +html[data-theme="dark"] .btn-ghost:hover { + background: rgba(148, 163, 184, 0.12); +} + +html[data-theme="dark"] input, +html[data-theme="dark"] select, +html[data-theme="dark"] textarea { + background: rgba(15, 17, 16, 0.65); + border-color: rgba(71, 85, 105, 0.5); + color: #f8fafc; +} + +html[data-theme="dark"] header { + border-color: rgba(71, 85, 105, 0.35); + background: rgba(15, 17, 16, 0.8); +} + +html[data-theme="dark"] .fc .fc-button { + border-color: rgba(71, 85, 105, 0.5); + background: rgba(15, 17, 16, 0.75); + color: #f8fafc; +} + +html[data-theme="dark"] .fc .fc-button-primary:not(:disabled).fc-button-active, +html[data-theme="dark"] .fc .fc-button-primary:not(:disabled):active { + background: #f8f7f2; + color: #0f1110; +} + +html[data-theme="dark"] .fc .fc-daygrid-event, +html[data-theme="dark"] .fc .fc-timegrid-event { + background: #e2e8f0; + color: #0f1110; +} + +html[data-theme="dark"] .fc .fc-daygrid-event .fc-event-main, +html[data-theme="dark"] .fc .fc-timegrid-event .fc-event-main, +html[data-theme="dark"] .fc .fc-daygrid-event .fc-event-title, +html[data-theme="dark"] .fc .fc-timegrid-event .fc-event-title, +html[data-theme="dark"] .fc .fc-daygrid-event .fc-event-time, +html[data-theme="dark"] .fc .fc-timegrid-event .fc-event-time { + color: #0f1110; +} + +html[data-theme="dark"] .fc .fc-day-today { + background: rgba(248, 247, 242, 0.08); +} + +html[data-theme="dark"] .fc .fc-daygrid-day.fc-day-past { + background: rgba(148, 163, 184, 0.08); +} + +html[data-theme="dark"] .fc .fc-daygrid-day.fc-day-past .fc-daygrid-day-number { + color: rgba(226, 232, 240, 0.7); +} + +html[data-theme="dark"] .drag-handle { + border-color: rgba(71, 85, 105, 0.5); + color: #e2e8f0; + background: rgba(15, 17, 16, 0.8); +} + +html[data-theme="dark"] .drag-handle:hover { + background: rgba(30, 41, 59, 0.9); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.35); +} + +.fc .fc-toolbar-title { + font-size: 1.1rem; + font-weight: 600; +} + +.fc .fc-button { + border-radius: 999px; + border: 1px solid #e2e8f0; + background: #ffffff; + color: #0f172a; + text-transform: none; + padding: 0.4rem 0.8rem; +} + +.fc .fc-button-primary:not(:disabled).fc-button-active, +.fc .fc-button-primary:not(:disabled):active { + background: #0f172a; + color: #ffffff; +} + +.fc .fc-daygrid-event, +.fc .fc-timegrid-event { + border-radius: 0.6rem; + border: none; + background: #1f2937; + color: #ffffff; +} + +.fc .fc-daygrid-event .fc-event-main, +.fc .fc-timegrid-event .fc-event-main, +.fc .fc-daygrid-event .fc-event-title, +.fc .fc-timegrid-event .fc-event-title, +.fc .fc-daygrid-event .fc-event-time, +.fc .fc-timegrid-event .fc-event-time { + color: #ffffff; +} + +.fc .fc-daygrid-event .event-shell, +.fc .fc-timegrid-event .event-shell { + position: relative; + padding-right: 1.75rem; +} + +.fc .fc-daygrid-event .event-toggle, +.fc .fc-timegrid-event .event-toggle { + position: absolute; + right: 0.3rem; + top: 0.3rem; +} + +.drag-handle { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + border: 1px solid #e2e8f0; + padding: 0.35rem 0.55rem; + color: #475569; + background: #ffffff; + cursor: grab; + transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; +} + +.drag-handle:hover { + transform: translateY(-1px); + background: #f8fafc; + box-shadow: 0 6px 16px rgba(15, 23, 42, 0.12); +} + +.drag-handle:active { + cursor: grabbing; + transform: translateY(0); +} + +.drag-card { + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.drag-card.dragging { + opacity: 0.7; + box-shadow: 0 20px 40px rgba(15, 23, 42, 0.18); +} + +.drag-card.drag-target { + outline: 2px dashed rgba(99, 102, 241, 0.35); + outline-offset: 4px; +} + +.drag-card.shift-up { + transform: translateY(-10px); +} + +.drag-card.shift-down { + transform: translateY(10px); +} + +.fc .fc-day-today { + background: rgba(31, 41, 55, 0.08); +} + +.fc .fc-daygrid-day.fc-day-past { + background: rgba(15, 23, 42, 0.03); +} + +.fc .fc-daygrid-day.fc-day-past .fc-daygrid-day-number { + color: rgba(15, 23, 42, 0.5); +} + +@media (max-width: 768px) { + .fc .fc-toolbar { + flex-direction: column; + gap: 0.5rem; + } + + .fc .fc-toolbar-title { + font-size: 1rem; + } + + .fc .fc-button { + padding: 0.35rem 0.7rem; + font-size: 0.75rem; + } } diff --git a/app/layout.tsx b/app/layout.tsx index a217866..56f1d19 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,7 +5,7 @@ import NavBar from "../components/NavBar"; export const metadata: Metadata = { title: "Vereinskalender", - description: "Kalenderapp fuer Vereine" + description: "Kalenderapp für Vereine" }; export default function RootLayout({ @@ -16,10 +16,19 @@ export default function RootLayout({ return ( - - - - + + + + + +