Aktueller Stand
This commit is contained in:
6
.env
6
.env
@@ -1,4 +1,4 @@
|
|||||||
DATABASE_URL="file:./dev.db"
|
DATABASE_URL="file:./data/dev.db"
|
||||||
NEXTAUTH_SECRET="change-me-in-prod"
|
NEXTAUTH_SECRET="change-me-in-prod"
|
||||||
NEXTAUTH_URL="http://localhost:3000"
|
NEXTAUTH_URL="http://docker:3101"
|
||||||
ADMIN_EMAILS="admin@example.com"
|
SUPERADMIN_EMAILS="meikdre@gmx.de"
|
||||||
|
|||||||
12
.env.example
12
.env.example
@@ -1,4 +1,12 @@
|
|||||||
DATABASE_URL="file:./dev.db"
|
DATABASE_URL="file:./data/dev.db"
|
||||||
NEXTAUTH_SECRET="replace-with-strong-secret"
|
NEXTAUTH_SECRET="replace-with-strong-secret"
|
||||||
NEXTAUTH_URL="http://localhost:3000"
|
NEXTAUTH_URL="http://localhost:3101"
|
||||||
ADMIN_EMAILS="admin@example.com"
|
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 <noreply@example.com>"
|
||||||
|
NOMINATIM_USER_AGENT="vereinskalender/1.0 (mailto:admin@example.com)"
|
||||||
|
|||||||
@@ -14,11 +14,8 @@ No build or test scripts are defined yet. When you add tooling, document the exa
|
|||||||
- `make build` for production builds
|
- `make build` for production builds
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
## Coding Style & Naming Conventions
|
||||||
No style configuration is present. When initializing the project, standardize and document:
|
Use consistent formatting for TS/JS (2 spaces) and follow the existing component and API patterns in `app/` and `components/`.
|
||||||
- Indentation (e.g., 2 spaces for JS/TS, 4 for Python)
|
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.
|
||||||
- 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
|
## Testing Guidelines
|
||||||
No testing framework is configured yet. When you add tests:
|
No testing framework is configured yet. When you add tests:
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ RUN --mount=type=cache,target=/root/.npm \
|
|||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
if [ ! -d node_modules/@fullcalendar/core ]; then npm install; fi
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|||||||
108
README.md
108
README.md
@@ -1,15 +1,18 @@
|
|||||||
# Vereinskalender
|
# 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
|
## Features
|
||||||
- Admins koennen Termine sofort freigeben oder Vorschlaege bestaetigen/ablehnen.
|
- Admins können Termine sofort freigeben oder Vorschläge bestätigen/ablehnen.
|
||||||
- Mitglieder schlagen Termine vor; Freigaben laufen ueber das Admin-Panel.
|
- 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.
|
- 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
|
## 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
|
- Backend: Next.js Route Handlers, Prisma ORM, SQLite
|
||||||
- Auth: NextAuth (Credentials + Prisma Adapter)
|
- Auth: NextAuth (Credentials + Prisma Adapter)
|
||||||
- Export: iCal via `ical-generator`
|
- Export: iCal via `ical-generator`
|
||||||
@@ -30,47 +33,114 @@ npm run prisma:migrate
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open `http://localhost:3000`.
|
Öffne `http://localhost:3000`.
|
||||||
|
|
||||||
## Konfiguration
|
## Konfiguration
|
||||||
|
|
||||||
In `.env` (lokal) bzw. per Umgebungsvariablen (Docker/Prod):
|
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_SECRET="replace-with-strong-secret"
|
||||||
NEXTAUTH_URL="http://localhost:3000"
|
NEXTAUTH_URL="http://localhost:3101"
|
||||||
ADMIN_EMAILS="admin@example.com"
|
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 <noreply@example.com>"
|
||||||
|
NOMINATIM_USER_AGENT="vereinskalender/1.0 (mailto:admin@example.com)"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Admin-Setup
|
## Admin-Setup
|
||||||
|
|
||||||
`ADMIN_EMAILS` (kommagetrennt) steuert, welche Accounts beim Signup als Admin markiert werden.
|
`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
|
## Wichtige Befehle
|
||||||
|
|
||||||
- `npm run dev` - lokale Entwicklung
|
- `npm run dev` - lokale Entwicklung
|
||||||
- `npm run build` - Production Build
|
- `npm run build` - Production Build
|
||||||
- `npm run start` - Server starten (Production)
|
- `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:deploy` - Schema push (Container-Start)
|
||||||
- `npm run prisma:studio` - Prisma Studio
|
- `npm run prisma:studio` - Prisma Studio
|
||||||
- `npm run copy:fullcalendar-css` - FullCalendar CSS nach `public/vendor/fullcalendar/` kopieren
|
|
||||||
|
|
||||||
## APIs (kurz)
|
## APIs (kurz)
|
||||||
- `POST /api/register` - Registrierung
|
- `POST /api/register` - Registrierung
|
||||||
- `GET /api/events` - Events (Admins sehen alles, User nur eigene + freigegebene)
|
- `GET /api/events` - Events (Admins sehen alles, User nur eigene + freigegebene)
|
||||||
- `POST /api/events` - Termin vorschlagen/anlegen
|
- `POST /api/events` - Termin vorschlagen/anlegen
|
||||||
- `PATCH /api/events/:id` - Freigeben/Ablehnen (Admin)
|
- `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
|
- `GET /api/views` - Eigene Ansichten
|
||||||
- `POST /api/views` - Ansicht erstellen
|
- `POST /api/views` - Ansicht erstellen
|
||||||
- `POST /api/views/:id/items` - Termin zur Ansicht
|
- `POST /api/views/:id/items` - Termin zur Ansicht
|
||||||
- `DELETE /api/views/:id/items` - Termin entfernen
|
- `DELETE /api/views/:id/items` - Termin entfernen
|
||||||
|
- `GET /api/views/default` - Standardansicht laden/erstellen
|
||||||
- `GET /api/ical/:token` - iCal Feed der Ansicht
|
- `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
|
## 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
|
## Docker
|
||||||
|
|
||||||
@@ -79,15 +149,21 @@ cp .env.example .env
|
|||||||
docker compose up --build
|
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).
|
- `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)
|
## Schnellere Builds (Best Practices)
|
||||||
- `package-lock.json` committen und im Dockerfile `npm ci` nutzen (bereits vorbereitet).
|
- `package-lock.json` committen und im Dockerfile `npm ci` nutzen (bereits vorbereitet).
|
||||||
- BuildKit-Cache nutzen (im Dockerfile aktiv, benoetigt Docker BuildKit).
|
- BuildKit-Cache nutzen (im Dockerfile aktiv, benötigt Docker BuildKit).
|
||||||
- Fuer schnelle lokale Iteration: `docker compose -f docker-compose.dev.yml up --build`.
|
- 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
|
## Sicherheitshinweise
|
||||||
- Keine Secrets committen; `.env` ist in `.dockerignore`.
|
- 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.
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import AdminPanel from "../../components/AdminPanel";
|
import AdminPanel from "../../components/AdminPanel";
|
||||||
|
import AdminSystemSettings from "../../components/AdminSystemSettings";
|
||||||
import { authOptions } from "../../lib/auth";
|
import { authOptions } from "../../lib/auth";
|
||||||
|
|
||||||
export default async function AdminPage() {
|
export default async function AdminPage() {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (session?.user?.role !== "ADMIN") {
|
if (session?.user?.role !== "ADMIN" && session?.user?.role !== "SUPERADMIN") {
|
||||||
return (
|
return (
|
||||||
<div className="rounded border border-dashed border-slate-300 bg-white p-8 text-center">
|
<div className="card-muted text-center">
|
||||||
<p className="text-slate-700">Nur fuer Admins.</p>
|
<p className="text-slate-700">Nur für Admins.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <AdminPanel />;
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<AdminPanel />
|
||||||
|
{session?.user?.role === "SUPERADMIN" && <AdminSystemSettings />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
5
app/admin/settings/page.tsx
Normal file
5
app/admin/settings/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function AdminSettingsPage() {
|
||||||
|
redirect("/admin");
|
||||||
|
}
|
||||||
16
app/admin/users/page.tsx
Normal file
16
app/admin/users/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="card-muted text-center">
|
||||||
|
<p className="text-slate-700">Nur für Admins.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AdminUserApprovals role={session?.user?.role ?? null} />;
|
||||||
|
}
|
||||||
67
app/api/branding/logo/route.ts
Normal file
67
app/api/branding/logo/route.ts
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
120
app/api/categories/route.ts
Normal file
120
app/api/categories/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
@@ -13,16 +13,76 @@ export async function PATCH(request: Request, context: { params: { id: string }
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { status } = body || {};
|
const {
|
||||||
|
status,
|
||||||
if (!status || !["APPROVED", "REJECTED"].includes(status)) {
|
title,
|
||||||
return NextResponse.json({ error: "Status ungueltig." }, { status: 400 });
|
description,
|
||||||
}
|
location,
|
||||||
|
locationPlaceId,
|
||||||
|
locationLat,
|
||||||
|
locationLng,
|
||||||
|
startAt,
|
||||||
|
endAt,
|
||||||
|
categoryId
|
||||||
|
} = body || {};
|
||||||
|
|
||||||
|
if (status && ["APPROVED", "REJECTED"].includes(status)) {
|
||||||
const event = await prisma.event.update({
|
const event = await prisma.event.update({
|
||||||
where: { id: context.params.id },
|
where: { id: context.params.id },
|
||||||
data: { status }
|
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: {
|
||||||
|
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);
|
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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
const events = await prisma.event.findMany({
|
const events = await prisma.event.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: { startAt: "asc" }
|
orderBy: { startAt: "asc" },
|
||||||
|
include: { category: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(events);
|
return NextResponse.json(events);
|
||||||
@@ -38,24 +39,68 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
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(
|
return NextResponse.json(
|
||||||
{ error: "Titel, Start und Ende sind erforderlich." },
|
{ error: "Titel und Start sind erforderlich." },
|
||||||
{ status: 400 }
|
{ 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({
|
const event = await prisma.event.create({
|
||||||
data: {
|
data: {
|
||||||
title,
|
title,
|
||||||
description: description || null,
|
description: description || null,
|
||||||
location: location || null,
|
location: location || null,
|
||||||
startAt: new Date(startAt),
|
locationPlaceId: locationPlaceId || null,
|
||||||
endAt: new Date(endAt),
|
locationLat: locationLat ? Number(locationLat) : null,
|
||||||
|
locationLng: locationLng ? Number(locationLng) : null,
|
||||||
|
startAt: startDate,
|
||||||
|
endAt: endDate,
|
||||||
status: isAdminSession(session) ? "APPROVED" : "PENDING",
|
status: isAdminSession(session) ? "APPROVED" : "PENDING",
|
||||||
createdBy: { connect: { email: session.user?.email || "" } }
|
createdBy: { connect: { email: creatorEmail } },
|
||||||
|
category: { connect: { id: categoryId } }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ export async function GET(
|
|||||||
) {
|
) {
|
||||||
const view = await prisma.userView.findUnique({
|
const view = await prisma.userView.findUnique({
|
||||||
where: { token: context.params.token },
|
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) {
|
if (!view) {
|
||||||
@@ -20,17 +25,36 @@ export async function GET(
|
|||||||
timezone: "Europe/Berlin"
|
timezone: "Europe/Berlin"
|
||||||
});
|
});
|
||||||
|
|
||||||
view.items
|
const excludedIds = new Set(view.exclusions.map((item) => item.eventId));
|
||||||
|
const explicitEvents = view.items
|
||||||
.map((item) => item.event)
|
.map((item) => item.event)
|
||||||
.filter((event) => event.status === "APPROVED")
|
.filter((event) => event.status === "APPROVED");
|
||||||
.forEach((event) => {
|
|
||||||
|
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({
|
calendar.createEvent({
|
||||||
id: event.id,
|
id: event.id,
|
||||||
summary: event.title,
|
summary: event.title,
|
||||||
description: event.description || undefined,
|
description: event.description || undefined,
|
||||||
location: event.location || undefined,
|
location: event.location || undefined,
|
||||||
start: event.startAt,
|
start,
|
||||||
end: event.endAt
|
end
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
143
app/api/ical/import/route.ts
Normal file
143
app/api/ical/import/route.ts
Normal file
@@ -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<string, any>;
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
36
app/api/password-reset/confirm/route.ts
Normal file
36
app/api/password-reset/confirm/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
41
app/api/password-reset/request/route.ts
Normal file
41
app/api/password-reset/request/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
83
app/api/places/autocomplete/route.ts
Normal file
83
app/api/places/autocomplete/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
71
app/api/places/details/route.ts
Normal file
71
app/api/places/details/route.ts
Normal file
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
70
app/api/places/reverse/route.ts
Normal file
70
app/api/places/reverse/route.ts
Normal file
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
91
app/api/profile/route.ts
Normal file
91
app/api/profile/route.ts
Normal file
@@ -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)
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,30 +1,89 @@
|
|||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
import { prisma } from "../../../lib/prisma";
|
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) {
|
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 body = await request.json();
|
||||||
const { email, name, password } = body || {};
|
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 });
|
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) {
|
if (existing) {
|
||||||
return NextResponse.json({ error: "Account existiert bereits." }, { status: 409 });
|
return NextResponse.json({ error: "Account existiert bereits." }, { status: 409 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(password, 10);
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
const superAdmin = isSuperAdminEmail(normalizedEmail);
|
||||||
|
const admin = isAdminEmail(normalizedEmail) || superAdmin;
|
||||||
|
|
||||||
const user = await prisma.user.create({
|
const user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email,
|
email: normalizedEmail,
|
||||||
name: name || null,
|
name: name || null,
|
||||||
passwordHash,
|
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 });
|
return NextResponse.json({ id: user.id, email: user.email });
|
||||||
}
|
}
|
||||||
|
|||||||
76
app/api/settings/google-places/route.ts
Normal file
76
app/api/settings/google-places/route.ts
Normal file
@@ -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"
|
||||||
|
});
|
||||||
|
}
|
||||||
118
app/api/settings/logo/route.ts
Normal file
118
app/api/settings/logo/route.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
"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 });
|
||||||
|
}
|
||||||
303
app/api/users/route.ts
Normal file
303
app/api/users/route.ts
Normal file
@@ -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<Record<string, {
|
||||||
|
attempts: number;
|
||||||
|
lastAttempt: Date | null;
|
||||||
|
lockedUntil: Date | null;
|
||||||
|
}>>((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<string, any> = {};
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
38
app/api/verify-email/confirm/route.ts
Normal file
38
app/api/verify-email/confirm/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
44
app/api/verify-email/request/route.ts
Normal file
44
app/api/verify-email/request/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
65
app/api/views/[id]/categories/route.ts
Normal file
65
app/api/views/[id]/categories/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
@@ -27,8 +27,34 @@ export async function POST(request: Request, context: { params: { id: string } }
|
|||||||
return NextResponse.json({ error: "Event erforderlich." }, { status: 400 });
|
return NextResponse.json({ error: "Event erforderlich." }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.userViewItem.create({
|
const event = await prisma.event.findUnique({
|
||||||
data: { viewId: view.id, eventId }
|
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 });
|
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 });
|
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({
|
await prisma.userViewItem.deleteMany({
|
||||||
where: { viewId: view.id, eventId }
|
where: { viewId: view.id, eventId }
|
||||||
});
|
});
|
||||||
|
|||||||
27
app/api/views/default/rotate/route.ts
Normal file
27
app/api/views/default/rotate/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
57
app/api/views/default/route.ts
Normal file
57
app/api/views/default/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
@@ -11,7 +11,11 @@ export async function GET() {
|
|||||||
|
|
||||||
const views = await prisma.userView.findMany({
|
const views = await prisma.userView.findMany({
|
||||||
where: { user: { email: session.user?.email || "" } },
|
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" }
|
orderBy: { createdAt: "desc" }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
322
app/globals.css
322
app/globals.css
@@ -1,4 +1,5 @@
|
|||||||
@import "@fontsource/space-grotesk/variable.css";
|
|
||||||
|
@import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@@ -7,18 +8,319 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
--surface: #ffffff;
|
--surface: #ffffff;
|
||||||
--ink: #1f1a17;
|
--ink: #0f0f10;
|
||||||
--muted: #f7efe4;
|
--muted: #f2f2ee;
|
||||||
--line: #e6dccf;
|
--line: #deded6;
|
||||||
--accent: #ff6b4a;
|
--accent: #6f7a4f;
|
||||||
--accent-strong: #e24a2b;
|
--accent-strong: #4e5837;
|
||||||
|
--accent-glow: #aab790;
|
||||||
|
--cool: #2f3b2a;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
background:
|
background:
|
||||||
radial-gradient(65% 80% at 0% 0%, #fff1df 0%, transparent 60%),
|
radial-gradient(60% 80% at 0% 0%, rgba(170, 183, 144, 0.25) 0%, transparent 60%),
|
||||||
radial-gradient(65% 80% at 100% 0%, #e9f0ff 0%, transparent 60%),
|
radial-gradient(60% 70% at 100% 0%, rgba(15, 15, 16, 0.08) 0%, transparent 60%),
|
||||||
#f8f2e9;
|
linear-gradient(180deg, rgba(255, 255, 255, 0.7), transparent 25%),
|
||||||
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
#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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import NavBar from "../components/NavBar";
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Vereinskalender",
|
title: "Vereinskalender",
|
||||||
description: "Kalenderapp fuer Vereine"
|
description: "Kalenderapp für Vereine"
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -16,10 +16,19 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<link rel="stylesheet" href="/vendor/fullcalendar/fullcalendar-core.css" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="stylesheet" href="/vendor/fullcalendar/fullcalendar-daygrid.css" />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||||
<link rel="stylesheet" href="/vendor/fullcalendar/fullcalendar-timegrid.css" />
|
<link
|
||||||
<link rel="stylesheet" href="/vendor/fullcalendar/fullcalendar-list.css" />
|
href="https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="stylesheet" href="/vendor/fullcalendar/fullcalendar.css" />
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `(function(){try{var t=localStorage.getItem("theme");var theme=t==="dark"?"dark":"light";document.documentElement.dataset.theme=theme;}catch(e){}})();`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<Providers>
|
<Providers>
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
@@ -17,34 +19,53 @@ export default function LoginPage() {
|
|||||||
const result = await signIn("credentials", {
|
const result = await signIn("credentials", {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
redirect: true,
|
redirect: false
|
||||||
callbackUrl: "/"
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
|
if (result.error === "PENDING") {
|
||||||
|
setError("Dein Konto wartet auf Freischaltung durch einen Admin.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.error === "EMAIL_NOT_VERIFIED") {
|
||||||
|
setError("Bitte bestätige zuerst deine E-Mail.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.error === "LOCKED") {
|
||||||
|
setError("Zu viele Versuche. Bitte später erneut versuchen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError("Login fehlgeschlagen.");
|
setError("Login fehlgeschlagen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result?.ok) {
|
||||||
|
router.push("/");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-md rounded bg-white p-6 shadow-sm">
|
<div className="mx-auto max-w-md card fade-up">
|
||||||
<h1 className="text-2xl font-semibold">Login</h1>
|
<h1 className="text-2xl font-semibold">Login</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-600">
|
||||||
|
Bitte anmelden.
|
||||||
|
</p>
|
||||||
<form onSubmit={onSubmit} className="mt-4 space-y-3">
|
<form onSubmit={onSubmit} className="mt-4 space-y-3">
|
||||||
<input
|
<input
|
||||||
name="email"
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="E-Mail"
|
placeholder="E-Mail"
|
||||||
required
|
required
|
||||||
className="w-full rounded border border-slate-300 px-3 py-2"
|
className="w-full rounded-xl border border-slate-300 px-3 py-2"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Passwort"
|
placeholder="Passwort"
|
||||||
required
|
required
|
||||||
className="w-full rounded border border-slate-300 px-3 py-2"
|
className="w-full rounded-xl border border-slate-300 px-3 py-2"
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="w-full rounded bg-brand-500 px-4 py-2 text-white">
|
<button type="submit" className="btn-accent w-full">
|
||||||
Anmelden
|
Anmelden
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -52,6 +73,18 @@ export default function LoginPage() {
|
|||||||
<p className="mt-4 text-sm text-slate-600">
|
<p className="mt-4 text-sm text-slate-600">
|
||||||
Kein Konto? <Link href="/register" className="text-brand-700">Registrieren</Link>
|
Kein Konto? <Link href="/register" className="text-brand-700">Registrieren</Link>
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
|
Passwort vergessen?{" "}
|
||||||
|
<Link href="/reset" className="text-brand-700">
|
||||||
|
Zurücksetzen
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
|
E-Mail nicht bestätigt?{" "}
|
||||||
|
<Link href="/verify" className="text-brand-700">
|
||||||
|
Link erneut senden
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
29
app/page.tsx
29
app/page.tsx
@@ -1,24 +1,23 @@
|
|||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import CalendarBoard from "../components/CalendarBoard";
|
import CalendarBoard from "../components/CalendarBoard";
|
||||||
import EventForm from "../components/EventForm";
|
import { authOptions } from "../lib/auth";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default async function HomePage() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<section className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-brand-500 via-brand-700 to-slate-900 p-8 text-white shadow-lg">
|
{session?.user?.status === "ACTIVE" ? (
|
||||||
<div className="absolute -right-16 -top-16 h-40 w-40 rounded-full bg-white/20 blur-2xl" />
|
<CalendarBoard />
|
||||||
<div className="absolute -bottom-20 left-10 h-56 w-56 rounded-full bg-white/10 blur-3xl" />
|
) : session?.user ? (
|
||||||
<div className="relative">
|
<div className="card-muted text-center">
|
||||||
<h1 className="text-3xl font-semibold">Vereinskalender</h1>
|
<p className="text-slate-700">
|
||||||
<p className="mt-2 max-w-2xl text-sm text-white/90">
|
Dein Konto wartet auf Freischaltung durch einen Admin.
|
||||||
Termine einstellen, abstimmen und als persoenlichen Kalender abonnieren.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
) : (
|
||||||
|
redirect("/login")
|
||||||
<div className="grid gap-8 lg:grid-cols-[1.2fr_0.8fr]">
|
)}
|
||||||
<CalendarBoard />
|
|
||||||
<EventForm />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,30 +40,33 @@ export default function RegisterPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-md rounded bg-white p-6 shadow-sm">
|
<div className="mx-auto max-w-md card fade-up">
|
||||||
<h1 className="text-2xl font-semibold">Registrieren</h1>
|
<h1 className="text-2xl font-semibold">Registrieren</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-600">
|
||||||
|
Erstelle ein Konto, um Termine vorzuschlagen und eigene Ansichten anzulegen.
|
||||||
|
</p>
|
||||||
<form onSubmit={onSubmit} className="mt-4 space-y-3">
|
<form onSubmit={onSubmit} className="mt-4 space-y-3">
|
||||||
<input
|
<input
|
||||||
name="name"
|
name="name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
className="w-full rounded border border-slate-300 px-3 py-2"
|
className="w-full rounded-xl border border-slate-300 px-3 py-2"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
name="email"
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="E-Mail"
|
placeholder="E-Mail"
|
||||||
required
|
required
|
||||||
className="w-full rounded border border-slate-300 px-3 py-2"
|
className="w-full rounded-xl border border-slate-300 px-3 py-2"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Passwort"
|
placeholder="Passwort"
|
||||||
required
|
required
|
||||||
className="w-full rounded border border-slate-300 px-3 py-2"
|
className="w-full rounded-xl border border-slate-300 px-3 py-2"
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="w-full rounded bg-brand-500 px-4 py-2 text-white">
|
<button type="submit" className="btn-accent w-full">
|
||||||
Konto anlegen
|
Konto anlegen
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
19
app/reset/confirm/page.tsx
Normal file
19
app/reset/confirm/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import ResetConfirmClient from "./reset-confirm-client";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default function ResetConfirmPage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="mx-auto max-w-md card fade-up">
|
||||||
|
<h1 className="text-2xl font-semibold">Neues Passwort</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-600">Link wird geladen...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ResetConfirmClient />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
app/reset/confirm/reset-confirm-client.tsx
Normal file
69
app/reset/confirm/reset-confirm-client.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export default function ResetConfirmClient() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const token = searchParams.get("token");
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setStatus(null);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
const newPassword = formData.get("newPassword");
|
||||||
|
|
||||||
|
const response = await fetch("/api/password-reset/confirm", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ token, newPassword })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setError(data.error || "Passwort konnte nicht geändert werden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("Passwort aktualisiert. Du kannst dich jetzt anmelden.");
|
||||||
|
event.currentTarget.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-md card fade-up">
|
||||||
|
<h1 className="text-2xl font-semibold">Ungültiger Link</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-600">
|
||||||
|
Der Link ist unvollständig oder abgelaufen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-md card fade-up">
|
||||||
|
<h1 className="text-2xl font-semibold">Neues Passwort</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-600">
|
||||||
|
Setze ein neues Passwort für dein Konto.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={onSubmit} className="mt-4 space-y-3">
|
||||||
|
<input
|
||||||
|
name="newPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="Neues Passwort"
|
||||||
|
required
|
||||||
|
className="w-full rounded-xl border border-slate-300 px-3 py-2"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn-accent w-full">
|
||||||
|
Passwort speichern
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{status && <p className="mt-3 text-sm text-emerald-600">{status}</p>}
|
||||||
|
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
app/reset/page.tsx
Normal file
54
app/reset/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export default function ResetPage() {
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setStatus(null);
|
||||||
|
setError(null);
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
const email = formData.get("email");
|
||||||
|
|
||||||
|
const response = await fetch("/api/password-reset/request", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setError(data.error || "Anfrage fehlgeschlagen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("Falls ein Konto existiert, wurde ein Link versendet.");
|
||||||
|
event.currentTarget.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-md card fade-up">
|
||||||
|
<h1 className="text-2xl font-semibold">Passwort zurücksetzen</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-600">
|
||||||
|
Gib deine E-Mail an und wir senden dir einen Link zum Zurücksetzen.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={onSubmit} className="mt-4 space-y-3">
|
||||||
|
<input
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="E-Mail"
|
||||||
|
required
|
||||||
|
className="w-full rounded-xl border border-slate-300 px-3 py-2"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn-accent w-full">
|
||||||
|
Link senden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{status && <p className="mt-3 text-sm text-emerald-600">{status}</p>}
|
||||||
|
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
327
app/settings/page.tsx
Normal file
327
app/settings/page.tsx
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { signOut, useSession } from "next-auth/react";
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { data } = useSession();
|
||||||
|
const [viewToken, setViewToken] = useState<string | null>(null);
|
||||||
|
const [viewId, setViewId] = useState<string | null>(null);
|
||||||
|
const [subscribedCategories, setSubscribedCategories] = useState<Set<string>>(new Set());
|
||||||
|
const [allCategories, setAllCategories] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
const [categoryError, setCategoryError] = useState<string | null>(null);
|
||||||
|
const [categoryStatus, setCategoryStatus] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
const [profileError, setProfileError] = useState<string | null>(null);
|
||||||
|
const [profileStatus, setProfileStatus] = useState<string | null>(null);
|
||||||
|
const [theme, setTheme] = useState<"light" | "dark">("light");
|
||||||
|
const [copyStatus, setCopyStatus] = useState<"success" | "error" | null>(null);
|
||||||
|
|
||||||
|
const loadView = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/views/default");
|
||||||
|
if (!response.ok) return;
|
||||||
|
const payload = await response.json();
|
||||||
|
setViewToken(payload.token);
|
||||||
|
setViewId(payload.id);
|
||||||
|
const ids = new Set<string>(
|
||||||
|
(payload.categories || []).map((item: { categoryId: string }) => item.categoryId)
|
||||||
|
);
|
||||||
|
setSubscribedCategories(ids);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCategories = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/categories");
|
||||||
|
if (!response.ok) return;
|
||||||
|
setAllCategories(await response.json());
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data?.user) {
|
||||||
|
loadView();
|
||||||
|
loadCategories();
|
||||||
|
}
|
||||||
|
}, [data?.user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const saved = window.localStorage.getItem("theme");
|
||||||
|
const next = saved === "dark" ? "dark" : "light";
|
||||||
|
setTheme(next);
|
||||||
|
document.documentElement.dataset.theme = next;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const rotateToken = async () => {
|
||||||
|
setError(null);
|
||||||
|
setStatus(null);
|
||||||
|
setCopyStatus(null);
|
||||||
|
const response = await fetch("/api/views/default/rotate", { method: "POST" });
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json();
|
||||||
|
setError(payload.error || "Token konnte nicht erneuert werden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
setViewToken(payload.token);
|
||||||
|
setStatus("Neuer iCal-Link erstellt.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateProfile = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setProfileError(null);
|
||||||
|
setProfileStatus(null);
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
const payload = {
|
||||||
|
currentPassword: formData.get("currentPassword"),
|
||||||
|
newEmail: formData.get("newEmail"),
|
||||||
|
newPassword: formData.get("newPassword")
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch("/api/profile", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setProfileError(data.error || "Profil konnte nicht aktualisiert werden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataRes = await response.json();
|
||||||
|
setProfileStatus("Profil aktualisiert.");
|
||||||
|
if (dataRes.changedEmail || dataRes.changedPassword) {
|
||||||
|
setProfileStatus("Profil aktualisiert. Bitte erneut anmelden.");
|
||||||
|
await signOut({ callbackUrl: "/login" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseUrl = typeof window === "undefined" ? "" : window.location.origin;
|
||||||
|
const icalUrl = viewToken ? `${baseUrl}/api/ical/${viewToken}` : "";
|
||||||
|
|
||||||
|
const applyTheme = (next: "light" | "dark") => {
|
||||||
|
setTheme(next);
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.localStorage.setItem("theme", next);
|
||||||
|
document.documentElement.dataset.theme = next;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyIcalUrl = async () => {
|
||||||
|
if (!icalUrl) return;
|
||||||
|
setCopyStatus(null);
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(icalUrl);
|
||||||
|
} else {
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = icalUrl;
|
||||||
|
textarea.setAttribute("readonly", "true");
|
||||||
|
textarea.style.position = "absolute";
|
||||||
|
textarea.style.left = "-9999px";
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
|
setCopyStatus("success");
|
||||||
|
} catch {
|
||||||
|
setCopyStatus("error");
|
||||||
|
}
|
||||||
|
window.setTimeout(() => setCopyStatus(null), 2500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCategory = async (categoryId: string) => {
|
||||||
|
if (!viewId) return;
|
||||||
|
setCategoryError(null);
|
||||||
|
setCategoryStatus(null);
|
||||||
|
const isSubscribed = subscribedCategories.has(categoryId);
|
||||||
|
const response = await fetch(`/api/views/${viewId}/categories`, {
|
||||||
|
method: isSubscribed ? "DELETE" : "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ categoryId })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const payload = await response.json();
|
||||||
|
setCategoryError(payload.error || "Kategorien konnten nicht aktualisiert werden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = new Set(subscribedCategories);
|
||||||
|
if (isSubscribed) {
|
||||||
|
next.delete(categoryId);
|
||||||
|
setCategoryStatus("Kategorie entfernt.");
|
||||||
|
} else {
|
||||||
|
next.add(categoryId);
|
||||||
|
setCategoryStatus("Kategorie abonniert.");
|
||||||
|
}
|
||||||
|
setSubscribedCategories(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="card space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">Profil</p>
|
||||||
|
<h1 className="text-2xl font-semibold">Einstellungen</h1>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={updateProfile} className="grid gap-3 md:grid-cols-2">
|
||||||
|
<input
|
||||||
|
name="newEmail"
|
||||||
|
type="email"
|
||||||
|
placeholder={`Neue E-Mail (aktuell: ${data?.user?.email || ""})`}
|
||||||
|
className="rounded-xl border border-slate-300 px-3 py-2 md:col-span-2"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="newPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="Neues Passwort"
|
||||||
|
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="currentPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="Aktuelles Passwort (Pflicht)"
|
||||||
|
required
|
||||||
|
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn-accent md:col-span-2">
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{profileStatus && (
|
||||||
|
<p className="text-sm text-emerald-600">{profileStatus}</p>
|
||||||
|
)}
|
||||||
|
{profileError && <p className="text-sm text-red-600">{profileError}</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||||
|
Darstellung
|
||||||
|
</p>
|
||||||
|
<h2 className="text-lg font-semibold">Theme</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyTheme("light")}
|
||||||
|
className={`rounded-full border px-4 py-2 text-sm font-semibold transition ${
|
||||||
|
theme === "light"
|
||||||
|
? "border-slate-900 bg-slate-900 text-white"
|
||||||
|
: "border-slate-200 text-slate-700 hover:bg-slate-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Hell
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyTheme("dark")}
|
||||||
|
className={`rounded-full border px-4 py-2 text-sm font-semibold transition ${
|
||||||
|
theme === "dark"
|
||||||
|
? "border-slate-900 bg-slate-900 text-white"
|
||||||
|
: "border-slate-200 text-slate-700 hover:bg-slate-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Dunkel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">iCal</p>
|
||||||
|
<h2 className="text-lg font-semibold">Persönlicher Kalenderlink</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
Dein Link kann in externen Kalender-Apps abonniert werden.
|
||||||
|
</p>
|
||||||
|
{viewToken ? (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 text-sm">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="font-medium">iCal URL</p>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copyIcalUrl}
|
||||||
|
aria-label="iCal-Link kopieren"
|
||||||
|
className="rounded-full border border-slate-200 p-2 text-slate-600 transition hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" />
|
||||||
|
<rect x="3" y="3" width="13" height="13" rx="2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{copyStatus && (
|
||||||
|
<div
|
||||||
|
className={`absolute right-0 top-full mt-2 rounded-full px-3 py-1 text-[11px] font-semibold shadow ${
|
||||||
|
copyStatus === "success"
|
||||||
|
? "bg-emerald-100 text-emerald-700"
|
||||||
|
: "bg-rose-100 text-rose-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{copyStatus === "success" ? "Kopiert" : "Fehler"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="break-all text-slate-700">{icalUrl}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-600">iCal-Link wird geladen...</p>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn-ghost" onClick={rotateToken}>
|
||||||
|
Link erneuern
|
||||||
|
</button>
|
||||||
|
{status && <p className="text-sm text-emerald-600">{status}</p>}
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||||
|
Kategorien
|
||||||
|
</p>
|
||||||
|
<h2 className="text-lg font-semibold">Kategorien abonnieren</h2>
|
||||||
|
</div>
|
||||||
|
{allCategories.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-600">Noch keine Kategorien vorhanden.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{allCategories.map((category) => {
|
||||||
|
const isSubscribed = subscribedCategories.has(category.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={category.id}
|
||||||
|
type="button"
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs ${
|
||||||
|
isSubscribed
|
||||||
|
? "border-emerald-200 bg-emerald-50 text-emerald-700"
|
||||||
|
: "border-slate-200 text-slate-700"
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleCategory(category.id)}
|
||||||
|
>
|
||||||
|
{category.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{categoryStatus && (
|
||||||
|
<p className="text-sm text-emerald-600">{categoryStatus}</p>
|
||||||
|
)}
|
||||||
|
{categoryError && <p className="text-sm text-red-600">{categoryError}</p>}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
app/verify/confirm/page.tsx
Normal file
19
app/verify/confirm/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import VerifyConfirmClient from "./verify-confirm-client";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default function VerifyConfirmPage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="mx-auto max-w-md card fade-up">
|
||||||
|
<h1 className="text-2xl font-semibold">E-Mail verifizieren</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-600">Link wird geladen...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<VerifyConfirmClient />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
app/verify/confirm/verify-confirm-client.tsx
Normal file
55
app/verify/confirm/verify-confirm-client.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export default function VerifyConfirmClient() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const token = searchParams.get("token");
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const onConfirm = async () => {
|
||||||
|
setStatus(null);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await fetch("/api/verify-email/confirm", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ token })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setError(data.error || "Verifizierung fehlgeschlagen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("E-Mail verifiziert. Du kannst dich jetzt anmelden.");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-md card fade-up">
|
||||||
|
<h1 className="text-2xl font-semibold">Ungültiger Link</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-600">
|
||||||
|
Der Link ist unvollständig oder abgelaufen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-md card fade-up space-y-3">
|
||||||
|
<h1 className="text-2xl font-semibold">E-Mail verifizieren</h1>
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
Klicke auf den Button, um deine E-Mail zu bestätigen.
|
||||||
|
</p>
|
||||||
|
<button type="button" className="btn-accent" onClick={onConfirm}>
|
||||||
|
E-Mail bestätigen
|
||||||
|
</button>
|
||||||
|
{status && <p className="text-sm text-emerald-600">{status}</p>}
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
app/verify/page.tsx
Normal file
54
app/verify/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export default function VerifyPage() {
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setStatus(null);
|
||||||
|
setError(null);
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
const email = formData.get("email");
|
||||||
|
|
||||||
|
const response = await fetch("/api/verify-email/request", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setError(data.error || "Anfrage fehlgeschlagen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("Falls ein Konto existiert, wurde ein Link versendet.");
|
||||||
|
event.currentTarget.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-md card fade-up">
|
||||||
|
<h1 className="text-2xl font-semibold">E-Mail verifizieren</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-600">
|
||||||
|
Gib deine E-Mail an, um einen neuen Verifizierungslink zu erhalten.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={onSubmit} className="mt-4 space-y-3">
|
||||||
|
<input
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="E-Mail"
|
||||||
|
required
|
||||||
|
className="w-full rounded-xl border border-slate-300 px-3 py-2"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn-accent w-full">
|
||||||
|
Link senden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{status && <p className="mt-3 text-sm text-emerald-600">{status}</p>}
|
||||||
|
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ export default async function ViewsPage() {
|
|||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded border border-dashed border-slate-300 bg-white p-8 text-center">
|
<div className="card-muted text-center">
|
||||||
<p className="text-slate-700">Bitte anmelden, um Ansichten zu verwalten.</p>
|
<p className="text-slate-700">Bitte anmelden, um Ansichten zu verwalten.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,18 +9,40 @@ type EventItem = {
|
|||||||
endAt: string;
|
endAt: string;
|
||||||
status: string;
|
status: string;
|
||||||
location?: string | null;
|
location?: string | null;
|
||||||
|
locationPlaceId?: string | null;
|
||||||
|
locationLat?: number | null;
|
||||||
|
locationLng?: number | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
category?: { id: string; name: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AdminPanel() {
|
export default function AdminPanel() {
|
||||||
const [events, setEvents] = useState<EventItem[]>([]);
|
const [events, setEvents] = useState<EventItem[]>([]);
|
||||||
|
const [allEvents, setAllEvents] = useState<EventItem[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [categories, setCategories] = useState<{ id: string; name: string }[]>(
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const [categoryError, setCategoryError] = useState<string | null>(null);
|
||||||
|
const [categoryStatus, setCategoryStatus] = useState<string | null>(null);
|
||||||
|
const [categoryModalOpen, setCategoryModalOpen] = useState(false);
|
||||||
|
const [categoryModalError, setCategoryModalError] = useState<string | null>(null);
|
||||||
|
const [categoryModalStatus, setCategoryModalStatus] = useState<string | null>(null);
|
||||||
|
const [editingCategory, setEditingCategory] = useState<{ id: string; name: string } | null>(null);
|
||||||
|
const [editEvent, setEditEvent] = useState<EventItem | null>(null);
|
||||||
|
const [editStatus, setEditStatus] = useState<string | null>(null);
|
||||||
|
const [editError, setEditError] = useState<string | null>(null);
|
||||||
|
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||||
|
const [importFile, setImportFile] = useState<File | null>(null);
|
||||||
|
const [importCategoryId, setImportCategoryId] = useState("");
|
||||||
|
const [importStatus, setImportStatus] = useState<string | null>(null);
|
||||||
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/events?status=PENDING");
|
const response = await fetch("/api/events?status=PENDING");
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Vorschlaege konnten nicht geladen werden.");
|
throw new Error("Vorschläge konnten nicht geladen werden.");
|
||||||
}
|
}
|
||||||
setEvents(await response.json());
|
setEvents(await response.json());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -28,8 +50,34 @@ export default function AdminPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadAllEvents = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/events");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Termine konnten nicht geladen werden.");
|
||||||
|
}
|
||||||
|
setAllEvents(await response.json());
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCategories = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/categories");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Kategorien konnten nicht geladen werden.");
|
||||||
|
}
|
||||||
|
setCategories(await response.json());
|
||||||
|
} catch (err) {
|
||||||
|
setCategoryError((err as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
|
loadCategories();
|
||||||
|
loadAllEvents();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateStatus = async (id: string, status: "APPROVED" | "REJECTED") => {
|
const updateStatus = async (id: string, status: "APPROVED" | "REJECTED") => {
|
||||||
@@ -39,18 +87,230 @@ export default function AdminPanel() {
|
|||||||
body: JSON.stringify({ status })
|
body: JSON.stringify({ status })
|
||||||
});
|
});
|
||||||
load();
|
load();
|
||||||
|
loadAllEvents();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteEvent = async (id: string) => {
|
||||||
|
const ok = window.confirm("Termin wirklich löschen?");
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
await fetch(`/api/events/${id}`, { method: "DELETE" });
|
||||||
|
load();
|
||||||
|
loadAllEvents();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLocalDateTime = (value?: string | null) => {
|
||||||
|
if (!value) return "";
|
||||||
|
const date = new Date(value);
|
||||||
|
const offset = date.getTimezoneOffset() * 60000;
|
||||||
|
return new Date(date.getTime() - offset).toISOString().slice(0, 16);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toIsoString = (value: FormDataEntryValue | null) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const raw = String(value);
|
||||||
|
if (!raw) return null;
|
||||||
|
const date = new Date(raw);
|
||||||
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
|
return date.toISOString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateEvent = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setEditStatus(null);
|
||||||
|
setEditError(null);
|
||||||
|
|
||||||
|
if (!editEvent) return;
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
const payload = {
|
||||||
|
title: formData.get("title"),
|
||||||
|
description: formData.get("description"),
|
||||||
|
location: formData.get("location"),
|
||||||
|
locationPlaceId: formData.get("locationPlaceId"),
|
||||||
|
locationLat: formData.get("locationLat"),
|
||||||
|
locationLng: formData.get("locationLng"),
|
||||||
|
startAt: toIsoString(formData.get("startAt")),
|
||||||
|
endAt: toIsoString(formData.get("endAt")),
|
||||||
|
categoryId: formData.get("categoryId")
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`/api/events/${editEvent.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setEditError(data.error || "Termin konnte nicht aktualisiert werden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await response.json();
|
||||||
|
setEditStatus("Termin aktualisiert.");
|
||||||
|
setEditEvent(updated);
|
||||||
|
load();
|
||||||
|
loadAllEvents();
|
||||||
|
setIsEditOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createCategory = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setCategoryError(null);
|
||||||
|
setCategoryStatus(null);
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
const rawName = String(formData.get("name") || "").trim();
|
||||||
|
if (!rawName) {
|
||||||
|
setCategoryError("Name erforderlich.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch("/api/categories", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: rawName })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setCategoryError(data.error || "Kategorie konnte nicht angelegt werden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await response.json();
|
||||||
|
setCategories((prev) => {
|
||||||
|
if (prev.some((item) => item.id === created.id)) return prev;
|
||||||
|
return [...prev, created].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
});
|
||||||
|
event.currentTarget.reset();
|
||||||
|
setCategoryStatus("Kategorie angelegt.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCategoryModal = (category: { id: string; name: string }) => {
|
||||||
|
setEditingCategory(category);
|
||||||
|
setCategoryModalError(null);
|
||||||
|
setCategoryModalStatus(null);
|
||||||
|
setCategoryModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeCategoryModal = () => {
|
||||||
|
setEditingCategory(null);
|
||||||
|
setCategoryModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCategory = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!editingCategory) return;
|
||||||
|
setCategoryModalError(null);
|
||||||
|
setCategoryModalStatus(null);
|
||||||
|
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
const name = String(formData.get("name") || "").trim();
|
||||||
|
if (!name) {
|
||||||
|
setCategoryModalError("Name erforderlich.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch("/api/categories", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ id: editingCategory.id, name })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setCategoryModalError(data.error || "Kategorie konnte nicht aktualisiert werden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await response.json();
|
||||||
|
setCategories((prev) =>
|
||||||
|
prev
|
||||||
|
.map((item) => (item.id === updated.id ? updated : item))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
);
|
||||||
|
setCategoryModalStatus("Kategorie aktualisiert.");
|
||||||
|
setCategoryModalOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteCategory = async (categoryId: string) => {
|
||||||
|
const ok = window.confirm("Kategorie wirklich löschen? Zugeordnete Termine bleiben erhalten.");
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
const response = await fetch(`/api/categories?id=${categoryId}`, {
|
||||||
|
method: "DELETE"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setCategoryError(data.error || "Kategorie konnte nicht gelöscht werden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCategories((prev) => prev.filter((item) => item.id !== categoryId));
|
||||||
|
setCategoryStatus("Kategorie gelöscht.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const importIcal = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setImportStatus(null);
|
||||||
|
setImportError(null);
|
||||||
|
|
||||||
|
if (!importFile) {
|
||||||
|
setImportError("Bitte eine iCal-Datei auswählen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!importCategoryId) {
|
||||||
|
setImportError("Bitte eine Kategorie auswählen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", importFile);
|
||||||
|
formData.append("categoryId", importCategoryId);
|
||||||
|
|
||||||
|
const response = await fetch("/api/ical/import", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setImportError(data.error || "Import fehlgeschlagen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const details = [
|
||||||
|
`${data.created || 0} importiert`,
|
||||||
|
`${data.duplicates || 0} doppelt`,
|
||||||
|
`${data.skipped || 0} übersprungen`
|
||||||
|
];
|
||||||
|
if (data.recurringSkipped) {
|
||||||
|
details.push(`${data.recurringSkipped} wiederkehrend`);
|
||||||
|
}
|
||||||
|
setImportStatus(`Import abgeschlossen: ${details.join(", ")}.`);
|
||||||
|
setImportFile(null);
|
||||||
|
setImportCategoryId("");
|
||||||
|
load();
|
||||||
|
loadAllEvents();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4 fade-up">
|
||||||
<h1 className="text-2xl font-semibold">Adminfreigaben</h1>
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">Admin</p>
|
||||||
|
<h1 className="text-2xl font-semibold">Offene Vorschläge</h1>
|
||||||
|
</div>
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
{events.length === 0 ? (
|
{events.length === 0 ? (
|
||||||
<p className="text-slate-600">Keine offenen Vorschlaege.</p>
|
<div className="card-muted">
|
||||||
|
<p className="text-slate-600">Keine offenen Vorschläge.</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{events.map((event) => (
|
{events.map((event) => (
|
||||||
<div key={event.id} className="rounded border border-slate-200 bg-white p-4">
|
<div key={event.id} className="card">
|
||||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-medium">{event.title}</h2>
|
<h2 className="text-lg font-medium">{event.title}</h2>
|
||||||
@@ -65,14 +325,14 @@ export default function AdminPanel() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateStatus(event.id, "APPROVED")}
|
onClick={() => updateStatus(event.id, "APPROVED")}
|
||||||
className="rounded bg-emerald-600 px-3 py-1.5 text-white"
|
className="rounded-full bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white"
|
||||||
>
|
>
|
||||||
Freigeben
|
Freigeben
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateStatus(event.id, "REJECTED")}
|
onClick={() => updateStatus(event.id, "REJECTED")}
|
||||||
className="rounded bg-red-600 px-3 py-1.5 text-white"
|
className="rounded-full bg-red-600 px-3 py-1.5 text-sm font-semibold text-white"
|
||||||
>
|
>
|
||||||
Ablehnen
|
Ablehnen
|
||||||
</button>
|
</button>
|
||||||
@@ -85,6 +345,331 @@ export default function AdminPanel() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<section className="card space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||||
|
Kategorien
|
||||||
|
</p>
|
||||||
|
<h2 className="text-lg font-semibold">Kategorien verwalten</h2>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={createCategory} className="flex flex-wrap gap-2">
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
placeholder="z.B. Training"
|
||||||
|
className="flex-1 rounded-xl border border-slate-300 px-3 py-2"
|
||||||
|
/>
|
||||||
|
<button className="btn-accent" type="submit">
|
||||||
|
Anlegen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{categoryStatus && (
|
||||||
|
<p className="text-sm text-emerald-600">{categoryStatus}</p>
|
||||||
|
)}
|
||||||
|
{categoryError && (
|
||||||
|
<p className="text-sm text-red-600">{categoryError}</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{categories.length === 0 ? (
|
||||||
|
<span className="text-sm text-slate-600">
|
||||||
|
Noch keine Kategorien.
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
categories.map((category) => (
|
||||||
|
<div
|
||||||
|
key={category.id}
|
||||||
|
className="flex items-center justify-between rounded-xl border border-slate-200 px-3 py-2 text-sm text-slate-700"
|
||||||
|
>
|
||||||
|
<span>{category.name}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-700"
|
||||||
|
onClick={() => openCategoryModal(category)}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-full border border-red-200 px-3 py-1 text-xs text-red-600"
|
||||||
|
onClick={() => deleteCategory(category.id)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{categoryModalOpen && editingCategory && (
|
||||||
|
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/40 px-4 py-6">
|
||||||
|
<div className="card w-full max-w-md">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">Kategorie bearbeiten</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-sm text-slate-600"
|
||||||
|
onClick={closeCategoryModal}
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={updateCategory} className="mt-4 space-y-3">
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
defaultValue={editingCategory.name}
|
||||||
|
required
|
||||||
|
className="w-full rounded-xl border border-slate-300 px-3 py-2"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn-accent w-full">
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{categoryModalStatus && (
|
||||||
|
<p className="mt-3 text-sm text-emerald-600">{categoryModalStatus}</p>
|
||||||
|
)}
|
||||||
|
{categoryModalError && (
|
||||||
|
<p className="mt-3 text-sm text-red-600">{categoryModalError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<section className="card space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||||
|
iCal
|
||||||
|
</p>
|
||||||
|
<h2 className="text-lg font-semibold">iCal-Import</h2>
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
Lade eine iCal-Datei hoch, um Termine direkt zu importieren.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={importIcal} className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".ics,text/calendar"
|
||||||
|
onChange={(event) =>
|
||||||
|
setImportFile(event.currentTarget.files?.[0] || null)
|
||||||
|
}
|
||||||
|
className="block text-sm text-slate-600"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={importCategoryId}
|
||||||
|
onChange={(event) => setImportCategoryId(event.target.value)}
|
||||||
|
className="rounded-xl border border-slate-300 px-3 py-2 text-sm"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Kategorie wählen
|
||||||
|
</option>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<option key={category.id} value={category.id}>
|
||||||
|
{category.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="submit" className="btn-accent">
|
||||||
|
Import starten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{importStatus && <p className="text-sm text-emerald-600">{importStatus}</p>}
|
||||||
|
{importError && <p className="text-sm text-red-600">{importError}</p>}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section className="card space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||||
|
Termine
|
||||||
|
</p>
|
||||||
|
<h2 className="text-lg font-semibold">Alle Termine verwalten</h2>
|
||||||
|
</div>
|
||||||
|
{isEditOpen && editEvent && (
|
||||||
|
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/40 px-4 py-6">
|
||||||
|
<div className="card w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">Termin bearbeiten</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-sm text-slate-600"
|
||||||
|
onClick={() => {
|
||||||
|
setEditEvent(null);
|
||||||
|
setEditError(null);
|
||||||
|
setEditStatus(null);
|
||||||
|
setIsEditOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={updateEvent} className="mt-4 space-y-3">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<input
|
||||||
|
name="title"
|
||||||
|
required
|
||||||
|
defaultValue={editEvent.title}
|
||||||
|
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||||
|
placeholder="Titel"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="location"
|
||||||
|
defaultValue={editEvent.location || ""}
|
||||||
|
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||||
|
placeholder="Ort"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="locationPlaceId"
|
||||||
|
value={editEvent.locationPlaceId || ""}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="locationLat"
|
||||||
|
value={editEvent.locationLat ?? ""}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="locationLng"
|
||||||
|
value={editEvent.locationLng ?? ""}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="startAt"
|
||||||
|
type="datetime-local"
|
||||||
|
required
|
||||||
|
defaultValue={formatLocalDateTime(editEvent.startAt)}
|
||||||
|
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="endAt"
|
||||||
|
type="datetime-local"
|
||||||
|
defaultValue={formatLocalDateTime(editEvent.endAt)}
|
||||||
|
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
name="categoryId"
|
||||||
|
required
|
||||||
|
defaultValue={editEvent.category?.id || ""}
|
||||||
|
className="rounded-xl border border-slate-300 px-3 py-2 md:col-span-2"
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Kategorie waehlen
|
||||||
|
</option>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<option key={category.id} value={category.id}>
|
||||||
|
{category.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
defaultValue={editEvent.description || ""}
|
||||||
|
placeholder="Beschreibung"
|
||||||
|
className="min-h-[96px] rounded-xl border border-slate-300 px-3 py-2"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn-accent">
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
{editStatus && <p className="text-sm text-emerald-600">{editStatus}</p>}
|
||||||
|
{editError && <p className="text-sm text-red-600">{editError}</p>}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-left text-sm">
|
||||||
|
<thead className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-2">Datum</th>
|
||||||
|
<th className="pb-2">Titel</th>
|
||||||
|
<th className="pb-2">Kategorie</th>
|
||||||
|
<th className="pb-2">Status</th>
|
||||||
|
<th className="pb-2">Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{allEvents.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="py-4 text-slate-600">
|
||||||
|
Keine Termine vorhanden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
allEvents.map((event) => (
|
||||||
|
<tr key={event.id} className="border-t border-slate-200">
|
||||||
|
<td className="py-3 pr-3">
|
||||||
|
{new Date(event.startAt).toLocaleString("de-DE", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short"
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-3 font-medium">{event.title}</td>
|
||||||
|
<td className="py-3 pr-3">
|
||||||
|
{event.category?.name || "Ohne Kategorie"}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusIcon status={event.status} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-700"
|
||||||
|
onClick={() => {
|
||||||
|
setEditEvent(event);
|
||||||
|
setIsEditOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-full border border-red-200 px-3 py-1 text-xs text-red-600"
|
||||||
|
onClick={() => deleteEvent(event.id)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StatusIcon({ status }: { status: string }) {
|
||||||
|
if (status === "APPROVED") {
|
||||||
|
return (
|
||||||
|
<span title="Freigegeben" aria-label="Freigegeben" className="text-emerald-600">
|
||||||
|
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M5 12l4 4L19 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === "REJECTED") {
|
||||||
|
return (
|
||||||
|
<span title="Abgelehnt" aria-label="Abgelehnt" className="text-red-600">
|
||||||
|
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M6 6l12 12M18 6l-12 12" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span title="Offen" aria-label="Offen" className="text-amber-600">
|
||||||
|
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 6v6l4 2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
202
components/AdminSystemSettings.tsx
Normal file
202
components/AdminSystemSettings.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function AdminSystemSettings() {
|
||||||
|
const [apiKey, setApiKey] = useState("");
|
||||||
|
const [provider, setProvider] = useState("osm");
|
||||||
|
const [registrationEnabled, setRegistrationEnabled] = useState(true);
|
||||||
|
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||||
|
const [logoVersion, setLogoVersion] = useState(() => Date.now());
|
||||||
|
const [hasLogo, setHasLogo] = useState<boolean | null>(null);
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/settings/google-places");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Einstellungen konnten nicht geladen werden.");
|
||||||
|
}
|
||||||
|
const payload = await response.json();
|
||||||
|
setApiKey(payload.apiKey || "");
|
||||||
|
setProvider(payload.provider || "osm");
|
||||||
|
setRegistrationEnabled(payload.registrationEnabled !== false);
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadLogoStatus = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/branding/logo", {
|
||||||
|
method: "HEAD",
|
||||||
|
cache: "no-store"
|
||||||
|
});
|
||||||
|
setHasLogo(response.ok);
|
||||||
|
} catch {
|
||||||
|
setHasLogo(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
loadLogoStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setStatus(null);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await fetch("/api/settings/google-places", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ apiKey, provider, registrationEnabled })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setError(data.error || "Speichern fehlgeschlagen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("Gespeichert.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLogoUpload = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setStatus(null);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!logoFile) {
|
||||||
|
setError("Bitte ein Logo auswählen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", logoFile);
|
||||||
|
|
||||||
|
const response = await fetch("/api/settings/logo", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setError(data.error || "Logo-Upload fehlgeschlagen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLogoFile(null);
|
||||||
|
setLogoVersion(Date.now());
|
||||||
|
setHasLogo(true);
|
||||||
|
setStatus("Logo gespeichert.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLogoRemove = async () => {
|
||||||
|
setStatus(null);
|
||||||
|
setError(null);
|
||||||
|
const response = await fetch("/api/settings/logo", { method: "DELETE" });
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setError(data.error || "Logo konnte nicht gelöscht werden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHasLogo(false);
|
||||||
|
setLogoVersion(Date.now());
|
||||||
|
setStatus("Logo entfernt.");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">System</p>
|
||||||
|
<h1 className="text-2xl font-semibold">API Einstellungen</h1>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={onLogoUpload} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700">App-Logo</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
PNG, JPG, WEBP oder SVG. Empfohlen: 240×64 px.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{hasLogo ? (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<img
|
||||||
|
src={`/api/branding/logo?ts=${logoVersion}`}
|
||||||
|
alt="Aktuelles Logo"
|
||||||
|
className="h-10 max-w-[180px] rounded-md border border-slate-200 bg-white object-contain px-2"
|
||||||
|
onError={() => setHasLogo(false)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onLogoRemove}
|
||||||
|
className="btn-ghost"
|
||||||
|
>
|
||||||
|
Logo entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-500">Kein Logo hinterlegt.</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||||
|
onChange={(event) =>
|
||||||
|
setLogoFile(event.currentTarget.files?.[0] || null)
|
||||||
|
}
|
||||||
|
className="block text-sm text-slate-600"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn-accent">
|
||||||
|
Logo hochladen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form onSubmit={onSubmit} className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-700">
|
||||||
|
Ortsanbieter
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={provider}
|
||||||
|
onChange={(event) => setProvider(event.target.value)}
|
||||||
|
className="w-full rounded-xl border border-slate-300 px-3 py-2"
|
||||||
|
>
|
||||||
|
<option value="osm">OpenStreetMap (Nominatim)</option>
|
||||||
|
<option value="google">Google Places</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{provider === "google" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-700">
|
||||||
|
Google Places API Key
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(event) => setApiKey(event.target.value)}
|
||||||
|
className="w-full rounded-xl border border-slate-300 px-3 py-2"
|
||||||
|
placeholder="AIza..."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={registrationEnabled}
|
||||||
|
onChange={(event) => setRegistrationEnabled(event.target.checked)}
|
||||||
|
/>
|
||||||
|
Registrierung erlauben
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="btn-accent">
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{status && <p className="text-sm text-emerald-600">{status}</p>}
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
482
components/AdminUserApprovals.tsx
Normal file
482
components/AdminUserApprovals.tsx
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type UserItem = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name?: string | null;
|
||||||
|
status: string;
|
||||||
|
role: string;
|
||||||
|
emailVerified: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
loginStats?: {
|
||||||
|
attempts: number;
|
||||||
|
lastAttempt: string | null;
|
||||||
|
lockedUntil: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type AdminUserApprovalsProps = {
|
||||||
|
role?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminUserApprovals({ role }: AdminUserApprovalsProps) {
|
||||||
|
const [users, setUsers] = useState<UserItem[]>([]);
|
||||||
|
const [allUsers, setAllUsers] = useState<UserItem[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [modalError, setModalError] = useState<string | null>(null);
|
||||||
|
const [modalStatus, setModalStatus] = useState<string | null>(null);
|
||||||
|
const [editingUser, setEditingUser] = useState<UserItem | null>(null);
|
||||||
|
|
||||||
|
const loadPending = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/users?status=PENDING");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Nutzer konnten nicht geladen werden.");
|
||||||
|
}
|
||||||
|
setUsers(await response.json());
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAll = async () => {
|
||||||
|
if (!role) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/users");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Übersicht konnte nicht geladen werden.");
|
||||||
|
}
|
||||||
|
setAllUsers(await response.json());
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPending();
|
||||||
|
loadAll();
|
||||||
|
}, [role]);
|
||||||
|
|
||||||
|
const isSuperAdmin = role === "SUPERADMIN";
|
||||||
|
const canManageUsers = role === "ADMIN" || role === "SUPERADMIN";
|
||||||
|
|
||||||
|
const approveUser = async (userId: string) => {
|
||||||
|
setError(null);
|
||||||
|
setStatus(null);
|
||||||
|
const response = await fetch("/api/users", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ userId, status: "ACTIVE" })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setError(data.error || "Freischaltung fehlgeschlagen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUsers((prev) => prev.filter((user) => user.id !== userId));
|
||||||
|
setStatus("Benutzer freigeschaltet.");
|
||||||
|
loadAll();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingUser(null);
|
||||||
|
setModalError(null);
|
||||||
|
setModalStatus(null);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (user: UserItem) => {
|
||||||
|
setEditingUser(user);
|
||||||
|
setModalError(null);
|
||||||
|
setModalStatus(null);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditingUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveUser = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setModalError(null);
|
||||||
|
setModalStatus(null);
|
||||||
|
|
||||||
|
const formData = new FormData(event.currentTarget);
|
||||||
|
const payload = {
|
||||||
|
email: formData.get("email"),
|
||||||
|
name: formData.get("name"),
|
||||||
|
role: formData.get("role"),
|
||||||
|
status: formData.get("status"),
|
||||||
|
emailVerified: formData.get("emailVerified") === "on",
|
||||||
|
password: formData.get("password")
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch("/api/users", {
|
||||||
|
method: editingUser ? "PATCH" : "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(
|
||||||
|
editingUser ? { ...payload, userId: editingUser.id } : payload
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setModalError(data.error || "Speichern fehlgeschlagen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setModalStatus("Gespeichert.");
|
||||||
|
loadPending();
|
||||||
|
loadAll();
|
||||||
|
setModalOpen(false);
|
||||||
|
setEditingUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeUser = async (user: UserItem) => {
|
||||||
|
const ok = window.confirm(`Benutzer ${user.email} deaktivieren?`);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
const response = await fetch(`/api/users?id=${user.id}`, {
|
||||||
|
method: "DELETE"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setError(data.error || "Löschen fehlgeschlagen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("Benutzer deaktiviert.");
|
||||||
|
loadPending();
|
||||||
|
loadAll();
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetLoginAttempts = async (user: UserItem) => {
|
||||||
|
setError(null);
|
||||||
|
setStatus(null);
|
||||||
|
const response = await fetch("/api/users", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ userId: user.id, resetLoginAttempts: true })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setError(data.error || "Zurücksetzen fehlgeschlagen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("Loginversuche zurückgesetzt.");
|
||||||
|
loadAll();
|
||||||
|
};
|
||||||
|
const manageableUsers = isSuperAdmin
|
||||||
|
? allUsers
|
||||||
|
: allUsers.filter((user) => user.role === "USER");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="card space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||||
|
Freischaltung
|
||||||
|
</p>
|
||||||
|
<h2 className="text-lg font-semibold">Neue Registrierungen</h2>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
{status && <p className="text-sm text-emerald-600">{status}</p>}
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-600">Keine offenen Registrierungen.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-left text-sm">
|
||||||
|
<thead className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-2">E-Mail</th>
|
||||||
|
<th className="pb-2">Name</th>
|
||||||
|
<th className="pb-2">Rolle</th>
|
||||||
|
<th className="pb-2">Erstellt</th>
|
||||||
|
<th className="pb-2">Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((user) => (
|
||||||
|
<tr key={user.id} className="border-t border-slate-200">
|
||||||
|
<td className="py-3 pr-3">{user.email}</td>
|
||||||
|
<td className="py-3 pr-3">{user.name || "-"}</td>
|
||||||
|
<td className="py-3 pr-3">{user.role}</td>
|
||||||
|
<td className="py-3 pr-3">
|
||||||
|
{new Date(user.createdAt).toLocaleDateString("de-DE")}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-3">
|
||||||
|
<IconButton
|
||||||
|
label="Freigeben"
|
||||||
|
onClick={() => approveUser(user.id)}
|
||||||
|
>
|
||||||
|
<IconCheck />
|
||||||
|
</IconButton>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
{canManageUsers && (
|
||||||
|
<section className="card space-y-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||||
|
Übersicht
|
||||||
|
</p>
|
||||||
|
<h2 className="text-lg font-semibold">Alle Benutzer</h2>
|
||||||
|
</div>
|
||||||
|
<IconButton label="Benutzer anlegen" onClick={openCreate}>
|
||||||
|
<IconPlus />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
{manageableUsers.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-600">Keine Benutzer vorhanden.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-left text-sm">
|
||||||
|
<thead className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="pb-2">E-Mail</th>
|
||||||
|
<th className="pb-2">Name</th>
|
||||||
|
<th className="pb-2">Rolle</th>
|
||||||
|
<th className="pb-2">Status</th>
|
||||||
|
<th className="pb-2">Verifiziert</th>
|
||||||
|
<th className="pb-2">Fehlversuche</th>
|
||||||
|
<th className="pb-2">Letzter Versuch</th>
|
||||||
|
<th className="pb-2">Gesperrt bis</th>
|
||||||
|
<th className="pb-2">Erstellt</th>
|
||||||
|
<th className="pb-2">Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{manageableUsers.map((user) => (
|
||||||
|
<tr key={user.id} className="border-t border-slate-200">
|
||||||
|
<td className="py-3 pr-3">{user.email}</td>
|
||||||
|
<td className="py-3 pr-3">{user.name || "-"}</td>
|
||||||
|
<td className="py-3 pr-3">{user.role}</td>
|
||||||
|
<td className="py-3 pr-3">{user.status}</td>
|
||||||
|
<td className="py-3 pr-3">
|
||||||
|
{user.emailVerified ? "Ja" : "Nein"}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-3">{user.loginStats?.attempts ?? 0}</td>
|
||||||
|
<td className="py-3 pr-3">
|
||||||
|
{user.loginStats?.lastAttempt
|
||||||
|
? new Date(user.loginStats.lastAttempt).toLocaleString("de-DE")
|
||||||
|
: "-"}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-3">
|
||||||
|
{user.loginStats?.lockedUntil
|
||||||
|
? new Date(user.loginStats.lockedUntil).toLocaleString("de-DE")
|
||||||
|
: "-"}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-3">
|
||||||
|
{new Date(user.createdAt).toLocaleDateString("de-DE")}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-3">
|
||||||
|
<div className="flex flex-nowrap gap-2">
|
||||||
|
<IconButton
|
||||||
|
label="Bearbeiten"
|
||||||
|
onClick={() => openEdit(user)}
|
||||||
|
>
|
||||||
|
<IconEdit />
|
||||||
|
</IconButton>
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<IconButton
|
||||||
|
label="Loginversuche zurücksetzen"
|
||||||
|
onClick={() => resetLoginAttempts(user)}
|
||||||
|
>
|
||||||
|
<IconUnlock />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
label="Deaktivieren"
|
||||||
|
onClick={() => removeUser(user)}
|
||||||
|
>
|
||||||
|
<IconTrash />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{modalOpen && (
|
||||||
|
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/40 px-4 py-6">
|
||||||
|
<div className="card w-full max-w-xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
{editingUser ? "Benutzer bearbeiten" : "Benutzer anlegen"}
|
||||||
|
</h3>
|
||||||
|
<IconButton label="Schließen" onClick={closeModal}>
|
||||||
|
<IconClose />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={saveUser} className="mt-4 grid gap-3 md:grid-cols-2">
|
||||||
|
<input
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
defaultValue={editingUser?.email || ""}
|
||||||
|
placeholder="E-Mail"
|
||||||
|
className="rounded-xl border border-slate-300 px-3 py-2 md:col-span-2"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
defaultValue={editingUser?.name || ""}
|
||||||
|
placeholder="Name"
|
||||||
|
className="rounded-xl border border-slate-300 px-3 py-2 md:col-span-2"
|
||||||
|
/>
|
||||||
|
{isSuperAdmin ? (
|
||||||
|
<select
|
||||||
|
name="role"
|
||||||
|
defaultValue={editingUser?.role || "USER"}
|
||||||
|
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||||
|
>
|
||||||
|
<option value="USER">USER</option>
|
||||||
|
<option value="ADMIN">ADMIN</option>
|
||||||
|
<option value="SUPERADMIN">SUPERADMIN</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input type="hidden" name="role" value="USER" />
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={editingUser?.status || "PENDING"}
|
||||||
|
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||||
|
>
|
||||||
|
<option value="ACTIVE">ACTIVE</option>
|
||||||
|
<option value="PENDING">PENDING</option>
|
||||||
|
<option value="DISABLED">DISABLED</option>
|
||||||
|
</select>
|
||||||
|
<label className="flex items-center gap-2 text-sm md:col-span-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="emailVerified"
|
||||||
|
defaultChecked={editingUser?.emailVerified || false}
|
||||||
|
/>
|
||||||
|
E-Mail verifiziert
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
placeholder={
|
||||||
|
editingUser ? "Neues Passwort (optional)" : "Passwort"
|
||||||
|
}
|
||||||
|
required={!editingUser}
|
||||||
|
className="rounded-xl border border-slate-300 px-3 py-2 md:col-span-2"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-accent md:col-span-2 flex items-center justify-center"
|
||||||
|
aria-label="Speichern"
|
||||||
|
title="Speichern"
|
||||||
|
>
|
||||||
|
<IconCheck />
|
||||||
|
<span className="sr-only">Speichern</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{modalStatus && (
|
||||||
|
<p className="mt-3 text-sm text-emerald-600">{modalStatus}</p>
|
||||||
|
)}
|
||||||
|
{modalError && (
|
||||||
|
<p className="mt-3 text-sm text-red-600">{modalError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconButton({
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-slate-200 text-slate-700 hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconCheck() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M5 12l4 4L19 6" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconEdit() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 20h9" strokeLinecap="round" />
|
||||||
|
<path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4L16.5 3.5z" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconTrash() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M3 6h18" strokeLinecap="round" />
|
||||||
|
<path d="M8 6V4h8v2" strokeLinecap="round" />
|
||||||
|
<path d="M7 6l1 14h8l1-14" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconPlus() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconClose() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M6 6l12 12M18 6l-12 12" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconUnlock() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M7 11V7a5 5 0 0110 0" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<rect x="3" y="11" width="18" height="10" rx="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<path d="M12 15v2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,217 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import dynamic from "next/dynamic";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
export default function EventForm() {
|
const MapPicker = dynamic(() => import("./MapPicker"), { ssr: false });
|
||||||
|
|
||||||
|
type Category = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PlaceResult = {
|
||||||
|
description: string;
|
||||||
|
place_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EventFormProps = {
|
||||||
|
variant?: "card" | "inline";
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
prefillStartAt?: string | Date | null;
|
||||||
|
showTrigger?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EventForm({
|
||||||
|
variant = "card",
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
prefillStartAt,
|
||||||
|
showTrigger = true
|
||||||
|
}: EventFormProps) {
|
||||||
|
const { data } = useSession();
|
||||||
const [status, setStatus] = useState<string | null>(null);
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [startAt, setStartAt] = useState("");
|
||||||
|
const [endAt, setEndAt] = useState("");
|
||||||
|
const [placesProvider, setPlacesProvider] = useState<"google" | "osm">("osm");
|
||||||
|
const [placesKey, setPlacesKey] = useState("");
|
||||||
|
const [placeQuery, setPlaceQuery] = useState("");
|
||||||
|
const [placeResults, setPlaceResults] = useState<PlaceResult[]>([]);
|
||||||
|
const [placeId, setPlaceId] = useState<string | null>(null);
|
||||||
|
const [placeLat, setPlaceLat] = useState<number | null>(null);
|
||||||
|
const [placeLng, setPlaceLng] = useState<number | null>(null);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadCategories = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/categories");
|
||||||
|
if (!response.ok) return;
|
||||||
|
setCategories(await response.json());
|
||||||
|
} catch {
|
||||||
|
// Ignore category load errors in UI.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadCategories();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isControlled = open !== undefined;
|
||||||
|
const modalOpen = isControlled ? open : isOpen;
|
||||||
|
const setModalOpen = (value: boolean) => {
|
||||||
|
if (isControlled) {
|
||||||
|
onOpenChange?.(value);
|
||||||
|
} else {
|
||||||
|
setIsOpen(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLocalDateTime = (value: Date) => {
|
||||||
|
const offset = value.getTimezoneOffset() * 60000;
|
||||||
|
return new Date(value.getTime() - offset).toISOString().slice(0, 16);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadKey = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/settings/google-places");
|
||||||
|
if (!response.ok) return;
|
||||||
|
const payload = await response.json();
|
||||||
|
setPlacesKey(payload.apiKey || "");
|
||||||
|
setPlacesProvider(payload.provider === "google" ? "google" : "osm");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (modalOpen) {
|
||||||
|
loadKey();
|
||||||
|
}
|
||||||
|
}, [modalOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPlaces = async () => {
|
||||||
|
if (placeQuery.trim().length < 3) {
|
||||||
|
setPlaceResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (placesProvider === "google" && !placesKey) {
|
||||||
|
setPlaceResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/places/autocomplete?input=${encodeURIComponent(placeQuery)}&countries=de,fr,ch,at`
|
||||||
|
);
|
||||||
|
if (!response.ok) return;
|
||||||
|
const payload = await response.json();
|
||||||
|
setPlaceResults(payload.predictions || []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
fetchPlaces();
|
||||||
|
}, 350);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [placeQuery, placesKey, placesProvider]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!modalOpen) return;
|
||||||
|
if (prefillStartAt) {
|
||||||
|
const startDate =
|
||||||
|
prefillStartAt instanceof Date
|
||||||
|
? prefillStartAt
|
||||||
|
: new Date(prefillStartAt);
|
||||||
|
if (Number.isNaN(startDate.getTime())) return;
|
||||||
|
const startValue = formatLocalDateTime(startDate);
|
||||||
|
setStartAt(startValue);
|
||||||
|
const endDate = new Date(startDate.getTime() + 3 * 60 * 60 * 1000);
|
||||||
|
setEndAt(formatLocalDateTime(endDate));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setHours(12, 0, 0, 0);
|
||||||
|
setStartAt(formatLocalDateTime(startDate));
|
||||||
|
const endDate = new Date(startDate.getTime() + 3 * 60 * 60 * 1000);
|
||||||
|
setEndAt(formatLocalDateTime(endDate));
|
||||||
|
}, [modalOpen, prefillStartAt]);
|
||||||
|
|
||||||
|
const selectPlace = async (place: PlaceResult) => {
|
||||||
|
setPlaceResults([]);
|
||||||
|
setPlaceQuery(place.description);
|
||||||
|
setPlaceId(place.place_id);
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/places/details?placeId=${encodeURIComponent(place.place_id)}`
|
||||||
|
);
|
||||||
|
if (!response.ok) return;
|
||||||
|
const payload = await response.json();
|
||||||
|
setPlaceLat(payload.lat ?? null);
|
||||||
|
setPlaceLng(payload.lng ?? null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reverseLookup = async (lat: number, lng: number) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/places/reverse?lat=${lat}&lng=${lng}`
|
||||||
|
);
|
||||||
|
if (!response.ok) return;
|
||||||
|
const payload = await response.json();
|
||||||
|
if (payload.label) {
|
||||||
|
setPlaceQuery(payload.label);
|
||||||
|
}
|
||||||
|
if (payload.placeId) {
|
||||||
|
setPlaceId(payload.placeId);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickOnMap = (lat: number, lng: number) => {
|
||||||
|
setPlaceLat(lat);
|
||||||
|
setPlaceLng(lng);
|
||||||
|
setPlaceResults([]);
|
||||||
|
reverseLookup(lat, lng);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toIsoString = (value: FormDataEntryValue | null) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const raw = String(value);
|
||||||
|
if (!raw) return null;
|
||||||
|
const date = new Date(raw);
|
||||||
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
|
return date.toISOString();
|
||||||
|
};
|
||||||
|
|
||||||
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const formData = new FormData(event.currentTarget);
|
const formData = new FormData(form);
|
||||||
const payload = {
|
const payload = {
|
||||||
title: formData.get("title"),
|
title: formData.get("title"),
|
||||||
description: formData.get("description"),
|
description: formData.get("description"),
|
||||||
location: formData.get("location"),
|
location: formData.get("location"),
|
||||||
startAt: formData.get("startAt"),
|
locationPlaceId: formData.get("locationPlaceId"),
|
||||||
endAt: formData.get("endAt")
|
locationLat: formData.get("locationLat"),
|
||||||
|
locationLng: formData.get("locationLng"),
|
||||||
|
startAt: toIsoString(formData.get("startAt")),
|
||||||
|
endAt: toIsoString(formData.get("endAt")),
|
||||||
|
categoryId: formData.get("categoryId")
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -32,54 +226,210 @@ export default function EventForm() {
|
|||||||
throw new Error(data.error || "Fehler beim Speichern.");
|
throw new Error(data.error || "Fehler beim Speichern.");
|
||||||
}
|
}
|
||||||
|
|
||||||
event.currentTarget.reset();
|
form.reset();
|
||||||
setStatus("Termin vorgeschlagen. Ein Admin bestaetigt ihn.");
|
setStartAt("");
|
||||||
|
setEndAt("");
|
||||||
|
setStatus("Termin vorgeschlagen. Ein Admin bestätigt ihn.");
|
||||||
|
setModalOpen(false);
|
||||||
|
window.dispatchEvent(new Event("views-updated"));
|
||||||
|
setPlaceQuery("");
|
||||||
|
setPlaceResults([]);
|
||||||
|
setPlaceId(null);
|
||||||
|
setPlaceLat(null);
|
||||||
|
setPlaceLng(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const triggerButton = showTrigger ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-accent"
|
||||||
|
onClick={() => {
|
||||||
|
setModalOpen(true);
|
||||||
|
setPlaceQuery("");
|
||||||
|
setPlaceResults([]);
|
||||||
|
setPlaceId(null);
|
||||||
|
setPlaceLat(null);
|
||||||
|
setPlaceLng(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Neuer Termin
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded bg-white p-4 shadow-sm">
|
<>
|
||||||
<h2 className="text-lg font-semibold">Termin vorschlagen</h2>
|
{variant === "card" ? (
|
||||||
|
<section className="card fade-up-delay">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
{triggerButton}
|
||||||
|
</div>
|
||||||
|
{categories.length === 0 && (
|
||||||
|
<p className="mt-3 text-sm text-amber-600">
|
||||||
|
Noch keine Kategorien vorhanden. Bitte Admin um Anlage.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{status && <p className="mt-3 text-sm text-emerald-600">{status}</p>}
|
||||||
|
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||||
|
</section>
|
||||||
|
) : triggerButton ? (
|
||||||
|
<div className="flex flex-col items-start gap-2">
|
||||||
|
{triggerButton}
|
||||||
|
{status && <p className="text-xs text-emerald-600">{status}</p>}
|
||||||
|
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{modalOpen &&
|
||||||
|
mounted &&
|
||||||
|
createPortal(
|
||||||
|
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/50 px-4 py-6">
|
||||||
|
<div className="card w-full max-w-xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">Neuen Termin anlegen</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-sm text-slate-600"
|
||||||
|
onClick={() => setModalOpen(false)}
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{data?.user?.role === "USER" && (
|
||||||
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
|
Hinweis: Terminvorschläge müssen von einem Admin freigegeben werden.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<form onSubmit={onSubmit} className="mt-4 grid gap-3 md:grid-cols-2">
|
<form onSubmit={onSubmit} className="mt-4 grid gap-3 md:grid-cols-2">
|
||||||
<input
|
<input
|
||||||
name="title"
|
name="title"
|
||||||
placeholder="Titel"
|
placeholder="Titel"
|
||||||
required
|
required
|
||||||
className="rounded border border-slate-300 px-3 py-2"
|
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||||
/>
|
/>
|
||||||
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
name="location"
|
name="location"
|
||||||
placeholder="Ort"
|
placeholder="Ort"
|
||||||
className="rounded border border-slate-300 px-3 py-2"
|
value={placeQuery}
|
||||||
|
onChange={(event) => {
|
||||||
|
setPlaceQuery(event.target.value);
|
||||||
|
setPlaceId(null);
|
||||||
|
setPlaceLat(null);
|
||||||
|
setPlaceLng(null);
|
||||||
|
}}
|
||||||
|
className="w-full rounded-xl border border-slate-300 px-3 py-2"
|
||||||
/>
|
/>
|
||||||
|
{placeResults.length > 0 && (
|
||||||
|
<div className="absolute z-10 mt-2 max-h-56 w-full overflow-y-auto rounded-xl border border-slate-200 bg-white shadow">
|
||||||
|
{placeResults.map((place) => (
|
||||||
|
<button
|
||||||
|
key={place.place_id}
|
||||||
|
type="button"
|
||||||
|
className="block w-full px-3 py-2 text-left text-sm hover:bg-slate-100"
|
||||||
|
onClick={() => selectPlace(place)}
|
||||||
|
>
|
||||||
|
{place.description}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{placesProvider === "google" && !placesKey && placeQuery.length > 0 && (
|
||||||
|
<div className="mt-2 text-xs text-slate-500">
|
||||||
|
Google Places ist nicht konfiguriert.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||||
|
Ort per Karte wählen
|
||||||
|
</label>
|
||||||
|
<div className="mt-2 overflow-hidden rounded-xl border border-slate-200">
|
||||||
|
<MapPicker
|
||||||
|
value={
|
||||||
|
placeLat !== null && placeLng !== null
|
||||||
|
? { lat: placeLat, lng: placeLng }
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onChange={(coords) => pickOnMap(coords.lat, coords.lng)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-slate-500">
|
||||||
|
Klick auf die Karte, um den Ort zu setzen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="locationPlaceId" value={placeId || ""} />
|
||||||
|
<input type="hidden" name="locationLat" value={placeLat ?? ""} />
|
||||||
|
<input type="hidden" name="locationLng" value={placeLng ?? ""} />
|
||||||
<input
|
<input
|
||||||
name="startAt"
|
name="startAt"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
required
|
required
|
||||||
className="rounded border border-slate-300 px-3 py-2"
|
value={startAt}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextStart = event.target.value;
|
||||||
|
setStartAt(nextStart);
|
||||||
|
if (nextStart) {
|
||||||
|
const startDate = new Date(nextStart);
|
||||||
|
const endDate = new Date(startDate.getTime() + 3 * 60 * 60 * 1000);
|
||||||
|
const offset = endDate.getTimezoneOffset() * 60000;
|
||||||
|
const local = new Date(endDate.getTime() - offset)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 16);
|
||||||
|
setEndAt(local);
|
||||||
|
} else {
|
||||||
|
setEndAt("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
name="endAt"
|
name="endAt"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
required
|
value={endAt}
|
||||||
className="rounded border border-slate-300 px-3 py-2"
|
onChange={(event) => setEndAt(event.target.value)}
|
||||||
|
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||||
/>
|
/>
|
||||||
|
<select
|
||||||
|
name="categoryId"
|
||||||
|
required
|
||||||
|
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||||
|
disabled={categories.length === 0}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Kategorie wählen
|
||||||
|
</option>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<option key={category.id} value={category.id}>
|
||||||
|
{category.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
<textarea
|
<textarea
|
||||||
name="description"
|
name="description"
|
||||||
placeholder="Beschreibung"
|
placeholder="Beschreibung"
|
||||||
className="min-h-[96px] rounded border border-slate-300 px-3 py-2 md:col-span-2"
|
className="min-h-[96px] rounded-xl border border-slate-300 px-3 py-2 md:col-span-2"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="rounded bg-brand-500 px-4 py-2 text-white md:col-span-2"
|
className="btn-accent md:col-span-2"
|
||||||
|
disabled={categories.length === 0}
|
||||||
>
|
>
|
||||||
Vorschlag senden
|
Vorschlag senden
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{categories.length === 0 && (
|
||||||
|
<p className="mt-3 text-sm text-amber-600">
|
||||||
|
Noch keine Kategorien vorhanden. Bitte Admin um Anlage.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{status && <p className="mt-3 text-sm text-emerald-600">{status}</p>}
|
{status && <p className="mt-3 text-sm text-emerald-600">{status}</p>}
|
||||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||||
</section>
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
65
components/MapPicker.tsx
Normal file
65
components/MapPicker.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { MapContainer, Marker, TileLayer, useMapEvents } from "react-leaflet";
|
||||||
|
import L from "leaflet";
|
||||||
|
|
||||||
|
type LatLng = { lat: number; lng: number };
|
||||||
|
|
||||||
|
let iconConfigured = false;
|
||||||
|
|
||||||
|
const ensureLeafletIcons = () => {
|
||||||
|
if (iconConfigured) return;
|
||||||
|
delete (L.Icon.Default.prototype as { _getIconUrl?: () => string })._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl:
|
||||||
|
"https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
||||||
|
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
||||||
|
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png"
|
||||||
|
});
|
||||||
|
iconConfigured = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
function MapClickHandler({ onPick }: { onPick: (coords: LatLng) => void }) {
|
||||||
|
useMapEvents({
|
||||||
|
click(event) {
|
||||||
|
onPick({ lat: event.latlng.lat, lng: event.latlng.lng });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MapPicker({
|
||||||
|
value,
|
||||||
|
onChange
|
||||||
|
}: {
|
||||||
|
value: LatLng | null;
|
||||||
|
onChange: (coords: LatLng) => void;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
ensureLeafletIcons();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const center = value || { lat: 52.52, lng: 13.405 };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="h-64 w-full overflow-hidden rounded-xl overscroll-contain"
|
||||||
|
onWheel={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MapContainer
|
||||||
|
center={center}
|
||||||
|
zoom={value ? 14 : 5}
|
||||||
|
scrollWheelZoom
|
||||||
|
className="h-full w-full"
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
<MapClickHandler onPick={onChange} />
|
||||||
|
{value && <Marker position={value} />}
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,36 +1,77 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { signIn, signOut, useSession } from "next-auth/react";
|
import { signIn, signOut, useSession } from "next-auth/react";
|
||||||
|
|
||||||
export default function NavBar() {
|
export default function NavBar() {
|
||||||
const { data } = useSession();
|
const { data } = useSession();
|
||||||
const isAdmin = data?.user?.role === "ADMIN";
|
const pathname = usePathname();
|
||||||
|
const isAdmin = data?.user?.role === "ADMIN" || data?.user?.role === "SUPERADMIN";
|
||||||
|
const isSuperAdmin = data?.user?.role === "SUPERADMIN";
|
||||||
|
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||||
|
const linkClass = (href: string) =>
|
||||||
|
pathname === href
|
||||||
|
? "rounded-full bg-slate-900 px-3 py-1 text-white"
|
||||||
|
: "rounded-full px-3 py-1 text-slate-700 hover:bg-slate-100";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadLogo = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/branding/logo", {
|
||||||
|
method: "HEAD",
|
||||||
|
cache: "no-store"
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
setLogoUrl(`/api/branding/logo?ts=${Date.now()}`);
|
||||||
|
} else {
|
||||||
|
setLogoUrl(null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setLogoUrl(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadLogo();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="border-b border-slate-200/70 bg-white/80 backdrop-blur">
|
<header className="sticky top-0 z-20 border-b border-slate-200/70 bg-white/70 backdrop-blur">
|
||||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
|
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
|
||||||
<Link href="/" className="text-lg font-semibold text-slate-900">
|
<Link href="/" className="flex items-center gap-3 text-lg font-semibold tracking-tight text-slate-900">
|
||||||
Vereinskalender
|
{logoUrl && (
|
||||||
|
<img
|
||||||
|
src={logoUrl}
|
||||||
|
alt="Vereinskalender Logo"
|
||||||
|
className="h-8 w-auto max-w-[140px] object-contain"
|
||||||
|
onError={() => setLogoUrl(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>Vereinskalender</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex items-center gap-4 text-sm">
|
<nav className="flex items-center gap-3 text-sm">
|
||||||
{data?.user && (
|
{data?.user && (
|
||||||
<>
|
<>
|
||||||
<Link href="/views" className="text-slate-700 hover:text-slate-900">
|
|
||||||
Meine Ansichten
|
|
||||||
</Link>
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Link href="/admin" className="text-slate-700 hover:text-slate-900">
|
<>
|
||||||
|
<Link href="/admin" className={linkClass("/admin")}>
|
||||||
Admin
|
Admin
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/admin/users" className={linkClass("/admin/users")}>
|
||||||
|
Registrierungen
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
<Link href="/settings" className={linkClass("/settings")}>
|
||||||
|
Einstellungen
|
||||||
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{data?.user ? (
|
{data?.user ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => signOut()}
|
onClick={() => signOut()}
|
||||||
className="rounded bg-slate-900 px-3 py-1.5 text-white"
|
className="btn-primary"
|
||||||
>
|
>
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
@@ -38,7 +79,7 @@ export default function NavBar() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => signIn()}
|
onClick={() => signIn()}
|
||||||
className="rounded bg-brand-500 px-3 py-1.5 text-white"
|
className="btn-accent"
|
||||||
>
|
>
|
||||||
Login
|
Login
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,46 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
type EventItem = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
startAt: string;
|
|
||||||
endAt: string;
|
|
||||||
status: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ViewItem = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
token: string;
|
|
||||||
items: { event: EventItem }[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ViewManager() {
|
export default function ViewManager() {
|
||||||
const [views, setViews] = useState<ViewItem[]>([]);
|
const [view, setView] = useState<{ id: string; name: string; token: string } | null>(
|
||||||
const [events, setEvents] = useState<EventItem[]>([]);
|
null
|
||||||
const [selectedView, setSelectedView] = useState<string | null>(null);
|
);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const [viewsRes, eventsRes] = await Promise.all([
|
const viewRes = await fetch("/api/views/default");
|
||||||
fetch("/api/views"),
|
if (!viewRes.ok) {
|
||||||
fetch("/api/events?status=APPROVED")
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!viewsRes.ok || !eventsRes.ok) {
|
|
||||||
throw new Error("Daten konnten nicht geladen werden.");
|
throw new Error("Daten konnten nicht geladen werden.");
|
||||||
}
|
}
|
||||||
|
setView(await viewRes.json());
|
||||||
const viewsData = await viewsRes.json();
|
|
||||||
const eventsData = await eventsRes.json();
|
|
||||||
setViews(viewsData);
|
|
||||||
setEvents(eventsData);
|
|
||||||
if (viewsData.length > 0 && !selectedView) {
|
|
||||||
setSelectedView(viewsData[0].id);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
}
|
}
|
||||||
@@ -50,150 +24,40 @@ export default function ViewManager() {
|
|||||||
load();
|
load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => load();
|
||||||
|
window.addEventListener("views-updated", handler);
|
||||||
|
return () => window.removeEventListener("views-updated", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const icalBase = typeof window === "undefined" ? "" : window.location.origin;
|
const icalBase = typeof window === "undefined" ? "" : window.location.origin;
|
||||||
const activeView = useMemo(
|
|
||||||
() => views.find((view) => view.id === selectedView) || null,
|
|
||||||
[views, selectedView]
|
|
||||||
);
|
|
||||||
|
|
||||||
const createView = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const formData = new FormData(event.currentTarget);
|
|
||||||
const name = formData.get("name");
|
|
||||||
const response = await fetch("/api/views", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ name })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
event.currentTarget.reset();
|
|
||||||
load();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addItem = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!selectedView) return;
|
|
||||||
const formData = new FormData(event.currentTarget);
|
|
||||||
const eventId = formData.get("eventId");
|
|
||||||
|
|
||||||
await fetch(`/api/views/${selectedView}/items`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ eventId })
|
|
||||||
});
|
|
||||||
|
|
||||||
load();
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeItem = async (eventId: string) => {
|
|
||||||
if (!selectedView) return;
|
|
||||||
await fetch(`/api/views/${selectedView}/items`, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ eventId })
|
|
||||||
});
|
|
||||||
load();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6">
|
<section className="space-y-6 fade-up">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">Meine Kalenderansichten</h1>
|
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">Persönlich</p>
|
||||||
|
<h1 className="text-2xl font-semibold">Meine Terminansicht</h1>
|
||||||
<p className="text-slate-600">
|
<p className="text-slate-600">
|
||||||
Stelle dir eine persoenliche Terminansicht zusammen und abonnieren sie als iCal.
|
Stelle dir eine persönliche Terminansicht zusammen und abonniere sie als iCal.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="card space-y-4">
|
||||||
<div className="space-y-4 rounded bg-white p-4 shadow-sm">
|
<h2 className="text-lg font-semibold">Deine Standardansicht</h2>
|
||||||
<h2 className="text-lg font-semibold">Neue Ansicht</h2>
|
<p className="text-sm text-slate-600">
|
||||||
<form onSubmit={createView} className="flex gap-2">
|
Diese Ansicht kannst du im Kalender oder in der Listenansicht direkt befüllen.
|
||||||
<input
|
</p>
|
||||||
name="name"
|
{view && (
|
||||||
required
|
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 text-sm">
|
||||||
placeholder="z.B. Jugendtermine"
|
|
||||||
className="flex-1 rounded border border-slate-300 px-3 py-2"
|
|
||||||
/>
|
|
||||||
<button className="rounded bg-brand-500 px-3 py-2 text-white" type="submit">
|
|
||||||
Anlegen
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-700">Ansicht waehlen</label>
|
|
||||||
<select
|
|
||||||
className="w-full rounded border border-slate-300 px-3 py-2"
|
|
||||||
value={selectedView || ""}
|
|
||||||
onChange={(event) => setSelectedView(event.target.value)}
|
|
||||||
>
|
|
||||||
{views.map((view) => (
|
|
||||||
<option key={view.id} value={view.id}>
|
|
||||||
{view.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeView && (
|
|
||||||
<div className="rounded border border-slate-200 bg-slate-50 p-3 text-sm">
|
|
||||||
<p className="font-medium">iCal URL</p>
|
<p className="font-medium">iCal URL</p>
|
||||||
<p className="break-all text-slate-700">
|
<p className="break-all text-slate-700">
|
||||||
{icalBase}/api/ical/{activeView.token}
|
{icalBase}/api/ical/{view.token}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 rounded bg-white p-4 shadow-sm">
|
|
||||||
<h2 className="text-lg font-semibold">Termine hinzufuegen</h2>
|
|
||||||
<form onSubmit={addItem} className="space-y-3">
|
|
||||||
<select
|
|
||||||
name="eventId"
|
|
||||||
className="w-full rounded border border-slate-300 px-3 py-2"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="" disabled>
|
|
||||||
Termin waehlen
|
|
||||||
</option>
|
|
||||||
{events.map((event) => (
|
|
||||||
<option key={event.id} value={event.id}>
|
|
||||||
{event.title} ({new Date(event.startAt).toLocaleDateString()})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button className="w-full rounded bg-slate-900 px-3 py-2 text-white" type="submit">
|
|
||||||
Zur Ansicht hinzufuegen
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h3 className="text-sm font-semibold text-slate-700">Inhalte der Ansicht</h3>
|
|
||||||
{activeView?.items.length ? (
|
|
||||||
activeView.items.map((item) => (
|
|
||||||
<div
|
|
||||||
key={item.event.id}
|
|
||||||
className="flex items-center justify-between rounded border border-slate-200 px-3 py-2"
|
|
||||||
>
|
|
||||||
<span className="text-sm">{item.event.title}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-xs text-red-600"
|
|
||||||
onClick={() => removeItem(item.event.id)}
|
|
||||||
>
|
|
||||||
Entfernen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-slate-500">Noch keine Termine.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./:/app
|
- ./:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
- app-data:/app/data
|
- app-data:/app/prisma/data
|
||||||
command: npm run dev
|
command: npm run dev
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ services:
|
|||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3101:3000"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: "file:./data/dev.db"
|
DATABASE_URL: "file:./data/dev.db"
|
||||||
volumes:
|
volumes:
|
||||||
- app-data:/app/data
|
- /opt/docker/vereinskalender/app-data:/app/prisma/data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
app-data:
|
app-data:
|
||||||
|
|||||||
BIN
fullcalendar-6.1.20.tgz
Normal file
BIN
fullcalendar-6.1.20.tgz
Normal file
Binary file not shown.
BIN
fullcalendar-core-5.11.5.tgz
Normal file
BIN
fullcalendar-core-5.11.5.tgz
Normal file
Binary file not shown.
BIN
fullcalendar-core-6.1.11.tgz
Normal file
BIN
fullcalendar-core-6.1.11.tgz
Normal file
Binary file not shown.
BIN
fullcalendar-core-6.1.20.tgz
Normal file
BIN
fullcalendar-core-6.1.20.tgz
Normal file
Binary file not shown.
BIN
fullcalendar-daygrid-6.1.11.tgz
Normal file
BIN
fullcalendar-daygrid-6.1.11.tgz
Normal file
Binary file not shown.
@@ -7,9 +7,25 @@ export async function requireSession() {
|
|||||||
if (!session?.user?.email) {
|
if (!session?.user?.email) {
|
||||||
return { session: null, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
|
return { session: null, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
|
||||||
}
|
}
|
||||||
|
if (session.user.status && session.user.status !== "ACTIVE") {
|
||||||
|
return {
|
||||||
|
session: null,
|
||||||
|
response: NextResponse.json({ error: "Account nicht freigeschaltet." }, { status: 403 })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (session.user.emailVerified === false) {
|
||||||
|
return {
|
||||||
|
session: null,
|
||||||
|
response: NextResponse.json({ error: "E-Mail nicht verifiziert." }, { status: 403 })
|
||||||
|
};
|
||||||
|
}
|
||||||
return { session, response: null };
|
return { session, response: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAdminSession(session: { user?: { role?: string } } | null) {
|
export function isAdminSession(session: { user?: { role?: string } } | null) {
|
||||||
return session?.user?.role === "ADMIN";
|
return session?.user?.role === "ADMIN" || session?.user?.role === "SUPERADMIN";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSuperAdminSession(session: { user?: { role?: string } } | null) {
|
||||||
|
return session?.user?.role === "SUPERADMIN";
|
||||||
}
|
}
|
||||||
|
|||||||
132
lib/auth.ts
132
lib/auth.ts
@@ -4,6 +4,61 @@ import type { NextAuthOptions } from "next-auth";
|
|||||||
import CredentialsProvider from "next-auth/providers/credentials";
|
import CredentialsProvider from "next-auth/providers/credentials";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
|
const MAX_LOGIN_ATTEMPTS = 5;
|
||||||
|
const LOGIN_WINDOW_MINUTES = 15;
|
||||||
|
const LOGIN_LOCK_MINUTES = 15;
|
||||||
|
|
||||||
|
const getClientIp = (req: unknown) => {
|
||||||
|
const headers =
|
||||||
|
req && typeof req === "object" && "headers" in req ? (req as any).headers : null;
|
||||||
|
if (!headers) return "unknown";
|
||||||
|
const forwarded =
|
||||||
|
typeof headers.get === "function"
|
||||||
|
? headers.get("x-forwarded-for") || headers.get("x-real-ip")
|
||||||
|
: headers["x-forwarded-for"] || headers["x-real-ip"];
|
||||||
|
if (!forwarded) return "unknown";
|
||||||
|
return String(forwarded).split(",")[0].trim() || "unknown";
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeEmail = (value?: string | null) =>
|
||||||
|
(value || "").trim().toLowerCase();
|
||||||
|
|
||||||
|
const resetIfExpired = async (record: {
|
||||||
|
id: string;
|
||||||
|
attempts: number;
|
||||||
|
lastAttempt: Date;
|
||||||
|
}) => {
|
||||||
|
const cutoff = Date.now() - LOGIN_WINDOW_MINUTES * 60 * 1000;
|
||||||
|
if (record.lastAttempt.getTime() < cutoff) {
|
||||||
|
await prisma.loginAttempt.update({
|
||||||
|
where: { id: record.id },
|
||||||
|
data: { attempts: 0, lockedUntil: null, lastAttempt: new Date() }
|
||||||
|
});
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return record.attempts;
|
||||||
|
};
|
||||||
|
|
||||||
|
const recordFailure = async (email: string, ip: string, attempts: number) => {
|
||||||
|
const nextAttempts = attempts + 1;
|
||||||
|
const lockedUntil =
|
||||||
|
nextAttempts >= MAX_LOGIN_ATTEMPTS
|
||||||
|
? new Date(Date.now() + LOGIN_LOCK_MINUTES * 60 * 1000)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
await prisma.loginAttempt.upsert({
|
||||||
|
where: { email_ip: { email, ip } },
|
||||||
|
update: { attempts: nextAttempts, lastAttempt: new Date(), lockedUntil },
|
||||||
|
create: {
|
||||||
|
email,
|
||||||
|
ip,
|
||||||
|
attempts: nextAttempts,
|
||||||
|
lastAttempt: new Date(),
|
||||||
|
lockedUntil
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const authOptions: NextAuthOptions = {
|
export const authOptions: NextAuthOptions = {
|
||||||
adapter: PrismaAdapter(prisma),
|
adapter: PrismaAdapter(prisma),
|
||||||
session: { strategy: "jwt" },
|
session: { strategy: "jwt" },
|
||||||
@@ -14,17 +69,46 @@ export const authOptions: NextAuthOptions = {
|
|||||||
email: { label: "Email", type: "email" },
|
email: { label: "Email", type: "email" },
|
||||||
password: { label: "Password", type: "password" }
|
password: { label: "Password", type: "password" }
|
||||||
},
|
},
|
||||||
async authorize(credentials) {
|
async authorize(credentials, req) {
|
||||||
if (!credentials?.email || !credentials.password) {
|
if (!credentials?.email || !credentials.password) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const email = normalizeEmail(credentials.email);
|
||||||
|
const ip = getClientIp(req);
|
||||||
|
let attempt: { id: string; attempts: number; lastAttempt: Date; lockedUntil: Date | null } | null = null;
|
||||||
|
try {
|
||||||
|
attempt = await prisma.loginAttempt.findUnique({
|
||||||
|
where: { email_ip: { email, ip } }
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
attempt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt?.lockedUntil && attempt.lockedUntil > new Date()) {
|
||||||
|
throw new Error("LOCKED");
|
||||||
|
}
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
if (attempt) {
|
||||||
|
try {
|
||||||
|
attempts = await resetIfExpired(attempt);
|
||||||
|
} catch {
|
||||||
|
attempts = attempt.attempts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { email: credentials.email }
|
where: { email }
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return null;
|
try {
|
||||||
|
await recordFailure(email, ip, attempts);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
throw new Error("INVALID");
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = await bcrypt.compare(
|
const valid = await bcrypt.compare(
|
||||||
@@ -33,14 +117,37 @@ export const authOptions: NextAuthOptions = {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
return null;
|
try {
|
||||||
|
await recordFailure(email, ip, attempts);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
throw new Error("INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.status !== "ACTIVE") {
|
||||||
|
throw new Error("PENDING");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.emailVerified) {
|
||||||
|
throw new Error("EMAIL_NOT_VERIFIED");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt) {
|
||||||
|
try {
|
||||||
|
await prisma.loginAttempt.delete({ where: { id: attempt.id } });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
role: user.role
|
role: user.role,
|
||||||
|
status: user.status,
|
||||||
|
emailVerified: user.emailVerified
|
||||||
} as any;
|
} as any;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -48,13 +155,19 @@ export const authOptions: NextAuthOptions = {
|
|||||||
callbacks: {
|
callbacks: {
|
||||||
async jwt({ token, user }) {
|
async jwt({ token, user }) {
|
||||||
if (user) {
|
if (user) {
|
||||||
|
token.id = (user as any).id;
|
||||||
token.role = (user as any).role;
|
token.role = (user as any).role;
|
||||||
|
token.status = (user as any).status;
|
||||||
|
token.emailVerified = (user as any).emailVerified;
|
||||||
}
|
}
|
||||||
return token;
|
return token;
|
||||||
},
|
},
|
||||||
async session({ session, token }) {
|
async session({ session, token }) {
|
||||||
if (session.user) {
|
if (session.user) {
|
||||||
|
(session.user as any).id = token.id;
|
||||||
(session.user as any).role = token.role;
|
(session.user as any).role = token.role;
|
||||||
|
(session.user as any).status = token.status;
|
||||||
|
(session.user as any).emailVerified = token.emailVerified;
|
||||||
}
|
}
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
@@ -72,3 +185,12 @@ export const isAdminEmail = (email?: string | null) => {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
return list.includes(email.toLowerCase());
|
return list.includes(email.toLowerCase());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isSuperAdminEmail = (email?: string | null) => {
|
||||||
|
if (!email) return false;
|
||||||
|
const list = (process.env.SUPERADMIN_EMAILS || "")
|
||||||
|
.split(",")
|
||||||
|
.map((entry) => entry.trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
return list.includes(email.toLowerCase());
|
||||||
|
};
|
||||||
|
|||||||
40
lib/mailer.ts
Normal file
40
lib/mailer.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
|
type MailPayload = {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasSmtpConfig = () =>
|
||||||
|
Boolean(
|
||||||
|
process.env.SMTP_HOST &&
|
||||||
|
process.env.SMTP_PORT &&
|
||||||
|
process.env.SMTP_USER &&
|
||||||
|
process.env.SMTP_PASS
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function sendMail(payload: MailPayload) {
|
||||||
|
if (!hasSmtpConfig()) {
|
||||||
|
// Fallback for dev: log instead of sending.
|
||||||
|
console.log("[mail]", payload.subject, payload.text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST,
|
||||||
|
port: Number(process.env.SMTP_PORT),
|
||||||
|
secure: process.env.SMTP_SECURE === "true",
|
||||||
|
auth: {
|
||||||
|
user: process.env.SMTP_USER,
|
||||||
|
pass: process.env.SMTP_PASS
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: process.env.SMTP_FROM || process.env.SMTP_USER,
|
||||||
|
to: payload.to,
|
||||||
|
subject: payload.subject,
|
||||||
|
text: payload.text
|
||||||
|
});
|
||||||
|
}
|
||||||
5
next-auth.d.ts
vendored
5
next-auth.d.ts
vendored
@@ -3,9 +3,12 @@ import NextAuth from "next-auth";
|
|||||||
declare module "next-auth" {
|
declare module "next-auth" {
|
||||||
interface Session {
|
interface Session {
|
||||||
user?: {
|
user?: {
|
||||||
|
id?: string;
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
role?: "ADMIN" | "USER";
|
role?: "SUPERADMIN" | "ADMIN" | "USER";
|
||||||
|
status?: "PENDING" | "ACTIVE";
|
||||||
|
emailVerified?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
30
package.json
30
package.json
@@ -6,9 +6,8 @@
|
|||||||
"node": "24.13.0"
|
"node": "24.13.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"copy:fullcalendar-css": "node scripts/copy-fullcalendar-css.js",
|
"dev": "next dev",
|
||||||
"dev": "npm run copy:fullcalendar-css && next dev",
|
"build": "prisma generate && next build",
|
||||||
"build": "npm run copy:fullcalendar-css && prisma generate && next build",
|
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"prisma:migrate": "prisma migrate dev",
|
"prisma:migrate": "prisma migrate dev",
|
||||||
@@ -16,30 +15,35 @@
|
|||||||
"prisma:studio": "prisma studio"
|
"prisma:studio": "prisma studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fullcalendar/core": "^6.1.11",
|
"@fullcalendar/core": "6.1.20",
|
||||||
"@fullcalendar/daygrid": "^6.1.11",
|
"@fullcalendar/daygrid": "6.1.20",
|
||||||
"@fullcalendar/interaction": "^6.1.11",
|
"@fullcalendar/interaction": "6.1.20",
|
||||||
"@fullcalendar/list": "^6.1.11",
|
"@fullcalendar/list": "6.1.20",
|
||||||
"@fullcalendar/react": "^6.1.11",
|
"@fullcalendar/react": "6.1.20",
|
||||||
"@fullcalendar/timegrid": "^6.1.11",
|
"@fullcalendar/timegrid": "6.1.20",
|
||||||
"@fontsource/space-grotesk": "^5.1.0",
|
|
||||||
"@next-auth/prisma-adapter": "^1.0.7",
|
"@next-auth/prisma-adapter": "^1.0.7",
|
||||||
"@prisma/client": "^5.19.1",
|
"@prisma/client": "^5.19.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"ical-generator": "^7.2.0",
|
"ical-generator": "^7.2.0",
|
||||||
"next": "14.2.5",
|
"leaflet": "^1.9.4",
|
||||||
|
"next": "14.2.25",
|
||||||
"next-auth": "^4.24.7",
|
"next-auth": "^4.24.7",
|
||||||
|
"node-ical": "^0.20.1",
|
||||||
|
"nodemailer": "^7.0.7",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"react-leaflet": "^4.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/leaflet": "^1.9.12",
|
||||||
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/node": "^20.14.10",
|
"@types/node": "^20.14.10",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-next": "14.2.5",
|
"eslint-config-next": "14.2.25",
|
||||||
"postcss": "^8.4.39",
|
"postcss": "^8.4.39",
|
||||||
"prisma": "^5.19.1",
|
"prisma": "^5.19.1",
|
||||||
"tailwindcss": "^3.4.7",
|
"tailwindcss": "^3.4.7",
|
||||||
|
|||||||
@@ -13,11 +13,14 @@ model User {
|
|||||||
name String?
|
name String?
|
||||||
passwordHash String
|
passwordHash String
|
||||||
role String @default("USER")
|
role String @default("USER")
|
||||||
|
status String @default("PENDING")
|
||||||
|
emailVerified Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
events Event[] @relation("EventCreator")
|
events Event[] @relation("EventCreator")
|
||||||
views UserView[]
|
views UserView[]
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
|
resetTokens PasswordResetToken[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Event {
|
model Event {
|
||||||
@@ -25,13 +28,27 @@ model Event {
|
|||||||
title String
|
title String
|
||||||
description String?
|
description String?
|
||||||
location String?
|
location String?
|
||||||
|
locationPlaceId String?
|
||||||
|
locationLat Float?
|
||||||
|
locationLng Float?
|
||||||
startAt DateTime
|
startAt DateTime
|
||||||
endAt DateTime
|
endAt DateTime?
|
||||||
status String @default("PENDING")
|
status String @default("PENDING")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
createdById String
|
createdById String
|
||||||
createdBy User @relation("EventCreator", fields: [createdById], references: [id])
|
createdBy User @relation("EventCreator", fields: [createdById], references: [id])
|
||||||
viewItems UserViewItem[]
|
viewItems UserViewItem[]
|
||||||
|
categoryId String?
|
||||||
|
category Category? @relation(fields: [categoryId], references: [id])
|
||||||
|
viewExclusions UserViewExclusion[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Category {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String @unique
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
events Event[]
|
||||||
|
viewSubscriptions UserViewCategory[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model UserView {
|
model UserView {
|
||||||
@@ -41,6 +58,8 @@ model UserView {
|
|||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User @relation(fields: [userId], references: [id])
|
||||||
items UserViewItem[]
|
items UserViewItem[]
|
||||||
|
categories UserViewCategory[]
|
||||||
|
exclusions UserViewExclusion[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +73,28 @@ model UserViewItem {
|
|||||||
@@unique([viewId, eventId])
|
@@unique([viewId, eventId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model UserViewCategory {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
viewId String
|
||||||
|
categoryId String
|
||||||
|
view UserView @relation(fields: [viewId], references: [id])
|
||||||
|
category Category @relation(fields: [categoryId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([viewId, categoryId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model UserViewExclusion {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
viewId String
|
||||||
|
eventId String
|
||||||
|
view UserView @relation(fields: [viewId], references: [id])
|
||||||
|
event Event @relation(fields: [eventId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([viewId, eventId])
|
||||||
|
}
|
||||||
|
|
||||||
model Account {
|
model Account {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
@@ -89,3 +130,34 @@ model VerificationToken {
|
|||||||
|
|
||||||
@@unique([identifier, token])
|
@@unique([identifier, token])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model PasswordResetToken {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
token String @unique
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model Setting {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
key String @unique
|
||||||
|
value String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model LoginAttempt {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String
|
||||||
|
ip String
|
||||||
|
attempts Int @default(0)
|
||||||
|
lastAttempt DateTime @default(now())
|
||||||
|
lockedUntil DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([email, ip])
|
||||||
|
}
|
||||||
|
|||||||
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><linearGradient id="g" x1="0" x2="1" y1="0" y2="1"><stop offset="0" stop-color="#0f0f10"/><stop offset="1" stop-color="#4e5837"/></linearGradient></defs><rect width="64" height="64" rx="14" fill="url(#g)"/><rect x="12" y="16" width="40" height="36" rx="8" fill="#ffffff"/><rect x="12" y="16" width="40" height="10" rx="8" fill="#e8e8e2"/><rect x="18" y="12" width="6" height="12" rx="3" fill="#4e5837"/><rect x="40" y="12" width="6" height="12" rx="3" fill="#4e5837"/><circle cx="24" cy="34" r="4" fill="#0f0f10"/><circle cx="40" cy="34" r="4" fill="#0f0f10"/><circle cx="32" cy="44" r="4" fill="#0f0f10"/></svg>
|
||||||
|
After Width: | Height: | Size: 678 B |
1878
public/vendor/fullcalendar/fullcalendar.css
vendored
Normal file
1878
public/vendor/fullcalendar/fullcalendar.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
55
scripts/build-fullcalendar-css.sh
Executable file
55
scripts/build-fullcalendar-css.sh
Executable file
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="6.1.20"
|
||||||
|
OUT_DIR="/root/vereinskalender/public/vendor/fullcalendar"
|
||||||
|
OUT_FILE="${OUT_DIR}/fullcalendar.css"
|
||||||
|
|
||||||
|
mkdir -p "${OUT_DIR}"
|
||||||
|
|
||||||
|
urls=(
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/core/src/styles/vars.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/core/src/styles/mixins.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/core/src/styles/page-root.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/core/src/styles/calendar-root.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/core/src/styles/button.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/core/src/styles/button-group.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/core/src/styles/toolbar.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/core/src/styles/view-harness.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/core/src/styles/scrollgrid.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/core/src/styles/scroller.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/core/src/styles/scroller-harness.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/core/src/styles/col-header.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/core/src/styles/bg.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/core/src/styles/event.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/core/src/styles/h-event.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/core/src/styles/icons.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/core/src/styles/sticky.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/core/src/styles/popover.css"
|
||||||
|
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/daygrid/src/styles/vars.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/daygrid/src/styles/daygrid.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/daygrid/src/styles/daygrid-event.css"
|
||||||
|
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/timegrid/src/styles/constants.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/timegrid/src/styles/timegrid.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/timegrid/src/styles/timegrid-slots.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/timegrid/src/styles/timegrid-cols.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/timegrid/src/styles/timegrid-event.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/timegrid/src/styles/timegrid-now-indicator.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/timegrid/src/styles/v-event.css"
|
||||||
|
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/list/src/styles/vars.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/list/src/styles/list.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/list/src/styles/list-table.css"
|
||||||
|
"https://raw.githubusercontent.com/fullcalendar/fullcalendar/v${VERSION}/packages/list/src/styles/list-event.css"
|
||||||
|
)
|
||||||
|
|
||||||
|
: > "${OUT_FILE}"
|
||||||
|
|
||||||
|
for url in "${urls[@]}"; do
|
||||||
|
echo "/* ${url} */" >> "${OUT_FILE}"
|
||||||
|
curl -fsSL "${url}" >> "${OUT_FILE}"
|
||||||
|
echo "" >> "${OUT_FILE}"
|
||||||
|
echo "" >> "${OUT_FILE}"
|
||||||
|
done
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
const fs = require("fs");
|
|
||||||
const path = require("path");
|
|
||||||
|
|
||||||
const mappings = [
|
|
||||||
{ pkg: "@fullcalendar/core", target: "fullcalendar-core.css" },
|
|
||||||
{ pkg: "@fullcalendar/daygrid", target: "fullcalendar-daygrid.css" },
|
|
||||||
{ pkg: "@fullcalendar/timegrid", target: "fullcalendar-timegrid.css" },
|
|
||||||
{ pkg: "@fullcalendar/list", target: "fullcalendar-list.css" }
|
|
||||||
];
|
|
||||||
|
|
||||||
const candidates = [
|
|
||||||
"main.css",
|
|
||||||
"index.css",
|
|
||||||
"style.css",
|
|
||||||
"styles.css",
|
|
||||||
"main.min.css",
|
|
||||||
"index.min.css",
|
|
||||||
"dist/main.css",
|
|
||||||
"dist/index.css"
|
|
||||||
];
|
|
||||||
|
|
||||||
const root = path.resolve(__dirname, "..");
|
|
||||||
const targetDir = path.join(root, "public", "vendor", "fullcalendar");
|
|
||||||
|
|
||||||
if (!fs.existsSync(targetDir)) {
|
|
||||||
fs.mkdirSync(targetDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
let missing = 0;
|
|
||||||
|
|
||||||
mappings.forEach(({ pkg, target }) => {
|
|
||||||
const basePath = path.join(root, "node_modules", pkg);
|
|
||||||
const sourcePath =
|
|
||||||
candidates
|
|
||||||
.map((candidate) => path.join(basePath, candidate))
|
|
||||||
.find((candidatePath) => fs.existsSync(candidatePath)) || null;
|
|
||||||
const targetPath = path.join(targetDir, target);
|
|
||||||
|
|
||||||
if (!sourcePath) {
|
|
||||||
console.error(`Missing FullCalendar CSS for ${pkg}`);
|
|
||||||
missing += 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.copyFileSync(sourcePath, targetPath);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (missing > 0) {
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
@@ -6,10 +6,10 @@ const config: Config = {
|
|||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
brand: {
|
brand: {
|
||||||
50: "#fff1ea",
|
50: "#f5f6ef",
|
||||||
100: "#ffd8cb",
|
100: "#e3e6d2",
|
||||||
500: "#ff6b4a",
|
500: "#6f7a4f",
|
||||||
700: "#e24a2b"
|
700: "#4e5837"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user