first commit
This commit is contained in:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
*.log
|
||||
.env
|
||||
4
.env
Normal file
4
.env
Normal file
@@ -0,0 +1,4 @@
|
||||
DATABASE_URL="file:./dev.db"
|
||||
NEXTAUTH_SECRET="change-me-in-prod"
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
ADMIN_EMAILS="admin@example.com"
|
||||
4
.env.example
Normal file
4
.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
DATABASE_URL="file:./dev.db"
|
||||
NEXTAUTH_SECRET="replace-with-strong-secret"
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
ADMIN_EMAILS="admin@example.com"
|
||||
40
AGENTS.md
Normal file
40
AGENTS.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
This repository is currently empty (no source, tests, or assets are present). When adding code, keep a simple, discoverable layout such as:
|
||||
- `src/` for application/library code
|
||||
- `tests/` or `__tests__/` for automated tests
|
||||
- `assets/` for static files (images, fixtures)
|
||||
Keep top-level docs like `README.md` and `AGENTS.md` in the root.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
No build or test scripts are defined yet. When you add tooling, document the exact commands in `README.md` and keep them consistent. Examples you may add later:
|
||||
- `npm run dev` for local development
|
||||
- `npm test` or `pytest` for running tests
|
||||
- `make build` for production builds
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
No style configuration is present. When initializing the project, standardize and document:
|
||||
- Indentation (e.g., 2 spaces for JS/TS, 4 for Python)
|
||||
- Naming patterns (e.g., `camelCase` for functions, `PascalCase` for classes)
|
||||
- Formatters/linters (e.g., Prettier, ESLint, Black, Ruff)
|
||||
Add config files (like `.editorconfig`, `.prettierrc`, or `pyproject.toml`) and keep them in version control.
|
||||
|
||||
## Testing Guidelines
|
||||
No testing framework is configured yet. When you add tests:
|
||||
- Co-locate tests under `tests/` or alongside modules (e.g., `src/foo.test.ts`)
|
||||
- Use clear, descriptive test names
|
||||
- Ensure tests run with a single command and include it in `README.md`
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
No Git history is available to infer conventions. Use clear, imperative commit messages (e.g., “Add event model”).
|
||||
For pull requests, include:
|
||||
- A short summary of changes
|
||||
- Linked issue or requirement if available
|
||||
- Screenshots or logs for UI/behavior changes
|
||||
|
||||
## Security & Configuration Tips
|
||||
If you add secrets or environment settings, use a `.env.example` and keep real secrets out of the repo. Document required environment variables in `README.md`.
|
||||
|
||||
## Agent Instructions
|
||||
Update `README.md` whenever you change workflows, setup steps, environment variables, or Docker behavior so future sessions stay aligned. This file (`AGENTS.md`) provides the contributor guide and lightweight rules for agents working in the repo.
|
||||
26
Dockerfile
Normal file
26
Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
FROM node:24-alpine AS base
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache openssl
|
||||
RUN npm install -g npm@11.7.0
|
||||
ENV NPM_CONFIG_UPDATE_NOTIFIER=false
|
||||
|
||||
FROM base AS deps
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||
|
||||
FROM base AS builder
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM base AS runner
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/next.config.js ./next.config.js
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/public ./public
|
||||
EXPOSE 3000
|
||||
CMD ["sh", "-c", "npm run prisma:deploy && npm run start"]
|
||||
93
README.md
Normal file
93
README.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Vereinskalender
|
||||
|
||||
State-of-the-art Kalenderapp fuer Vereine mit Admin-Freigaben, persoenlichen Kalenderansichten und iCal-Export. Die App basiert auf Next.js (App Router), Prisma und NextAuth (Credentials).
|
||||
|
||||
## Features
|
||||
- Admins koennen Termine sofort freigeben oder Vorschlaege bestaetigen/ablehnen.
|
||||
- Mitglieder schlagen Termine vor; Freigaben laufen ueber das Admin-Panel.
|
||||
- Mehrere Kalenderansichten (Monat, Woche, Liste) mit FullCalendar.
|
||||
- Eigene Ansichten mit iCal-Abonnement fuer externe Apps (iOS/Android).
|
||||
|
||||
## Tech-Stack
|
||||
- Frontend: Next.js 14 (App Router), React 18, Tailwind CSS, FullCalendar
|
||||
- Backend: Next.js Route Handlers, Prisma ORM, SQLite
|
||||
- Auth: NextAuth (Credentials + Prisma Adapter)
|
||||
- Export: iCal via `ical-generator`
|
||||
|
||||
## Projektstruktur
|
||||
- `app/` - Routen, Layouts und Seiten
|
||||
- `components/` - UI-Komponenten (Kalender, Admin-Panel, View-Manager)
|
||||
- `app/api/` - API-Routen (Auth, Events, Views, iCal)
|
||||
- `prisma/` - Schema und Migrations
|
||||
- `lib/` - Prisma Client, Auth-Helfer
|
||||
|
||||
## Lokale Entwicklung
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env
|
||||
npm run prisma:migrate
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open `http://localhost:3000`.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
In `.env` (lokal) bzw. per Umgebungsvariablen (Docker/Prod):
|
||||
|
||||
```
|
||||
DATABASE_URL="file:./dev.db"
|
||||
NEXTAUTH_SECRET="replace-with-strong-secret"
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
ADMIN_EMAILS="admin@example.com"
|
||||
```
|
||||
|
||||
## Admin-Setup
|
||||
|
||||
`ADMIN_EMAILS` (kommagetrennt) steuert, welche Accounts beim Signup als Admin markiert werden.
|
||||
|
||||
## Wichtige Befehle
|
||||
|
||||
- `npm run dev` - lokale Entwicklung
|
||||
- `npm run build` - Production Build
|
||||
- `npm run start` - Server starten (Production)
|
||||
- `npm run prisma:migrate` - DB Migrationen fuer SQLite (Dev)
|
||||
- `npm run prisma:deploy` - Schema push (Container-Start)
|
||||
- `npm run prisma:studio` - Prisma Studio
|
||||
- `npm run copy:fullcalendar-css` - FullCalendar CSS nach `public/vendor/fullcalendar/` kopieren
|
||||
|
||||
## APIs (kurz)
|
||||
- `POST /api/register` - Registrierung
|
||||
- `GET /api/events` - Events (Admins sehen alles, User nur eigene + freigegebene)
|
||||
- `POST /api/events` - Termin vorschlagen/anlegen
|
||||
- `PATCH /api/events/:id` - Freigeben/Ablehnen (Admin)
|
||||
- `GET /api/views` - Eigene Ansichten
|
||||
- `POST /api/views` - Ansicht erstellen
|
||||
- `POST /api/views/:id/items` - Termin zur Ansicht
|
||||
- `DELETE /api/views/:id/items` - Termin entfernen
|
||||
- `GET /api/ical/:token` - iCal Feed der Ansicht
|
||||
|
||||
## iCal-Abonnement
|
||||
|
||||
Unter "Meine Ansichten" wird fuer jede Ansicht eine iCal-URL erzeugt. Diese kann in Kalender-Apps (iOS/Android) abonniert werden.
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Wichtig fuer persistente Logins:
|
||||
- `NEXTAUTH_SECRET` in `.env` fix setzen (nicht bei jedem Build wechseln).
|
||||
- Die SQLite-DB liegt im `data/`-Volume des Containers und bleibt erhalten.
|
||||
|
||||
## Schnellere Builds (Best Practices)
|
||||
- `package-lock.json` committen und im Dockerfile `npm ci` nutzen (bereits vorbereitet).
|
||||
- BuildKit-Cache nutzen (im Dockerfile aktiv, benoetigt Docker BuildKit).
|
||||
- Fuer schnelle lokale Iteration: `docker compose -f docker-compose.dev.yml up --build`.
|
||||
|
||||
## Sicherheitshinweise
|
||||
- Keine Secrets committen; `.env` ist in `.dockerignore`.
|
||||
- Fuer Prod echte Secrets und eine externe DB nutzen.
|
||||
16
app/admin/page.tsx
Normal file
16
app/admin/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import AdminPanel from "../../components/AdminPanel";
|
||||
import { authOptions } from "../../lib/auth";
|
||||
|
||||
export default async function AdminPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (session?.user?.role !== "ADMIN") {
|
||||
return (
|
||||
<div className="rounded border border-dashed border-slate-300 bg-white p-8 text-center">
|
||||
<p className="text-slate-700">Nur fuer Admins.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <AdminPanel />;
|
||||
}
|
||||
6
app/api/auth/[...nextauth]/route.ts
Normal file
6
app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import NextAuth from "next-auth";
|
||||
import { authOptions } from "../../../../lib/auth";
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
28
app/api/events/[id]/route.ts
Normal file
28
app/api/events/[id]/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { isAdminSession, requireSession } from "../../../../lib/auth-helpers";
|
||||
|
||||
export async function PATCH(request: Request, context: { params: { id: string } }) {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!isAdminSession(session)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { status } = body || {};
|
||||
|
||||
if (!status || !["APPROVED", "REJECTED"].includes(status)) {
|
||||
return NextResponse.json({ error: "Status ungueltig." }, { status: 400 });
|
||||
}
|
||||
|
||||
const event = await prisma.event.update({
|
||||
where: { id: context.params.id },
|
||||
data: { status }
|
||||
});
|
||||
|
||||
return NextResponse.json(event);
|
||||
}
|
||||
63
app/api/events/route.ts
Normal file
63
app/api/events/route.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { isAdminSession, requireSession } from "../../../lib/auth-helpers";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const status = searchParams.get("status");
|
||||
const isAdmin = isAdminSession(session);
|
||||
|
||||
const where = isAdmin
|
||||
? status
|
||||
? { status }
|
||||
: {}
|
||||
: {
|
||||
OR: [
|
||||
{ status: "APPROVED" },
|
||||
{ createdBy: { email: session.user?.email || "" } }
|
||||
]
|
||||
};
|
||||
|
||||
const events = await prisma.event.findMany({
|
||||
where,
|
||||
orderBy: { startAt: "asc" }
|
||||
});
|
||||
|
||||
return NextResponse.json(events);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { title, description, location, startAt, endAt } = body || {};
|
||||
|
||||
if (!title || !startAt || !endAt) {
|
||||
return NextResponse.json(
|
||||
{ error: "Titel, Start und Ende sind erforderlich." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const event = await prisma.event.create({
|
||||
data: {
|
||||
title,
|
||||
description: description || null,
|
||||
location: location || null,
|
||||
startAt: new Date(startAt),
|
||||
endAt: new Date(endAt),
|
||||
status: isAdminSession(session) ? "APPROVED" : "PENDING",
|
||||
createdBy: { connect: { email: session.user?.email || "" } }
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json(event, { status: 201 });
|
||||
}
|
||||
42
app/api/ical/[token]/route.ts
Normal file
42
app/api/ical/[token]/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import ical from "ical-generator";
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
context: { params: { token: string } }
|
||||
) {
|
||||
const view = await prisma.userView.findUnique({
|
||||
where: { token: context.params.token },
|
||||
include: { items: { include: { event: true } }, user: true }
|
||||
});
|
||||
|
||||
if (!view) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const calendar = ical({
|
||||
name: `Vereinskalender - ${view.name}`,
|
||||
timezone: "Europe/Berlin"
|
||||
});
|
||||
|
||||
view.items
|
||||
.map((item) => item.event)
|
||||
.filter((event) => event.status === "APPROVED")
|
||||
.forEach((event) => {
|
||||
calendar.createEvent({
|
||||
id: event.id,
|
||||
summary: event.title,
|
||||
description: event.description || undefined,
|
||||
location: event.location || undefined,
|
||||
start: event.startAt,
|
||||
end: event.endAt
|
||||
});
|
||||
});
|
||||
|
||||
return new NextResponse(calendar.toString(), {
|
||||
headers: {
|
||||
"Content-Type": "text/calendar; charset=utf-8"
|
||||
}
|
||||
});
|
||||
}
|
||||
30
app/api/register/route.ts
Normal file
30
app/api/register/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { isAdminEmail } from "../../../lib/auth";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
const { email, name, password } = body || {};
|
||||
|
||||
if (!email || !password) {
|
||||
return NextResponse.json({ error: "Email und Passwort sind erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email } });
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: "Account existiert bereits." }, { status: 409 });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
name: name || null,
|
||||
passwordHash,
|
||||
role: isAdminEmail(email) ? "ADMIN" : "USER"
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ id: user.id, email: user.email });
|
||||
}
|
||||
60
app/api/views/[id]/items/route.ts
Normal file
60
app/api/views/[id]/items/route.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../../lib/prisma";
|
||||
import { requireSession } from "../../../../../lib/auth-helpers";
|
||||
|
||||
async function ensureOwner(viewId: string, email: string) {
|
||||
const view = await prisma.userView.findFirst({
|
||||
where: { id: viewId, user: { email } }
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
export async function POST(request: Request, context: { params: { id: string } }) {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const email = session.user?.email || "";
|
||||
const view = await ensureOwner(context.params.id, email);
|
||||
if (!view) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { eventId } = body || {};
|
||||
if (!eventId) {
|
||||
return NextResponse.json({ error: "Event erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.userViewItem.create({
|
||||
data: { viewId: view.id, eventId }
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true }, { status: 201 });
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, context: { params: { id: string } }) {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const email = session.user?.email || "";
|
||||
const view = await ensureOwner(context.params.id, email);
|
||||
if (!view) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { eventId } = body || {};
|
||||
if (!eventId) {
|
||||
return NextResponse.json({ error: "Event erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.userViewItem.deleteMany({
|
||||
where: { viewId: view.id, eventId }
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
43
app/api/views/route.ts
Normal file
43
app/api/views/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { requireSession } from "../../../lib/auth-helpers";
|
||||
|
||||
export async function GET() {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const views = await prisma.userView.findMany({
|
||||
where: { user: { email: session.user?.email || "" } },
|
||||
include: { items: { include: { event: true } } },
|
||||
orderBy: { createdAt: "desc" }
|
||||
});
|
||||
|
||||
return NextResponse.json(views);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name } = body || {};
|
||||
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: "Name erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const view = await prisma.userView.create({
|
||||
data: {
|
||||
name,
|
||||
token: randomUUID(),
|
||||
user: { connect: { email: session.user?.email || "" } }
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json(view, { status: 201 });
|
||||
}
|
||||
24
app/globals.css
Normal file
24
app/globals.css
Normal file
@@ -0,0 +1,24 @@
|
||||
@import "@fontsource/space-grotesk/variable.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--surface: #ffffff;
|
||||
--ink: #1f1a17;
|
||||
--muted: #f7efe4;
|
||||
--line: #e6dccf;
|
||||
--accent: #ff6b4a;
|
||||
--accent-strong: #e24a2b;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(65% 80% at 0% 0%, #fff1df 0%, transparent 60%),
|
||||
radial-gradient(65% 80% at 100% 0%, #e9f0ff 0%, transparent 60%),
|
||||
#f8f2e9;
|
||||
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||
}
|
||||
32
app/layout.tsx
Normal file
32
app/layout.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import Providers from "./providers";
|
||||
import NavBar from "../components/NavBar";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Vereinskalender",
|
||||
description: "Kalenderapp fuer Vereine"
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<link rel="stylesheet" href="/vendor/fullcalendar/fullcalendar-core.css" />
|
||||
<link rel="stylesheet" href="/vendor/fullcalendar/fullcalendar-daygrid.css" />
|
||||
<link rel="stylesheet" href="/vendor/fullcalendar/fullcalendar-timegrid.css" />
|
||||
<link rel="stylesheet" href="/vendor/fullcalendar/fullcalendar-list.css" />
|
||||
</head>
|
||||
<body>
|
||||
<Providers>
|
||||
<NavBar />
|
||||
<main className="mx-auto max-w-6xl px-4 py-8">{children}</main>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
57
app/login/page.tsx
Normal file
57
app/login/page.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const email = formData.get("email") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: true,
|
||||
callbackUrl: "/"
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
setError("Login fehlgeschlagen.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md rounded bg-white p-6 shadow-sm">
|
||||
<h1 className="text-2xl font-semibold">Login</h1>
|
||||
<form onSubmit={onSubmit} className="mt-4 space-y-3">
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="E-Mail"
|
||||
required
|
||||
className="w-full rounded border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Passwort"
|
||||
required
|
||||
className="w-full rounded border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
<button type="submit" className="w-full rounded bg-brand-500 px-4 py-2 text-white">
|
||||
Anmelden
|
||||
</button>
|
||||
</form>
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
<p className="mt-4 text-sm text-slate-600">
|
||||
Kein Konto? <Link href="/register" className="text-brand-700">Registrieren</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
app/page.tsx
Normal file
24
app/page.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import CalendarBoard from "../components/CalendarBoard";
|
||||
import EventForm from "../components/EventForm";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-brand-500 via-brand-700 to-slate-900 p-8 text-white shadow-lg">
|
||||
<div className="absolute -right-16 -top-16 h-40 w-40 rounded-full bg-white/20 blur-2xl" />
|
||||
<div className="absolute -bottom-20 left-10 h-56 w-56 rounded-full bg-white/10 blur-3xl" />
|
||||
<div className="relative">
|
||||
<h1 className="text-3xl font-semibold">Vereinskalender</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm text-white/90">
|
||||
Termine einstellen, abstimmen und als persoenlichen Kalender abonnieren.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<CalendarBoard />
|
||||
<EventForm />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
app/providers.tsx
Normal file
8
app/providers.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export default function Providers({ children }: { children: ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
77
app/register/page.tsx
Normal file
77
app/register/page.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const payload = {
|
||||
name: formData.get("name"),
|
||||
email: formData.get("email"),
|
||||
password: formData.get("password")
|
||||
};
|
||||
|
||||
const response = await fetch("/api/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
setError(data.error || "Registrierung fehlgeschlagen.");
|
||||
return;
|
||||
}
|
||||
|
||||
setDone(true);
|
||||
await signIn("credentials", {
|
||||
email: payload.email,
|
||||
password: payload.password,
|
||||
callbackUrl: "/"
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md rounded bg-white p-6 shadow-sm">
|
||||
<h1 className="text-2xl font-semibold">Registrieren</h1>
|
||||
<form onSubmit={onSubmit} className="mt-4 space-y-3">
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
className="w-full rounded border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="E-Mail"
|
||||
required
|
||||
className="w-full rounded border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Passwort"
|
||||
required
|
||||
className="w-full rounded border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
<button type="submit" className="w-full rounded bg-brand-500 px-4 py-2 text-white">
|
||||
Konto anlegen
|
||||
</button>
|
||||
</form>
|
||||
{done && <p className="mt-3 text-sm text-emerald-600">Account erstellt.</p>}
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
<p className="mt-4 text-sm text-slate-600">
|
||||
Schon registriert? <Link href="/login" className="text-brand-700">Login</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
app/views/page.tsx
Normal file
16
app/views/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import ViewManager from "../../components/ViewManager";
|
||||
import { authOptions } from "../../lib/auth";
|
||||
|
||||
export default async function ViewsPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<div className="rounded border border-dashed border-slate-300 bg-white p-8 text-center">
|
||||
<p className="text-slate-700">Bitte anmelden, um Ansichten zu verwalten.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ViewManager />;
|
||||
}
|
||||
90
components/AdminPanel.tsx
Normal file
90
components/AdminPanel.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type EventItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
status: string;
|
||||
location?: string | null;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export default function AdminPanel() {
|
||||
const [events, setEvents] = useState<EventItem[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/events?status=PENDING");
|
||||
if (!response.ok) {
|
||||
throw new Error("Vorschlaege konnten nicht geladen werden.");
|
||||
}
|
||||
setEvents(await response.json());
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const updateStatus = async (id: string, status: "APPROVED" | "REJECTED") => {
|
||||
await fetch(`/api/events/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h1 className="text-2xl font-semibold">Adminfreigaben</h1>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
{events.length === 0 ? (
|
||||
<p className="text-slate-600">Keine offenen Vorschlaege.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{events.map((event) => (
|
||||
<div key={event.id} className="rounded border border-slate-200 bg-white p-4">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium">{event.title}</h2>
|
||||
<p className="text-sm text-slate-600">
|
||||
{new Date(event.startAt).toLocaleString()} - {new Date(event.endAt).toLocaleString()}
|
||||
</p>
|
||||
{event.location && (
|
||||
<p className="text-sm text-slate-600">Ort: {event.location}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateStatus(event.id, "APPROVED")}
|
||||
className="rounded bg-emerald-600 px-3 py-1.5 text-white"
|
||||
>
|
||||
Freigeben
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateStatus(event.id, "REJECTED")}
|
||||
className="rounded bg-red-600 px-3 py-1.5 text-white"
|
||||
>
|
||||
Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{event.description && (
|
||||
<p className="mt-2 text-sm text-slate-700">{event.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
116
components/CalendarBoard.tsx
Normal file
116
components/CalendarBoard.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import FullCalendar from "@fullcalendar/react";
|
||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||
import listPlugin from "@fullcalendar/list";
|
||||
import interactionPlugin from "@fullcalendar/interaction";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
type EventItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
location?: string | null;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export default function CalendarBoard() {
|
||||
const { data } = useSession();
|
||||
const [events, setEvents] = useState<EventItem[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchEvents = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/events");
|
||||
if (!response.ok) {
|
||||
throw new Error("Events konnten nicht geladen werden.");
|
||||
}
|
||||
const payload = await response.json();
|
||||
setEvents(payload);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.user) {
|
||||
fetchEvents();
|
||||
}
|
||||
}, [data?.user]);
|
||||
|
||||
const calendarEvents = useMemo(
|
||||
() =>
|
||||
events.map((event) => ({
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
start: event.startAt,
|
||||
end: event.endAt,
|
||||
extendedProps: {
|
||||
status: event.status,
|
||||
location: event.location,
|
||||
description: event.description
|
||||
}
|
||||
})),
|
||||
[events]
|
||||
);
|
||||
|
||||
if (!data?.user) {
|
||||
return (
|
||||
<div className="rounded border border-dashed border-slate-300 bg-white p-8 text-center">
|
||||
<p className="text-slate-700">
|
||||
Bitte anmelden, um die Vereinskalender zu sehen.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Kalender</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchEvents}
|
||||
className="rounded border border-slate-300 px-3 py-1.5 text-sm text-slate-700"
|
||||
>
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
{loading && <p className="text-sm text-slate-500">Lade Termine...</p>}
|
||||
<div className="rounded bg-white p-4 shadow-sm">
|
||||
<FullCalendar
|
||||
plugins={[dayGridPlugin, timeGridPlugin, listPlugin, interactionPlugin]}
|
||||
initialView="dayGridMonth"
|
||||
headerToolbar={{
|
||||
left: "prev,next today",
|
||||
center: "title",
|
||||
right: "dayGridMonth,timeGridWeek,listWeek"
|
||||
}}
|
||||
events={calendarEvents}
|
||||
eventContent={(arg) => {
|
||||
const status = arg.event.extendedProps.status;
|
||||
return (
|
||||
<div>
|
||||
<div className="text-sm font-medium">{arg.event.title}</div>
|
||||
{status !== "APPROVED" && (
|
||||
<div className="text-xs text-amber-600">{status}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
height="auto"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
85
components/EventForm.tsx
Normal file
85
components/EventForm.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export default function EventForm() {
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const payload = {
|
||||
title: formData.get("title"),
|
||||
description: formData.get("description"),
|
||||
location: formData.get("location"),
|
||||
startAt: formData.get("startAt"),
|
||||
endAt: formData.get("endAt")
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/events", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "Fehler beim Speichern.");
|
||||
}
|
||||
|
||||
event.currentTarget.reset();
|
||||
setStatus("Termin vorgeschlagen. Ein Admin bestaetigt ihn.");
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded bg-white p-4 shadow-sm">
|
||||
<h2 className="text-lg font-semibold">Termin vorschlagen</h2>
|
||||
<form onSubmit={onSubmit} className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
<input
|
||||
name="title"
|
||||
placeholder="Titel"
|
||||
required
|
||||
className="rounded border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
name="location"
|
||||
placeholder="Ort"
|
||||
className="rounded border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
name="startAt"
|
||||
type="datetime-local"
|
||||
required
|
||||
className="rounded border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
name="endAt"
|
||||
type="datetime-local"
|
||||
required
|
||||
className="rounded border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
<textarea
|
||||
name="description"
|
||||
placeholder="Beschreibung"
|
||||
className="min-h-[96px] rounded border border-slate-300 px-3 py-2 md:col-span-2"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-brand-500 px-4 py-2 text-white md:col-span-2"
|
||||
>
|
||||
Vorschlag senden
|
||||
</button>
|
||||
</form>
|
||||
{status && <p className="mt-3 text-sm text-emerald-600">{status}</p>}
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
50
components/NavBar.tsx
Normal file
50
components/NavBar.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { signIn, signOut, useSession } from "next-auth/react";
|
||||
|
||||
export default function NavBar() {
|
||||
const { data } = useSession();
|
||||
const isAdmin = data?.user?.role === "ADMIN";
|
||||
|
||||
return (
|
||||
<header className="border-b border-slate-200/70 bg-white/80 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
|
||||
<Link href="/" className="text-lg font-semibold text-slate-900">
|
||||
Vereinskalender
|
||||
</Link>
|
||||
<nav className="flex items-center gap-4 text-sm">
|
||||
{data?.user && (
|
||||
<>
|
||||
<Link href="/views" className="text-slate-700 hover:text-slate-900">
|
||||
Meine Ansichten
|
||||
</Link>
|
||||
{isAdmin && (
|
||||
<Link href="/admin" className="text-slate-700 hover:text-slate-900">
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{data?.user ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signOut()}
|
||||
className="rounded bg-slate-900 px-3 py-1.5 text-white"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signIn()}
|
||||
className="rounded bg-brand-500 px-3 py-1.5 text-white"
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
199
components/ViewManager.tsx
Normal file
199
components/ViewManager.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
type EventItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type ViewItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
token: string;
|
||||
items: { event: EventItem }[];
|
||||
};
|
||||
|
||||
export default function ViewManager() {
|
||||
const [views, setViews] = useState<ViewItem[]>([]);
|
||||
const [events, setEvents] = useState<EventItem[]>([]);
|
||||
const [selectedView, setSelectedView] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const [viewsRes, eventsRes] = await Promise.all([
|
||||
fetch("/api/views"),
|
||||
fetch("/api/events?status=APPROVED")
|
||||
]);
|
||||
|
||||
if (!viewsRes.ok || !eventsRes.ok) {
|
||||
throw new Error("Daten konnten nicht geladen werden.");
|
||||
}
|
||||
|
||||
const viewsData = await viewsRes.json();
|
||||
const eventsData = await eventsRes.json();
|
||||
setViews(viewsData);
|
||||
setEvents(eventsData);
|
||||
if (viewsData.length > 0 && !selectedView) {
|
||||
setSelectedView(viewsData[0].id);
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const icalBase = typeof window === "undefined" ? "" : window.location.origin;
|
||||
const activeView = useMemo(
|
||||
() => views.find((view) => view.id === selectedView) || null,
|
||||
[views, selectedView]
|
||||
);
|
||||
|
||||
const createView = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const name = formData.get("name");
|
||||
const response = await fetch("/api/views", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
event.currentTarget.reset();
|
||||
load();
|
||||
}
|
||||
};
|
||||
|
||||
const addItem = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!selectedView) return;
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const eventId = formData.get("eventId");
|
||||
|
||||
await fetch(`/api/views/${selectedView}/items`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ eventId })
|
||||
});
|
||||
|
||||
load();
|
||||
};
|
||||
|
||||
const removeItem = async (eventId: string) => {
|
||||
if (!selectedView) return;
|
||||
await fetch(`/api/views/${selectedView}/items`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ eventId })
|
||||
});
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Meine Kalenderansichten</h1>
|
||||
<p className="text-slate-600">
|
||||
Stelle dir eine persoenliche Terminansicht zusammen und abonnieren sie als iCal.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-4 rounded bg-white p-4 shadow-sm">
|
||||
<h2 className="text-lg font-semibold">Neue Ansicht</h2>
|
||||
<form onSubmit={createView} className="flex gap-2">
|
||||
<input
|
||||
name="name"
|
||||
required
|
||||
placeholder="z.B. Jugendtermine"
|
||||
className="flex-1 rounded border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
<button className="rounded bg-brand-500 px-3 py-2 text-white" type="submit">
|
||||
Anlegen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-700">Ansicht waehlen</label>
|
||||
<select
|
||||
className="w-full rounded border border-slate-300 px-3 py-2"
|
||||
value={selectedView || ""}
|
||||
onChange={(event) => setSelectedView(event.target.value)}
|
||||
>
|
||||
{views.map((view) => (
|
||||
<option key={view.id} value={view.id}>
|
||||
{view.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{activeView && (
|
||||
<div className="rounded border border-slate-200 bg-slate-50 p-3 text-sm">
|
||||
<p className="font-medium">iCal URL</p>
|
||||
<p className="break-all text-slate-700">
|
||||
{icalBase}/api/ical/{activeView.token}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded bg-white p-4 shadow-sm">
|
||||
<h2 className="text-lg font-semibold">Termine hinzufuegen</h2>
|
||||
<form onSubmit={addItem} className="space-y-3">
|
||||
<select
|
||||
name="eventId"
|
||||
className="w-full rounded border border-slate-300 px-3 py-2"
|
||||
required
|
||||
>
|
||||
<option value="" disabled>
|
||||
Termin waehlen
|
||||
</option>
|
||||
{events.map((event) => (
|
||||
<option key={event.id} value={event.id}>
|
||||
{event.title} ({new Date(event.startAt).toLocaleDateString()})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="w-full rounded bg-slate-900 px-3 py-2 text-white" type="submit">
|
||||
Zur Ansicht hinzufuegen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-slate-700">Inhalte der Ansicht</h3>
|
||||
{activeView?.items.length ? (
|
||||
activeView.items.map((item) => (
|
||||
<div
|
||||
key={item.event.id}
|
||||
className="flex items-center justify-between rounded border border-slate-200 px-3 py-2"
|
||||
>
|
||||
<span className="text-sm">{item.event.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-red-600"
|
||||
onClick={() => removeItem(item.event.id)}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">Noch keine Termine.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
17
docker-compose.dev.yml
Normal file
17
docker-compose.dev.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: "file:./data/dev.db"
|
||||
volumes:
|
||||
- ./:/app
|
||||
- /app/node_modules
|
||||
- app-data:/app/data
|
||||
command: npm run dev
|
||||
|
||||
volumes:
|
||||
app-data:
|
||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
DATABASE_URL: "file:./data/dev.db"
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
|
||||
volumes:
|
||||
app-data:
|
||||
15
lib/auth-helpers.ts
Normal file
15
lib/auth-helpers.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextResponse } from "next/server";
|
||||
import { authOptions } from "./auth";
|
||||
|
||||
export async function requireSession() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user?.email) {
|
||||
return { session: null, response: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
|
||||
}
|
||||
return { session, response: null };
|
||||
}
|
||||
|
||||
export function isAdminSession(session: { user?: { role?: string } } | null) {
|
||||
return session?.user?.role === "ADMIN";
|
||||
}
|
||||
74
lib/auth.ts
Normal file
74
lib/auth.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { PrismaAdapter } from "@next-auth/prisma-adapter";
|
||||
import bcrypt from "bcryptjs";
|
||||
import type { NextAuthOptions } from "next-auth";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
adapter: PrismaAdapter(prisma),
|
||||
session: { strategy: "jwt" },
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
name: "credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" }
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email: credentials.email }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(
|
||||
credentials.password,
|
||||
user.passwordHash
|
||||
);
|
||||
|
||||
if (!valid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role
|
||||
} as any;
|
||||
}
|
||||
})
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.role = (user as any).role;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session.user) {
|
||||
(session.user as any).role = token.role;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
},
|
||||
pages: {
|
||||
signIn: "/login"
|
||||
}
|
||||
};
|
||||
|
||||
export const isAdminEmail = (email?: string | null) => {
|
||||
if (!email) return false;
|
||||
const list = (process.env.ADMIN_EMAILS || "")
|
||||
.split(",")
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
return list.includes(email.toLowerCase());
|
||||
};
|
||||
16
lib/prisma.ts
Normal file
16
lib/prisma.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var prisma: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
export const prisma =
|
||||
global.prisma ||
|
||||
new PrismaClient({
|
||||
log: ["warn", "error"]
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
global.prisma = prisma;
|
||||
}
|
||||
11
next-auth.d.ts
vendored
Normal file
11
next-auth.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
import NextAuth from "next-auth";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user?: {
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
role?: "ADMIN" | "USER";
|
||||
};
|
||||
}
|
||||
}
|
||||
5
next-env.d.ts
vendored
Normal file
5
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
6
next.config.js
Normal file
6
next.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
48
package.json
Normal file
48
package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "vereinskalender",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": "24.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
"copy:fullcalendar-css": "node scripts/copy-fullcalendar-css.js",
|
||||
"dev": "npm run copy:fullcalendar-css && next dev",
|
||||
"build": "npm run copy:fullcalendar-css && prisma generate && next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:deploy": "prisma db push",
|
||||
"prisma:studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fullcalendar/core": "^6.1.11",
|
||||
"@fullcalendar/daygrid": "^6.1.11",
|
||||
"@fullcalendar/interaction": "^6.1.11",
|
||||
"@fullcalendar/list": "^6.1.11",
|
||||
"@fullcalendar/react": "^6.1.11",
|
||||
"@fullcalendar/timegrid": "^6.1.11",
|
||||
"@fontsource/space-grotesk": "^5.1.0",
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@prisma/client": "^5.19.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"ical-generator": "^7.2.0",
|
||||
"next": "14.2.5",
|
||||
"next-auth": "^4.24.7",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "14.2.5",
|
||||
"postcss": "^8.4.39",
|
||||
"prisma": "^5.19.1",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
91
prisma/schema.prisma
Normal file
91
prisma/schema.prisma
Normal file
@@ -0,0 +1,91 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String?
|
||||
passwordHash String
|
||||
role String @default("USER")
|
||||
createdAt DateTime @default(now())
|
||||
events Event[] @relation("EventCreator")
|
||||
views UserView[]
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
}
|
||||
|
||||
model Event {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
description String?
|
||||
location String?
|
||||
startAt DateTime
|
||||
endAt DateTime
|
||||
status String @default("PENDING")
|
||||
createdAt DateTime @default(now())
|
||||
createdById String
|
||||
createdBy User @relation("EventCreator", fields: [createdById], references: [id])
|
||||
viewItems UserViewItem[]
|
||||
}
|
||||
|
||||
model UserView {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
token String @unique
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
items UserViewItem[]
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model UserViewItem {
|
||||
id String @id @default(cuid())
|
||||
viewId String
|
||||
eventId String
|
||||
view UserView @relation(fields: [viewId], references: [id])
|
||||
event Event @relation(fields: [eventId], references: [id])
|
||||
|
||||
@@unique([viewId, eventId])
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String
|
||||
refresh_token String?
|
||||
access_token String?
|
||||
expires_at Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
id_token String?
|
||||
session_state String?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([provider, providerAccountId])
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
sessionToken String @unique
|
||||
userId String
|
||||
expires DateTime
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
identifier String
|
||||
token String @unique
|
||||
expires DateTime
|
||||
|
||||
@@unique([identifier, token])
|
||||
}
|
||||
50
scripts/copy-fullcalendar-css.js
Normal file
50
scripts/copy-fullcalendar-css.js
Normal file
@@ -0,0 +1,50 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const mappings = [
|
||||
{ pkg: "@fullcalendar/core", target: "fullcalendar-core.css" },
|
||||
{ pkg: "@fullcalendar/daygrid", target: "fullcalendar-daygrid.css" },
|
||||
{ pkg: "@fullcalendar/timegrid", target: "fullcalendar-timegrid.css" },
|
||||
{ pkg: "@fullcalendar/list", target: "fullcalendar-list.css" }
|
||||
];
|
||||
|
||||
const candidates = [
|
||||
"main.css",
|
||||
"index.css",
|
||||
"style.css",
|
||||
"styles.css",
|
||||
"main.min.css",
|
||||
"index.min.css",
|
||||
"dist/main.css",
|
||||
"dist/index.css"
|
||||
];
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const targetDir = path.join(root, "public", "vendor", "fullcalendar");
|
||||
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
}
|
||||
|
||||
let missing = 0;
|
||||
|
||||
mappings.forEach(({ pkg, target }) => {
|
||||
const basePath = path.join(root, "node_modules", pkg);
|
||||
const sourcePath =
|
||||
candidates
|
||||
.map((candidate) => path.join(basePath, candidate))
|
||||
.find((candidatePath) => fs.existsSync(candidatePath)) || null;
|
||||
const targetPath = path.join(targetDir, target);
|
||||
|
||||
if (!sourcePath) {
|
||||
console.error(`Missing FullCalendar CSS for ${pkg}`);
|
||||
missing += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
fs.copyFileSync(sourcePath, targetPath);
|
||||
});
|
||||
|
||||
if (missing > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
20
tailwind.config.ts
Normal file
20
tailwind.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: "#fff1ea",
|
||||
100: "#ffd8cb",
|
||||
500: "#ff6b4a",
|
||||
700: "#e24a2b"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
|
||||
export default config;
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }]
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user