Aktueller Stand
This commit is contained in:
@@ -26,7 +26,9 @@ export async function GET(request: Request) {
|
||||
const events = await prisma.event.findMany({
|
||||
where,
|
||||
orderBy: { startAt: "asc" },
|
||||
include: { category: true }
|
||||
include: isAdmin
|
||||
? { category: true, createdBy: { select: { name: true, email: true } } }
|
||||
: { category: true }
|
||||
});
|
||||
|
||||
return NextResponse.json(events);
|
||||
|
||||
8
app/api/ical/[token]/[filename]/route.ts
Normal file
8
app/api/ical/[token]/[filename]/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { getIcalResponse } from "../../../../../lib/ical-export";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
context: { params: { token: string; filename: string } }
|
||||
) {
|
||||
return getIcalResponse(request, context.params.token);
|
||||
}
|
||||
@@ -1,66 +1,8 @@
|
||||
import ical from "ical-generator";
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { getIcalResponse } from "../../../../lib/ical-export";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
request: Request,
|
||||
context: { params: { token: string } }
|
||||
) {
|
||||
const view = await prisma.userView.findUnique({
|
||||
where: { token: context.params.token },
|
||||
include: {
|
||||
items: { include: { event: true } },
|
||||
categories: true,
|
||||
exclusions: true,
|
||||
user: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!view) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const calendar = ical({
|
||||
name: `Vereinskalender - ${view.name}`,
|
||||
timezone: "Europe/Berlin"
|
||||
});
|
||||
|
||||
const excludedIds = new Set(view.exclusions.map((item) => item.eventId));
|
||||
const explicitEvents = view.items
|
||||
.map((item) => item.event)
|
||||
.filter((event) => event.status === "APPROVED");
|
||||
|
||||
const categoryIds = view.categories.map((item) => item.categoryId);
|
||||
const categoryEvents =
|
||||
categoryIds.length > 0
|
||||
? await prisma.event.findMany({
|
||||
where: { categoryId: { in: categoryIds }, status: "APPROVED" }
|
||||
})
|
||||
: [];
|
||||
|
||||
const combined = [...explicitEvents, ...categoryEvents].filter(
|
||||
(event, index, all) =>
|
||||
all.findIndex((item) => item.id === event.id) === index &&
|
||||
!excludedIds.has(event.id)
|
||||
);
|
||||
|
||||
combined.forEach((event) => {
|
||||
const start = event.startAt;
|
||||
const end =
|
||||
event.endAt || new Date(event.startAt.getTime() + 3 * 60 * 60 * 1000);
|
||||
calendar.createEvent({
|
||||
id: event.id,
|
||||
summary: event.title,
|
||||
description: event.description || undefined,
|
||||
location: event.location || undefined,
|
||||
start,
|
||||
end
|
||||
});
|
||||
});
|
||||
|
||||
return new NextResponse(calendar.toString(), {
|
||||
headers: {
|
||||
"Content-Type": "text/calendar; charset=utf-8"
|
||||
}
|
||||
});
|
||||
return getIcalResponse(request, context.params.token);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { NextResponse } from "next/server";
|
||||
import { parseICS } from "node-ical";
|
||||
import { isAdminSession, requireSession } from "../../../../lib/auth-helpers";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { checkRateLimit, getRateLimitConfig } from "../../../../lib/rate-limit";
|
||||
import { getClientIp } from "../../../../lib/request";
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||
|
||||
@@ -11,6 +13,32 @@ const asText = (value: unknown) => {
|
||||
return String(value).trim();
|
||||
};
|
||||
|
||||
const parseGeo = (value: unknown) => {
|
||||
if (!value) return null;
|
||||
if (typeof value === "string") {
|
||||
const cleaned = value.trim();
|
||||
if (!cleaned) return null;
|
||||
const parts = cleaned.split(/[;,]/).map((part) => part.trim());
|
||||
if (parts.length >= 2) {
|
||||
const lat = Number(parts[0]);
|
||||
const lng = Number(parts[1]);
|
||||
if (!Number.isNaN(lat) && !Number.isNaN(lng)) {
|
||||
return { lat, lng };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
const lat = Number(record.lat ?? record.latitude);
|
||||
const lng = Number(record.lon ?? record.lng ?? record.longitude);
|
||||
if (!Number.isNaN(lat) && !Number.isNaN(lng)) {
|
||||
return { lat, lng };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
@@ -20,6 +48,22 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "Nur für Admins." }, { status: 403 });
|
||||
}
|
||||
|
||||
const ip = getClientIp(request);
|
||||
const email = session.user?.email || "unknown";
|
||||
const rateKey = `icalimport:${email}:${ip}`;
|
||||
const rateConfig = getRateLimitConfig("RATE_LIMIT_ICAL_IMPORT", 5);
|
||||
const rate = await checkRateLimit({
|
||||
key: rateKey,
|
||||
limit: rateConfig.limit,
|
||||
windowMs: rateConfig.windowMs
|
||||
});
|
||||
if (!rate.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Zu viele Importe. Bitte später erneut versuchen." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file");
|
||||
const categoryId = asText(formData.get("categoryId"));
|
||||
@@ -103,6 +147,7 @@ export async function POST(request: Request) {
|
||||
: new Date(start.getTime() + 3 * 60 * 60 * 1000);
|
||||
const location = asText(entry.location) || null;
|
||||
const description = asText(entry.description) || null;
|
||||
const geo = parseGeo(entry.geo);
|
||||
|
||||
const existing = await prisma.event.findFirst({
|
||||
where: {
|
||||
@@ -123,6 +168,8 @@ export async function POST(request: Request) {
|
||||
title,
|
||||
description,
|
||||
location,
|
||||
locationLat: geo ? geo.lat : null,
|
||||
locationLng: geo ? geo.lng : null,
|
||||
startAt: start,
|
||||
endAt: end,
|
||||
status: "APPROVED",
|
||||
|
||||
@@ -2,6 +2,8 @@ import { randomUUID } from "crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { sendMail } from "../../../../lib/mailer";
|
||||
import { checkRateLimit, getRateLimitConfig } from "../../../../lib/rate-limit";
|
||||
import { getClientIp } from "../../../../lib/request";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
@@ -11,9 +13,37 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "E-Mail erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
const normalizedEmail = String(email).trim().toLowerCase();
|
||||
const ip = getClientIp(request);
|
||||
const rateKey = `pwreset:${normalizedEmail}:${ip}`;
|
||||
const rateConfig = getRateLimitConfig("RATE_LIMIT_PASSWORD_RESET", 3);
|
||||
const rate = await checkRateLimit({
|
||||
key: rateKey,
|
||||
limit: rateConfig.limit,
|
||||
windowMs: rateConfig.windowMs
|
||||
});
|
||||
if (!rate.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Zu viele Anfragen. Bitte später erneut versuchen." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email: normalizedEmail } });
|
||||
|
||||
if (user) {
|
||||
const existingToken = await prisma.passwordResetToken.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
createdAt: { gt: new Date(Date.now() - 15 * 60 * 1000) }
|
||||
},
|
||||
orderBy: { createdAt: "desc" }
|
||||
});
|
||||
|
||||
if (existingToken) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
await prisma.passwordResetToken.deleteMany({ where: { userId: user.id } });
|
||||
|
||||
const token = randomUUID();
|
||||
@@ -31,7 +61,7 @@ export async function POST(request: Request) {
|
||||
const resetUrl = `${baseUrl}/reset/confirm?token=${token}`;
|
||||
|
||||
await sendMail({
|
||||
to: email,
|
||||
to: normalizedEmail,
|
||||
subject: "Passwort zurücksetzen",
|
||||
text: `Passwort zurücksetzen: ${resetUrl}`
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ import { randomUUID } from "crypto";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { isAdminEmail, isSuperAdminEmail } from "../../../lib/auth";
|
||||
import { sendMail } from "../../../lib/mailer";
|
||||
import { checkRateLimit, getRateLimitConfig } from "../../../lib/rate-limit";
|
||||
import { getClientIp } from "../../../lib/request";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const registrationSetting = await prisma.setting.findUnique({
|
||||
@@ -24,6 +26,21 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "Email und Passwort sind erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const ip = getClientIp(request);
|
||||
const rateKey = `register:${normalizedEmail}:${ip}`;
|
||||
const rateConfig = getRateLimitConfig("RATE_LIMIT_REGISTER", 5);
|
||||
const rate = await checkRateLimit({
|
||||
key: rateKey,
|
||||
limit: rateConfig.limit,
|
||||
windowMs: rateConfig.windowMs
|
||||
});
|
||||
if (!rate.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Zu viele Anfragen. Bitte später erneut versuchen." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email: normalizedEmail } });
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: "Account existiert bereits." }, { status: 409 });
|
||||
|
||||
39
app/api/settings/app-name/route.ts
Normal file
39
app/api/settings/app-name/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { isSuperAdminSession, requireSession } from "../../../../lib/auth-helpers";
|
||||
|
||||
const DEFAULT_APP_NAME = "Vereinskalender";
|
||||
|
||||
export async function GET() {
|
||||
const setting = await prisma.setting.findUnique({
|
||||
where: { key: "app_name" }
|
||||
});
|
||||
return NextResponse.json({ name: setting?.value || DEFAULT_APP_NAME });
|
||||
}
|
||||
|
||||
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 name = String(body?.name || "").trim();
|
||||
if (!name) {
|
||||
return NextResponse.json({ error: "Name erforderlich." }, { status: 400 });
|
||||
}
|
||||
if (name.length > 60) {
|
||||
return NextResponse.json({ error: "Name ist zu lang." }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.setting.upsert({
|
||||
where: { key: "app_name" },
|
||||
update: { value: name },
|
||||
create: { key: "app_name", value: name }
|
||||
});
|
||||
|
||||
return NextResponse.json({ name });
|
||||
}
|
||||
@@ -16,6 +16,7 @@ const MIME_TO_EXT: Record<string, string> = {
|
||||
"image/webp": "webp",
|
||||
"image/svg+xml": "svg"
|
||||
};
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024;
|
||||
|
||||
const resolveLogoPath = (relativePath: string) => {
|
||||
const absolutePath = path.join(DATA_DIR, relativePath);
|
||||
@@ -51,6 +52,9 @@ export async function POST(request: Request) {
|
||||
if (!extension) {
|
||||
return NextResponse.json({ error: "Dateityp nicht unterstützt." }, { status: 400 });
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json({ error: "Datei ist zu groß (max. 2 MB)." }, { status: 400 });
|
||||
}
|
||||
|
||||
await fs.mkdir(UPLOADS_DIR, { recursive: true });
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { randomUUID } from "crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { sendMail } from "../../../../lib/mailer";
|
||||
import { checkRateLimit, getRateLimitConfig } from "../../../../lib/rate-limit";
|
||||
import { getClientIp } from "../../../../lib/request";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
@@ -11,7 +13,23 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "E-Mail erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
const normalizedEmail = String(email).trim().toLowerCase();
|
||||
const ip = getClientIp(request);
|
||||
const rateKey = `verify:${normalizedEmail}:${ip}`;
|
||||
const rateConfig = getRateLimitConfig("RATE_LIMIT_VERIFY_EMAIL", 3);
|
||||
const rate = await checkRateLimit({
|
||||
key: rateKey,
|
||||
limit: rateConfig.limit,
|
||||
windowMs: rateConfig.windowMs
|
||||
});
|
||||
if (!rate.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Zu viele Anfragen. Bitte später erneut versuchen." },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email: normalizedEmail } });
|
||||
if (!user) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -20,13 +38,21 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
await prisma.verificationToken.deleteMany({ where: { identifier: email } });
|
||||
const existingToken = await prisma.verificationToken.findFirst({
|
||||
where: { identifier: normalizedEmail, expires: { gt: new Date() } }
|
||||
});
|
||||
|
||||
if (existingToken) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
await prisma.verificationToken.deleteMany({ where: { identifier: normalizedEmail } });
|
||||
|
||||
const token = randomUUID();
|
||||
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
await prisma.verificationToken.create({
|
||||
data: {
|
||||
identifier: email,
|
||||
identifier: normalizedEmail,
|
||||
token,
|
||||
expires
|
||||
}
|
||||
@@ -35,7 +61,7 @@ export async function POST(request: Request) {
|
||||
const baseUrl = process.env.NEXTAUTH_URL || "http://localhost:3000";
|
||||
const verifyUrl = `${baseUrl}/verify/confirm?token=${token}`;
|
||||
await sendMail({
|
||||
to: email,
|
||||
to: normalizedEmail,
|
||||
subject: "E-Mail verifizieren",
|
||||
text: `Bitte verifiziere deine E-Mail: ${verifyUrl}`
|
||||
});
|
||||
|
||||
@@ -55,3 +55,35 @@ export async function GET() {
|
||||
|
||||
return NextResponse.json(hydrated, { status: 201 });
|
||||
}
|
||||
|
||||
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 icalPastDays = Number(body?.icalPastDays);
|
||||
if (!Number.isFinite(icalPastDays) || icalPastDays < 0 || icalPastDays > 365) {
|
||||
return NextResponse.json(
|
||||
{ error: "iCal-Rückblick ist ungültig." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const email = session.user?.email || "";
|
||||
const view = await prisma.userView.findFirst({
|
||||
where: { user: { email } }
|
||||
});
|
||||
|
||||
if (!view) {
|
||||
return NextResponse.json({ error: "Ansicht nicht gefunden." }, { status: 404 });
|
||||
}
|
||||
|
||||
const updated = await prisma.userView.update({
|
||||
where: { id: view.id },
|
||||
data: { icalPastDays: Math.floor(icalPastDays) }
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
|
||||
140
app/globals.css
140
app/globals.css
@@ -128,29 +128,129 @@ html[data-theme="dark"] .btn-accent:hover {
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .btn-primary {
|
||||
background: #f8f7f2;
|
||||
color: #0f1110;
|
||||
border-color: rgba(148, 163, 184, 0.4);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .btn-primary:hover {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .btn-ghost {
|
||||
border-color: rgba(71, 85, 105, 0.6);
|
||||
color: #e2e8f0;
|
||||
background: rgba(15, 17, 16, 0.35);
|
||||
background: rgba(30, 41, 59, 0.55);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .btn-ghost:hover {
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
background: rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .ical-link {
|
||||
border-color: rgba(71, 85, 105, 0.5);
|
||||
background: rgba(30, 41, 59, 0.6);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .ical-link span {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] button.text-slate-600 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] button.text-slate-600:hover {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .list-table button {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .list-table button:hover {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .list-table .text-slate-600 {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .list-table svg {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .category-pill {
|
||||
border-color: rgba(71, 85, 105, 0.6);
|
||||
background: rgba(30, 41, 59, 0.55);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .category-pill button {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
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);
|
||||
background: rgba(30, 41, 59, 0.55);
|
||||
border-color: rgba(148, 163, 184, 0.45);
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] input::placeholder,
|
||||
html[data-theme="dark"] textarea::placeholder {
|
||||
color: rgba(226, 232, 240, 0.6);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] header {
|
||||
border-color: rgba(71, 85, 105, 0.35);
|
||||
background: rgba(15, 17, 16, 0.8);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .brand-title {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #334155;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.nav-link-active {
|
||||
background: #0f172a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.nav-link-active:hover {
|
||||
background: #0f172a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .nav-link {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .nav-link:hover {
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .nav-link-active {
|
||||
background: #f8f7f2;
|
||||
color: #0f1110;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .nav-link-active:hover {
|
||||
background: #f8f7f2;
|
||||
color: #0f1110;
|
||||
}
|
||||
|
||||
|
||||
html[data-theme="dark"] .fc .fc-button {
|
||||
border-color: rgba(71, 85, 105, 0.5);
|
||||
background: rgba(15, 17, 16, 0.75);
|
||||
@@ -190,6 +290,30 @@ 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"] .list-table tr {
|
||||
border-color: rgba(71, 85, 105, 0.4);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .list-table tr[data-bucket="past"] {
|
||||
background: rgba(30, 41, 59, 0.55);
|
||||
color: rgba(148, 163, 184, 0.9);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .list-table tr[data-bucket="today"] {
|
||||
background: rgba(217, 119, 6, 0.18);
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .list-table tr[data-bucket="tomorrow"] {
|
||||
background: rgba(16, 185, 129, 0.16);
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .list-table tr[data-bucket="future"] {
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .drag-handle {
|
||||
border-color: rgba(71, 85, 105, 0.5);
|
||||
color: #e2e8f0;
|
||||
@@ -225,8 +349,8 @@ html[data-theme="dark"] .drag-handle:hover {
|
||||
.fc .fc-timegrid-event {
|
||||
border-radius: 0.6rem;
|
||||
border: none;
|
||||
background: #1f2937;
|
||||
color: #ffffff;
|
||||
background: #e2e8f0;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.fc .fc-daygrid-event .fc-event-main,
|
||||
@@ -235,13 +359,15 @@ html[data-theme="dark"] .drag-handle:hover {
|
||||
.fc .fc-timegrid-event .fc-event-title,
|
||||
.fc .fc-daygrid-event .fc-event-time,
|
||||
.fc .fc-timegrid-event .fc-event-time {
|
||||
color: #ffffff;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.fc .fc-daygrid-event .event-shell,
|
||||
.fc .fc-timegrid-event .event-shell {
|
||||
position: relative;
|
||||
padding-right: 1.75rem;
|
||||
overflow: hidden;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.fc .fc-daygrid-event .event-toggle,
|
||||
|
||||
@@ -35,6 +35,10 @@ export default function LoginPage() {
|
||||
setError("Zu viele Versuche. Bitte später erneut versuchen.");
|
||||
return;
|
||||
}
|
||||
if (result.error === "RATE_LIMIT") {
|
||||
setError("Zu viele Anfragen. Bitte später erneut versuchen.");
|
||||
return;
|
||||
}
|
||||
setError("Login fehlgeschlagen.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,27 @@
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function Providers({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
const applyTheme = () => {
|
||||
try {
|
||||
const saved = window.localStorage.getItem("theme");
|
||||
if (saved === "dark" || saved === "light") {
|
||||
root.dataset.theme = saved;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
applyTheme();
|
||||
const handler = () => applyTheme();
|
||||
window.addEventListener("storage", handler);
|
||||
return () => window.removeEventListener("storage", handler);
|
||||
}, []);
|
||||
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -17,6 +17,9 @@ export default function SettingsPage() {
|
||||
const [profileStatus, setProfileStatus] = useState<string | null>(null);
|
||||
const [theme, setTheme] = useState<"light" | "dark">("light");
|
||||
const [copyStatus, setCopyStatus] = useState<"success" | "error" | null>(null);
|
||||
const [appName, setAppName] = useState("Vereinskalender");
|
||||
const [icalPastDays, setIcalPastDays] = useState(14);
|
||||
const icalReadyRef = useRef(false);
|
||||
|
||||
const loadView = async () => {
|
||||
try {
|
||||
@@ -25,6 +28,10 @@ export default function SettingsPage() {
|
||||
const payload = await response.json();
|
||||
setViewToken(payload.token);
|
||||
setViewId(payload.id);
|
||||
setIcalPastDays(
|
||||
typeof payload.icalPastDays === "number" ? payload.icalPastDays : 14
|
||||
);
|
||||
icalReadyRef.current = true;
|
||||
const ids = new Set<string>(
|
||||
(payload.categories || []).map((item: { categoryId: string }) => item.categoryId)
|
||||
);
|
||||
@@ -44,10 +51,23 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadAppName = async () => {
|
||||
try {
|
||||
const nameResponse = await fetch("/api/settings/app-name");
|
||||
if (nameResponse.ok) {
|
||||
const payload = await nameResponse.json();
|
||||
setAppName(payload.name || "Vereinskalender");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.user) {
|
||||
loadView();
|
||||
loadCategories();
|
||||
loadAppName();
|
||||
}
|
||||
}, [data?.user]);
|
||||
|
||||
@@ -106,7 +126,39 @@ export default function SettingsPage() {
|
||||
};
|
||||
|
||||
const baseUrl = typeof window === "undefined" ? "" : window.location.origin;
|
||||
const icalUrl = viewToken ? `${baseUrl}/api/ical/${viewToken}` : "";
|
||||
const toFilename = (value: string) =>
|
||||
value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "") || "kalender";
|
||||
|
||||
const icalQuery = icalPastDays > 0 ? `?pastDays=${icalPastDays}` : "";
|
||||
const icalUrl = viewToken
|
||||
? `${baseUrl}/api/ical/${viewToken}/${toFilename(appName)}.ical${icalQuery}`
|
||||
: "";
|
||||
|
||||
const updateIcalPastDays = async (value: number) => {
|
||||
setError(null);
|
||||
setStatus(null);
|
||||
const response = await fetch("/api/views/default", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ icalPastDays: value })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
setError(data.error || "Einstellung konnte nicht gespeichert werden.");
|
||||
return;
|
||||
}
|
||||
setStatus("iCal-Einstellung gespeichert.");
|
||||
window.setTimeout(() => setStatus(null), 2500);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!icalReadyRef.current || !viewId) return;
|
||||
updateIcalPastDays(icalPastDays);
|
||||
}, [icalPastDays, viewId]);
|
||||
|
||||
const applyTheme = (next: "light" | "dark") => {
|
||||
setTheme(next);
|
||||
@@ -247,43 +299,73 @@ export default function SettingsPage() {
|
||||
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"
|
||||
<div className="ical-link flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">
|
||||
iCal
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-slate-700"
|
||||
title={icalUrl}
|
||||
>
|
||||
{icalUrl}
|
||||
</span>
|
||||
<div className="relative flex items-center">
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
<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}>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="flex items-center gap-3 text-sm text-slate-700">
|
||||
<span className="relative inline-flex h-6 w-11 items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="peer sr-only"
|
||||
checked={icalPastDays > 0}
|
||||
onChange={(event) => setIcalPastDays(event.target.checked ? 14 : 0)}
|
||||
/>
|
||||
<span className="h-6 w-11 rounded-full bg-slate-200 transition peer-checked:bg-emerald-500"></span>
|
||||
<span className="absolute left-1 top-1 h-4 w-4 rounded-full bg-white transition peer-checked:translate-x-5"></span>
|
||||
</span>
|
||||
Rückblick der letzten 14 Tage aktivieren
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" className="hidden" onClick={rotateToken}>
|
||||
Link erneuern
|
||||
</button>
|
||||
{status && <p className="text-sm text-emerald-600">{status}</p>}
|
||||
{status && (
|
||||
<div className="fixed bottom-6 right-6 z-40 rounded-full bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow-lg">
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user