Aktueller Stand
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "../../../lib/prisma";
|
import { prisma } from "../../../lib/prisma";
|
||||||
import { isAdminSession, requireSession } from "../../../lib/auth-helpers";
|
import { isAdminSession, requireSession } from "../../../lib/auth-helpers";
|
||||||
|
import { getAccessSettings } from "../../../lib/system-settings";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const { session } = await requireSession();
|
const { session } = await requireSession();
|
||||||
@@ -26,7 +27,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name } = body || {};
|
const { name, isPublic } = body || {};
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return NextResponse.json({ error: "Name erforderlich." }, { status: 400 });
|
return NextResponse.json({ error: "Name erforderlich." }, { status: 400 });
|
||||||
@@ -37,8 +38,9 @@ export async function POST(request: Request) {
|
|||||||
return NextResponse.json({ error: "Kategorie existiert bereits." }, { status: 409 });
|
return NextResponse.json({ error: "Kategorie existiert bereits." }, { status: 409 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { publicAccessEnabled } = await getAccessSettings();
|
||||||
const category = await prisma.category.create({
|
const category = await prisma.category.create({
|
||||||
data: { name }
|
data: { name, isPublic: publicAccessEnabled && isPublic === true }
|
||||||
});
|
});
|
||||||
|
|
||||||
const views = await prisma.userView.findMany({
|
const views = await prisma.userView.findMany({
|
||||||
@@ -68,25 +70,42 @@ export async function PATCH(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { id, name } = body || {};
|
const { id, name, isPublic } = body || {};
|
||||||
|
|
||||||
if (!id || !name) {
|
if (!id) {
|
||||||
return NextResponse.json({ error: "ID und Name erforderlich." }, { status: 400 });
|
return NextResponse.json({ error: "ID erforderlich." }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmed = String(name).trim();
|
const data: { name?: string; isPublic?: boolean } = {};
|
||||||
if (!trimmed) {
|
|
||||||
return NextResponse.json({ error: "Name erforderlich." }, { status: 400 });
|
if (name !== undefined) {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
data.name = trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await prisma.category.findUnique({ where: { name: trimmed } });
|
const { publicAccessEnabled } = await getAccessSettings();
|
||||||
if (existing && existing.id !== id) {
|
if (publicAccessEnabled && typeof isPublic === "boolean") {
|
||||||
return NextResponse.json({ error: "Kategorie existiert bereits." }, { status: 409 });
|
data.isPublic = isPublic;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(data).length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Keine Änderungen angegeben." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const category = await prisma.category.update({
|
const category = await prisma.category.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { name: trimmed }
|
data
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(category);
|
return NextResponse.json(category);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "../../../../lib/prisma";
|
import { prisma } from "../../../../lib/prisma";
|
||||||
import { isAdminSession, requireSession } from "../../../../lib/auth-helpers";
|
import { isAdminSession, requireSession } from "../../../../lib/auth-helpers";
|
||||||
|
import { getAccessSettings } from "../../../../lib/system-settings";
|
||||||
|
|
||||||
export async function PATCH(request: Request, context: { params: { id: string } }) {
|
export async function PATCH(request: Request, context: { params: { id: string } }) {
|
||||||
const { session } = await requireSession();
|
const { session } = await requireSession();
|
||||||
@@ -23,7 +24,8 @@ export async function PATCH(request: Request, context: { params: { id: string }
|
|||||||
locationLng,
|
locationLng,
|
||||||
startAt,
|
startAt,
|
||||||
endAt,
|
endAt,
|
||||||
categoryId
|
categoryId,
|
||||||
|
publicOverride
|
||||||
} = body || {};
|
} = body || {};
|
||||||
|
|
||||||
if (status && ["APPROVED", "REJECTED"].includes(status)) {
|
if (status && ["APPROVED", "REJECTED"].includes(status)) {
|
||||||
@@ -44,6 +46,13 @@ export async function PATCH(request: Request, context: { params: { id: string }
|
|||||||
|
|
||||||
const startDate = new Date(startAt);
|
const startDate = new Date(startAt);
|
||||||
const endDate = endAt ? new Date(endAt) : null;
|
const endDate = endAt ? new Date(endAt) : null;
|
||||||
|
const { publicAccessEnabled } = await getAccessSettings();
|
||||||
|
const overrideValue =
|
||||||
|
publicAccessEnabled && publicOverride !== undefined
|
||||||
|
? publicOverride === null || publicOverride === true || publicOverride === false
|
||||||
|
? publicOverride
|
||||||
|
: null
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const event = await prisma.event.update({
|
const event = await prisma.event.update({
|
||||||
where: { id: context.params.id },
|
where: { id: context.params.id },
|
||||||
@@ -56,7 +65,8 @@ export async function PATCH(request: Request, context: { params: { id: string }
|
|||||||
locationLng: locationLng ? Number(locationLng) : null,
|
locationLng: locationLng ? Number(locationLng) : null,
|
||||||
startAt: startDate,
|
startAt: startDate,
|
||||||
endAt: endDate,
|
endAt: endDate,
|
||||||
category: { connect: { id: categoryId } }
|
category: { connect: { id: categoryId } },
|
||||||
|
publicOverride: overrideValue
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,66 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
import { prisma } from "../../../lib/prisma";
|
import { prisma } from "../../../lib/prisma";
|
||||||
import { isAdminSession, requireSession } from "../../../lib/auth-helpers";
|
import { isAdminSession, requireSession } from "../../../lib/auth-helpers";
|
||||||
|
import { authOptions } from "../../../lib/auth";
|
||||||
|
import { getAccessSettings } from "../../../lib/system-settings";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const { session } = await requireSession();
|
const session = await getServerSession(authOptions);
|
||||||
if (!session) {
|
if (session?.user?.status && session.user.status !== "ACTIVE") {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json(
|
||||||
|
{ error: "Account nicht freigeschaltet." },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (session?.user?.emailVerified === false) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "E-Mail nicht verifiziert." },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const status = searchParams.get("status");
|
const status = searchParams.get("status");
|
||||||
const isAdmin = isAdminSession(session);
|
const isAdmin = isAdminSession(session);
|
||||||
|
const { publicAccessEnabled } = await getAccessSettings();
|
||||||
|
|
||||||
|
if (!session?.user?.email) {
|
||||||
|
if (!publicAccessEnabled) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Öffentlicher Zugriff ist deaktiviert." },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const events = await prisma.event.findMany({
|
||||||
|
where: {
|
||||||
|
status: "APPROVED",
|
||||||
|
OR: [
|
||||||
|
{ publicOverride: true },
|
||||||
|
{ publicOverride: null, category: { isPublic: true } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
orderBy: { startAt: "asc" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
location: true,
|
||||||
|
locationPlaceId: true,
|
||||||
|
locationLat: true,
|
||||||
|
locationLng: true,
|
||||||
|
startAt: true,
|
||||||
|
endAt: true,
|
||||||
|
status: true,
|
||||||
|
category: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(events);
|
||||||
|
}
|
||||||
|
|
||||||
const where = isAdmin
|
const where = isAdmin
|
||||||
? status
|
? status
|
||||||
|
|||||||
@@ -1,29 +1,15 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "../../../../lib/prisma";
|
import { prisma } from "../../../../lib/prisma";
|
||||||
import { isSuperAdminSession, requireSession } from "../../../../lib/auth-helpers";
|
import { isSuperAdminSession, requireSession } from "../../../../lib/auth-helpers";
|
||||||
|
import { getSystemSettings } from "../../../../lib/system-settings";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const { session } = await requireSession();
|
const { session } = await requireSession();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
const settings = await getSystemSettings();
|
||||||
const apiKeySetting = await prisma.setting.findUnique({
|
return NextResponse.json(settings);
|
||||||
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) {
|
export async function POST(request: Request) {
|
||||||
@@ -37,7 +23,12 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { apiKey, provider, registrationEnabled } = body || {};
|
const {
|
||||||
|
apiKey,
|
||||||
|
provider,
|
||||||
|
registrationEnabled,
|
||||||
|
publicAccessEnabled
|
||||||
|
} = body || {};
|
||||||
|
|
||||||
if (!provider || !["google", "osm"].includes(provider)) {
|
if (!provider || !["google", "osm"].includes(provider)) {
|
||||||
return NextResponse.json({ error: "Provider erforderlich." }, { status: 400 });
|
return NextResponse.json({ error: "Provider erforderlich." }, { status: 400 });
|
||||||
@@ -68,9 +59,26 @@ export async function POST(request: Request) {
|
|||||||
create: { key: "registration_enabled", value: registrationValue }
|
create: { key: "registration_enabled", value: registrationValue }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const existing = await getSystemSettings();
|
||||||
|
const nextPublicAccessEnabled =
|
||||||
|
typeof publicAccessEnabled === "boolean"
|
||||||
|
? publicAccessEnabled
|
||||||
|
: existing.publicAccessEnabled;
|
||||||
|
|
||||||
|
const publicAccessValue = nextPublicAccessEnabled ? "true" : "false";
|
||||||
|
await prisma.setting.upsert({
|
||||||
|
where: { key: "public_access_enabled" },
|
||||||
|
update: { value: publicAccessValue },
|
||||||
|
create: { key: "public_access_enabled", value: publicAccessValue }
|
||||||
|
});
|
||||||
|
await prisma.setting.deleteMany({
|
||||||
|
where: { key: { in: ["public_events_enabled", "anonymous_access_enabled"] } }
|
||||||
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
apiKey: apiKeySetting.value,
|
apiKey: apiKeySetting.value,
|
||||||
provider: providerSetting.value,
|
provider: providerSetting.value,
|
||||||
registrationEnabled: registrationValue !== "false"
|
registrationEnabled: registrationValue !== "false",
|
||||||
|
publicAccessEnabled: nextPublicAccessEnabled
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,6 +250,18 @@ html[data-theme="dark"] .nav-link-active:hover {
|
|||||||
color: #0f1110;
|
color: #0f1110;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-menu-panel {
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
border-bottom: 1px solid rgba(226, 232, 240, 0.85);
|
||||||
|
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .mobile-menu-panel {
|
||||||
|
background: rgba(30, 37, 34, 0.95);
|
||||||
|
border-bottom-color: rgba(71, 85, 105, 0.35);
|
||||||
|
box-shadow: 0 18px 36px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
html[data-theme="dark"] .fc .fc-button {
|
html[data-theme="dark"] .fc .fc-button {
|
||||||
border-color: rgba(71, 85, 105, 0.5);
|
border-color: rgba(71, 85, 105, 0.5);
|
||||||
@@ -387,6 +399,9 @@ html[data-theme="dark"] .drag-handle:hover {
|
|||||||
color: #475569;
|
color: #475569;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,6 +451,10 @@ html[data-theme="dark"] .drag-handle:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.calendar-pane {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.fc .fc-toolbar {
|
.fc .fc-toolbar {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import { useState } from "react";
|
|||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showVerifyLink, setShowVerifyLink] = useState(false);
|
||||||
|
|
||||||
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setShowVerifyLink(false);
|
||||||
const formData = new FormData(event.currentTarget);
|
const formData = new FormData(event.currentTarget);
|
||||||
const email = formData.get("email") as string;
|
const email = formData.get("email") as string;
|
||||||
const password = formData.get("password") as string;
|
const password = formData.get("password") as string;
|
||||||
@@ -29,6 +31,7 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
if (result.error === "EMAIL_NOT_VERIFIED") {
|
if (result.error === "EMAIL_NOT_VERIFIED") {
|
||||||
setError("Bitte bestätige zuerst deine E-Mail.");
|
setError("Bitte bestätige zuerst deine E-Mail.");
|
||||||
|
setShowVerifyLink(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result.error === "LOCKED") {
|
if (result.error === "LOCKED") {
|
||||||
@@ -44,6 +47,7 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result?.ok) {
|
if (result?.ok) {
|
||||||
|
setShowVerifyLink(false);
|
||||||
router.push("/");
|
router.push("/");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -83,12 +87,14 @@ export default function LoginPage() {
|
|||||||
Zurücksetzen
|
Zurücksetzen
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-sm text-slate-600">
|
{showVerifyLink && (
|
||||||
E-Mail nicht bestätigt?{" "}
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
<Link href="/verify" className="text-brand-700">
|
E-Mail nicht bestätigt?{" "}
|
||||||
Link erneut senden
|
<Link href="/verify" className="text-brand-700">
|
||||||
</Link>
|
Link erneut senden
|
||||||
</p>
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
14
app/page.tsx
14
app/page.tsx
@@ -2,21 +2,27 @@ import { getServerSession } from "next-auth";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import CalendarBoard from "../components/CalendarBoard";
|
import CalendarBoard from "../components/CalendarBoard";
|
||||||
import { authOptions } from "../lib/auth";
|
import { authOptions } from "../lib/auth";
|
||||||
|
import { getAccessSettings } from "../lib/system-settings";
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
|
const { publicAccessEnabled } = await getAccessSettings();
|
||||||
|
if (!session?.user && !publicAccessEnabled) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
const isBlocked =
|
||||||
|
session?.user &&
|
||||||
|
(session.user.status !== "ACTIVE" || session.user.emailVerified === false);
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{session?.user?.status === "ACTIVE" ? (
|
{isBlocked ? (
|
||||||
<CalendarBoard />
|
|
||||||
) : session?.user ? (
|
|
||||||
<div className="card-muted text-center">
|
<div className="card-muted text-center">
|
||||||
<p className="text-slate-700">
|
<p className="text-slate-700">
|
||||||
Dein Konto wartet auf Freischaltung durch einen Admin.
|
Dein Konto wartet auf Freischaltung durch einen Admin.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
redirect("/login")
|
<CalendarBoard />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -220,6 +220,16 @@ export default function SettingsPage() {
|
|||||||
setSubscribedCategories(next);
|
setSubscribedCategories(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!data?.user) {
|
||||||
|
return (
|
||||||
|
<div className="card-muted text-center">
|
||||||
|
<p className="text-slate-700">
|
||||||
|
Bitte anmelden, um Einstellungen zu verwalten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<section className="card space-y-4">
|
<section className="card space-y-4">
|
||||||
|
|||||||
@@ -14,23 +14,28 @@ type EventItem = {
|
|||||||
locationLat?: number | null;
|
locationLat?: number | null;
|
||||||
locationLng?: number | null;
|
locationLng?: number | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
category?: { id: string; name: string } | null;
|
publicOverride?: boolean | null;
|
||||||
|
category?: { id: string; name: string; isPublic?: boolean } | null;
|
||||||
createdBy?: { name?: string | null; email?: string | null } | null;
|
createdBy?: { name?: string | null; email?: string | null } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CategoryItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
isPublic: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export default function AdminPanel() {
|
export default function AdminPanel() {
|
||||||
const [events, setEvents] = useState<EventItem[]>([]);
|
const [events, setEvents] = useState<EventItem[]>([]);
|
||||||
const [allEvents, setAllEvents] = 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 [categories, setCategories] = useState<CategoryItem[]>([]);
|
||||||
[]
|
|
||||||
);
|
|
||||||
const [categoryError, setCategoryError] = useState<string | null>(null);
|
const [categoryError, setCategoryError] = useState<string | null>(null);
|
||||||
const [categoryStatus, setCategoryStatus] = useState<string | null>(null);
|
const [categoryStatus, setCategoryStatus] = useState<string | null>(null);
|
||||||
const [categoryModalOpen, setCategoryModalOpen] = useState(false);
|
const [categoryModalOpen, setCategoryModalOpen] = useState(false);
|
||||||
const [categoryModalError, setCategoryModalError] = useState<string | null>(null);
|
const [categoryModalError, setCategoryModalError] = useState<string | null>(null);
|
||||||
const [categoryModalStatus, setCategoryModalStatus] = useState<string | null>(null);
|
const [categoryModalStatus, setCategoryModalStatus] = useState<string | null>(null);
|
||||||
const [editingCategory, setEditingCategory] = useState<{ id: string; name: string } | null>(null);
|
const [editingCategory, setEditingCategory] = useState<CategoryItem | null>(null);
|
||||||
const [editEvent, setEditEvent] = useState<EventItem | null>(null);
|
const [editEvent, setEditEvent] = useState<EventItem | null>(null);
|
||||||
const [editStatus, setEditStatus] = useState<string | null>(null);
|
const [editStatus, setEditStatus] = useState<string | null>(null);
|
||||||
const [editError, setEditError] = useState<string | null>(null);
|
const [editError, setEditError] = useState<string | null>(null);
|
||||||
@@ -45,6 +50,7 @@ export default function AdminPanel() {
|
|||||||
const [importCategoryId, setImportCategoryId] = useState("");
|
const [importCategoryId, setImportCategoryId] = useState("");
|
||||||
const [importStatus, setImportStatus] = useState<string | null>(null);
|
const [importStatus, setImportStatus] = useState<string | null>(null);
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
|
const [publicAccessEnabled, setPublicAccessEnabled] = useState<boolean | null>(null);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -82,10 +88,22 @@ export default function AdminPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadSystemSettings = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/settings/system");
|
||||||
|
if (!response.ok) return;
|
||||||
|
const payload = await response.json();
|
||||||
|
setPublicAccessEnabled(payload.publicAccessEnabled !== false);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
loadCategories();
|
loadCategories();
|
||||||
loadAllEvents();
|
loadAllEvents();
|
||||||
|
loadSystemSettings();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -97,6 +115,7 @@ export default function AdminPanel() {
|
|||||||
}, [pageSize]);
|
}, [pageSize]);
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(allEvents.length / pageSize));
|
const totalPages = Math.max(1, Math.ceil(allEvents.length / pageSize));
|
||||||
|
const showPublicControls = publicAccessEnabled === true;
|
||||||
|
|
||||||
const sortedEvents = [...allEvents].sort((a, b) => {
|
const sortedEvents = [...allEvents].sort((a, b) => {
|
||||||
const dir = sortDir === "asc" ? 1 : -1;
|
const dir = sortDir === "asc" ? 1 : -1;
|
||||||
@@ -160,6 +179,14 @@ export default function AdminPanel() {
|
|||||||
return date.toISOString();
|
return date.toISOString();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parsePublicOverride = (value: FormDataEntryValue | null) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const raw = String(value);
|
||||||
|
if (raw === "public") return true;
|
||||||
|
if (raw === "private") return false;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const updateEvent = async (event: React.FormEvent<HTMLFormElement>) => {
|
const updateEvent = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setEditStatus(null);
|
setEditStatus(null);
|
||||||
@@ -167,7 +194,7 @@ export default function AdminPanel() {
|
|||||||
|
|
||||||
if (!editEvent) return;
|
if (!editEvent) return;
|
||||||
const formData = new FormData(event.currentTarget);
|
const formData = new FormData(event.currentTarget);
|
||||||
const payload = {
|
const payload: Record<string, unknown> = {
|
||||||
title: formData.get("title"),
|
title: formData.get("title"),
|
||||||
description: formData.get("description"),
|
description: formData.get("description"),
|
||||||
location: formData.get("location"),
|
location: formData.get("location"),
|
||||||
@@ -178,6 +205,11 @@ export default function AdminPanel() {
|
|||||||
endAt: toIsoString(formData.get("endAt")),
|
endAt: toIsoString(formData.get("endAt")),
|
||||||
categoryId: formData.get("categoryId")
|
categoryId: formData.get("categoryId")
|
||||||
};
|
};
|
||||||
|
if (showPublicControls) {
|
||||||
|
payload.publicOverride = parsePublicOverride(
|
||||||
|
formData.get("publicOverride")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`/api/events/${editEvent.id}`, {
|
const response = await fetch(`/api/events/${editEvent.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -205,15 +237,21 @@ export default function AdminPanel() {
|
|||||||
setCategoryStatus(null);
|
setCategoryStatus(null);
|
||||||
const formData = new FormData(event.currentTarget);
|
const formData = new FormData(event.currentTarget);
|
||||||
const rawName = String(formData.get("name") || "").trim();
|
const rawName = String(formData.get("name") || "").trim();
|
||||||
|
const isPublic = formData.get("isPublic") === "on";
|
||||||
if (!rawName) {
|
if (!rawName) {
|
||||||
setCategoryError("Name erforderlich.");
|
setCategoryError("Name erforderlich.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = { name: rawName };
|
||||||
|
if (showPublicControls) {
|
||||||
|
payload.isPublic = isPublic;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch("/api/categories", {
|
const response = await fetch("/api/categories", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name: rawName })
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -231,7 +269,7 @@ export default function AdminPanel() {
|
|||||||
setCategoryStatus("Kategorie angelegt.");
|
setCategoryStatus("Kategorie angelegt.");
|
||||||
};
|
};
|
||||||
|
|
||||||
const openCategoryModal = (category: { id: string; name: string }) => {
|
const openCategoryModal = (category: CategoryItem) => {
|
||||||
setEditingCategory(category);
|
setEditingCategory(category);
|
||||||
setCategoryModalError(null);
|
setCategoryModalError(null);
|
||||||
setCategoryModalStatus(null);
|
setCategoryModalStatus(null);
|
||||||
@@ -251,15 +289,24 @@ export default function AdminPanel() {
|
|||||||
|
|
||||||
const formData = new FormData(event.currentTarget);
|
const formData = new FormData(event.currentTarget);
|
||||||
const name = String(formData.get("name") || "").trim();
|
const name = String(formData.get("name") || "").trim();
|
||||||
|
const isPublic = formData.get("isPublic") === "on";
|
||||||
if (!name) {
|
if (!name) {
|
||||||
setCategoryModalError("Name erforderlich.");
|
setCategoryModalError("Name erforderlich.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
id: editingCategory.id,
|
||||||
|
name
|
||||||
|
};
|
||||||
|
if (showPublicControls) {
|
||||||
|
payload.isPublic = isPublic;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch("/api/categories", {
|
const response = await fetch("/api/categories", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ id: editingCategory.id, name })
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -411,6 +458,12 @@ export default function AdminPanel() {
|
|||||||
placeholder="z.B. Training"
|
placeholder="z.B. Training"
|
||||||
className="flex-1 rounded-xl border border-slate-300 px-3 py-2"
|
className="flex-1 rounded-xl border border-slate-300 px-3 py-2"
|
||||||
/>
|
/>
|
||||||
|
{showPublicControls && (
|
||||||
|
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="checkbox" name="isPublic" />
|
||||||
|
Öffentlich
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<button className="btn-accent" type="submit">
|
<button className="btn-accent" type="submit">
|
||||||
Anlegen
|
Anlegen
|
||||||
</button>
|
</button>
|
||||||
@@ -434,6 +487,11 @@ export default function AdminPanel() {
|
|||||||
className="category-pill flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-sm text-slate-700"
|
className="category-pill flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-sm text-slate-700"
|
||||||
>
|
>
|
||||||
<span className="font-medium">{category.name}</span>
|
<span className="font-medium">{category.name}</span>
|
||||||
|
{showPublicControls && category.isPublic && (
|
||||||
|
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-xs text-emerald-700">
|
||||||
|
Öffentlich
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-full border border-slate-200 p-1 text-slate-600"
|
className="rounded-full border border-slate-200 p-1 text-slate-600"
|
||||||
@@ -500,6 +558,16 @@ export default function AdminPanel() {
|
|||||||
required
|
required
|
||||||
className="w-full rounded-xl border border-slate-300 px-3 py-2"
|
className="w-full rounded-xl border border-slate-300 px-3 py-2"
|
||||||
/>
|
/>
|
||||||
|
{showPublicControls && (
|
||||||
|
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="isPublic"
|
||||||
|
defaultChecked={editingCategory.isPublic}
|
||||||
|
/>
|
||||||
|
Öffentlich
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<button type="submit" className="btn-accent w-full">
|
<button type="submit" className="btn-accent w-full">
|
||||||
Speichern
|
Speichern
|
||||||
</button>
|
</button>
|
||||||
@@ -656,6 +724,45 @@ export default function AdminPanel() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{showPublicControls && (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-3 text-sm text-slate-700 md:col-span-2">
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||||
|
Öffentlich
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-3">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="publicOverride"
|
||||||
|
value="inherit"
|
||||||
|
defaultChecked={
|
||||||
|
editEvent.publicOverride !== true &&
|
||||||
|
editEvent.publicOverride !== false
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Kategorie übernehmen
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="publicOverride"
|
||||||
|
value="public"
|
||||||
|
defaultChecked={editEvent.publicOverride === true}
|
||||||
|
/>
|
||||||
|
Öffentlich
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="publicOverride"
|
||||||
|
value="private"
|
||||||
|
defaultChecked={editEvent.publicOverride === false}
|
||||||
|
/>
|
||||||
|
Nicht öffentlich
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
name="description"
|
name="description"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export default function AdminSystemSettings() {
|
|||||||
const [apiKey, setApiKey] = useState("");
|
const [apiKey, setApiKey] = useState("");
|
||||||
const [provider, setProvider] = useState("osm");
|
const [provider, setProvider] = useState("osm");
|
||||||
const [registrationEnabled, setRegistrationEnabled] = useState(true);
|
const [registrationEnabled, setRegistrationEnabled] = useState(true);
|
||||||
|
const [publicAccessEnabled, setPublicAccessEnabled] = useState(true);
|
||||||
const [appName, setAppName] = useState("Vereinskalender");
|
const [appName, setAppName] = useState("Vereinskalender");
|
||||||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||||
const [logoVersion, setLogoVersion] = useState(() => Date.now());
|
const [logoVersion, setLogoVersion] = useState(() => Date.now());
|
||||||
@@ -26,6 +27,7 @@ export default function AdminSystemSettings() {
|
|||||||
setApiKey(payload.apiKey || "");
|
setApiKey(payload.apiKey || "");
|
||||||
setProvider(payload.provider || "osm");
|
setProvider(payload.provider || "osm");
|
||||||
setRegistrationEnabled(payload.registrationEnabled !== false);
|
setRegistrationEnabled(payload.registrationEnabled !== false);
|
||||||
|
setPublicAccessEnabled(payload.publicAccessEnabled !== false);
|
||||||
if (appNameResponse.ok) {
|
if (appNameResponse.ok) {
|
||||||
const appPayload = await appNameResponse.json();
|
const appPayload = await appNameResponse.json();
|
||||||
setAppName(appPayload.name || "Vereinskalender");
|
setAppName(appPayload.name || "Vereinskalender");
|
||||||
@@ -61,7 +63,12 @@ export default function AdminSystemSettings() {
|
|||||||
fetch("/api/settings/system", {
|
fetch("/api/settings/system", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ apiKey, provider, registrationEnabled })
|
body: JSON.stringify({
|
||||||
|
apiKey,
|
||||||
|
provider,
|
||||||
|
registrationEnabled,
|
||||||
|
publicAccessEnabled
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
fetch("/api/settings/app-name", {
|
fetch("/api/settings/app-name", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -224,14 +231,29 @@ export default function AdminSystemSettings() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-3 text-sm text-slate-700">
|
||||||
<input
|
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||||
type="checkbox"
|
Zugriff
|
||||||
checked={registrationEnabled}
|
</p>
|
||||||
onChange={(event) => setRegistrationEnabled(event.target.checked)}
|
<div className="mt-3 space-y-3">
|
||||||
/>
|
<label className="flex items-center gap-2">
|
||||||
Registrierung erlauben
|
<input
|
||||||
</label>
|
type="checkbox"
|
||||||
|
checked={publicAccessEnabled}
|
||||||
|
onChange={(event) => setPublicAccessEnabled(event.target.checked)}
|
||||||
|
/>
|
||||||
|
Öffentlicher Zugriff erlauben
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={registrationEnabled}
|
||||||
|
onChange={(event) => setRegistrationEnabled(event.target.checked)}
|
||||||
|
/>
|
||||||
|
Registrierung erlauben
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button type="submit" className="btn-accent">
|
<button type="submit" className="btn-accent">
|
||||||
Speichern
|
Speichern
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ type EventItem = {
|
|||||||
startAt: string;
|
startAt: string;
|
||||||
endAt?: string | null;
|
endAt?: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
category?: { id: string; name: string } | null;
|
publicOverride?: boolean | null;
|
||||||
|
category?: { id: string; name: string; isPublic?: boolean } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ViewItem = {
|
type ViewItem = {
|
||||||
@@ -35,11 +36,12 @@ type ViewItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type DateBucket = "past" | "today" | "tomorrow" | "future";
|
type DateBucket = "past" | "today" | "tomorrow" | "future";
|
||||||
|
type Pane = "calendar" | "list" | "map";
|
||||||
|
|
||||||
const MAP_FILTER_STORAGE_KEY = "mapFilters";
|
const MAP_FILTER_STORAGE_KEY = "mapFilters";
|
||||||
|
|
||||||
export default function CalendarBoard() {
|
export default function CalendarBoard() {
|
||||||
const { data } = useSession();
|
const { data, status } = useSession();
|
||||||
const [events, setEvents] = useState<EventItem[]>([]);
|
const [events, setEvents] = useState<EventItem[]>([]);
|
||||||
const [view, setView] = useState<ViewItem | null>(null);
|
const [view, setView] = useState<ViewItem | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -60,6 +62,7 @@ export default function CalendarBoard() {
|
|||||||
const [editError, setEditError] = useState<string | null>(null);
|
const [editError, setEditError] = useState<string | null>(null);
|
||||||
const [bulkSelection, setBulkSelection] = useState<Set<string>>(new Set());
|
const [bulkSelection, setBulkSelection] = useState<Set<string>>(new Set());
|
||||||
const [mapFullscreen, setMapFullscreen] = useState(false);
|
const [mapFullscreen, setMapFullscreen] = useState(false);
|
||||||
|
const [publicAccessEnabled, setPublicAccessEnabled] = useState<boolean | null>(null);
|
||||||
const [isDarkTheme, setIsDarkTheme] = useState(false);
|
const [isDarkTheme, setIsDarkTheme] = useState(false);
|
||||||
const [mapDateFilter, setMapDateFilter] = useState<Set<DateBucket>>(
|
const [mapDateFilter, setMapDateFilter] = useState<Set<DateBucket>>(
|
||||||
new Set(["past", "today", "tomorrow", "future"])
|
new Set(["past", "today", "tomorrow", "future"])
|
||||||
@@ -69,14 +72,12 @@ export default function CalendarBoard() {
|
|||||||
);
|
);
|
||||||
const [formOpen, setFormOpen] = useState(false);
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
const [prefillStartAt, setPrefillStartAt] = useState<string | null>(null);
|
const [prefillStartAt, setPrefillStartAt] = useState<string | null>(null);
|
||||||
const [viewOrder, setViewOrder] = useState<
|
const [viewOrder, setViewOrder] = useState<Array<Pane>>([
|
||||||
Array<"calendar" | "list" | "map">
|
"list",
|
||||||
>(["calendar", "map", "list"]);
|
"map",
|
||||||
const [collapsed, setCollapsed] = useState<{
|
"calendar"
|
||||||
calendar: boolean;
|
]);
|
||||||
list: boolean;
|
const [collapsed, setCollapsed] = useState<Record<Pane, boolean>>({
|
||||||
map: boolean;
|
|
||||||
}>({
|
|
||||||
calendar: false,
|
calendar: false,
|
||||||
list: false,
|
list: false,
|
||||||
map: false
|
map: false
|
||||||
@@ -85,14 +86,21 @@ export default function CalendarBoard() {
|
|||||||
start: Date;
|
start: Date;
|
||||||
end: Date;
|
end: Date;
|
||||||
viewType: string;
|
viewType: string;
|
||||||
} | null>(null);
|
} | null>(() => {
|
||||||
const [initialView, setInitialView] = useState("dayGridMonth");
|
const now = new Date();
|
||||||
const [dragSource, setDragSource] = useState<
|
return {
|
||||||
"calendar" | "list" | "map" | null
|
start: new Date(now.getFullYear(), 0, 1),
|
||||||
>(null);
|
end: new Date(now.getFullYear() + 1, 0, 1),
|
||||||
const [dragOver, setDragOver] = useState<
|
viewType: "dayGridYear"
|
||||||
"calendar" | "list" | "map" | null
|
};
|
||||||
>(null);
|
});
|
||||||
|
const [initialView, setInitialView] = useState("dayGridYear");
|
||||||
|
const [dragSource, setDragSource] = useState<Pane | null>(null);
|
||||||
|
const [dragOver, setDragOver] = useState<Pane | null>(null);
|
||||||
|
const [isPointerDragging, setIsPointerDragging] = useState(false);
|
||||||
|
const pointerDragRef = useRef<{ pointerId: number; source: Pane } | null>(null);
|
||||||
|
const dragOverRef = useRef<Pane | null>(null);
|
||||||
|
const dragHandleRef = useRef<HTMLDivElement | null>(null);
|
||||||
const calendarRef = useRef<HTMLDivElement | null>(null);
|
const calendarRef = useRef<HTMLDivElement | null>(null);
|
||||||
const listRef = useRef<HTMLDivElement | null>(null);
|
const listRef = useRef<HTMLDivElement | null>(null);
|
||||||
const mapRef = useRef<HTMLDivElement | null>(null);
|
const mapRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -101,22 +109,27 @@ export default function CalendarBoard() {
|
|||||||
const lastActiveElementRef = useRef<HTMLElement | null>(null);
|
const lastActiveElementRef = useRef<HTMLElement | null>(null);
|
||||||
const isAdmin =
|
const isAdmin =
|
||||||
data?.user?.role === "ADMIN" || data?.user?.role === "SUPERADMIN";
|
data?.user?.role === "ADMIN" || data?.user?.role === "SUPERADMIN";
|
||||||
|
const canManageView = Boolean(view && data?.user);
|
||||||
|
const showPublicControls = publicAccessEnabled === true;
|
||||||
|
|
||||||
const fetchEvents = async () => {
|
const fetchEvents = async (includeView: boolean) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const [eventsResponse, viewResponse] = await Promise.all([
|
const requests = [fetch("/api/events")];
|
||||||
fetch("/api/events"),
|
if (includeView) {
|
||||||
fetch("/api/views/default")
|
requests.push(fetch("/api/views/default"));
|
||||||
]);
|
}
|
||||||
|
const [eventsResponse, viewResponse] = await Promise.all(requests);
|
||||||
if (!eventsResponse.ok) {
|
if (!eventsResponse.ok) {
|
||||||
throw new Error("Events konnten nicht geladen werden.");
|
throw new Error("Events konnten nicht geladen werden.");
|
||||||
}
|
}
|
||||||
const payload = await eventsResponse.json();
|
const payload = await eventsResponse.json();
|
||||||
setEvents(payload);
|
setEvents(payload);
|
||||||
if (viewResponse.ok) {
|
if (includeView && viewResponse?.ok) {
|
||||||
setView(await viewResponse.json());
|
setView(await viewResponse.json());
|
||||||
|
} else {
|
||||||
|
setView(null);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
@@ -126,10 +139,9 @@ export default function CalendarBoard() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.user) {
|
if (status === "loading") return;
|
||||||
fetchEvents();
|
fetchEvents(Boolean(data?.user));
|
||||||
}
|
}, [data?.user, status]);
|
||||||
}, [data?.user]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadKey = async () => {
|
const loadKey = async () => {
|
||||||
@@ -139,6 +151,7 @@ export default function CalendarBoard() {
|
|||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
setPlacesKey(payload.apiKey || "");
|
setPlacesKey(payload.apiKey || "");
|
||||||
setPlacesProvider(payload.provider === "google" ? "google" : "osm");
|
setPlacesProvider(payload.provider === "google" ? "google" : "osm");
|
||||||
|
setPublicAccessEnabled(payload.publicAccessEnabled !== false);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -166,22 +179,11 @@ export default function CalendarBoard() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = () => {
|
const handler = () => {
|
||||||
fetchEvents();
|
fetchEvents(Boolean(data?.user));
|
||||||
};
|
};
|
||||||
window.addEventListener("views-updated", handler);
|
window.addEventListener("views-updated", handler);
|
||||||
return () => window.removeEventListener("views-updated", handler);
|
return () => window.removeEventListener("views-updated", handler);
|
||||||
}, []);
|
}, [data?.user]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
const stored = window.localStorage.getItem("calendarLastView");
|
|
||||||
if (stored) {
|
|
||||||
setInitialView(stored);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
@@ -331,7 +333,7 @@ export default function CalendarBoard() {
|
|||||||
["calendar", "list", "map"].includes(String(item))
|
["calendar", "list", "map"].includes(String(item))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
] as Array<"calendar" | "list" | "map">;
|
] as Array<Pane>;
|
||||||
if (normalized.includes("calendar") && normalized.includes("list")) {
|
if (normalized.includes("calendar") && normalized.includes("list")) {
|
||||||
if (!normalized.includes("map")) {
|
if (!normalized.includes("map")) {
|
||||||
normalized.splice(1, 0, "map");
|
normalized.splice(1, 0, "map");
|
||||||
@@ -366,7 +368,7 @@ export default function CalendarBoard() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const persistOrder = (nextOrder: Array<"calendar" | "list" | "map">) => {
|
const persistOrder = (nextOrder: Array<Pane>) => {
|
||||||
setViewOrder(nextOrder);
|
setViewOrder(nextOrder);
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem("calendarViewOrder", JSON.stringify(nextOrder));
|
window.localStorage.setItem("calendarViewOrder", JSON.stringify(nextOrder));
|
||||||
@@ -375,7 +377,7 @@ export default function CalendarBoard() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleCollapse = (section: "calendar" | "list" | "map") => {
|
const toggleCollapse = (section: Pane) => {
|
||||||
const next = { ...collapsed, [section]: !collapsed[section] };
|
const next = { ...collapsed, [section]: !collapsed[section] };
|
||||||
setCollapsed(next);
|
setCollapsed(next);
|
||||||
try {
|
try {
|
||||||
@@ -385,10 +387,7 @@ export default function CalendarBoard() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const swapOrder = (
|
const swapOrder = (source: Pane, target: Pane) => {
|
||||||
source: "calendar" | "list" | "map",
|
|
||||||
target: "calendar" | "list" | "map"
|
|
||||||
) => {
|
|
||||||
if (source === target) return;
|
if (source === target) return;
|
||||||
const next = [...viewOrder];
|
const next = [...viewOrder];
|
||||||
const sourceIndex = next.indexOf(source);
|
const sourceIndex = next.indexOf(source);
|
||||||
@@ -401,7 +400,7 @@ export default function CalendarBoard() {
|
|||||||
|
|
||||||
const onDragStart = (
|
const onDragStart = (
|
||||||
event: React.DragEvent<HTMLDivElement>,
|
event: React.DragEvent<HTMLDivElement>,
|
||||||
section: "calendar" | "list" | "map"
|
section: Pane
|
||||||
) => {
|
) => {
|
||||||
event.dataTransfer.setData("text/plain", section);
|
event.dataTransfer.setData("text/plain", section);
|
||||||
event.dataTransfer.effectAllowed = "move";
|
event.dataTransfer.effectAllowed = "move";
|
||||||
@@ -418,7 +417,7 @@ export default function CalendarBoard() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDragEnter = (target: "calendar" | "list" | "map") => {
|
const onDragEnter = (target: Pane) => {
|
||||||
setDragOver(target);
|
setDragOver(target);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -427,10 +426,7 @@ export default function CalendarBoard() {
|
|||||||
setDragOver(null);
|
setDragOver(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDrop = (
|
const onDrop = (event: React.DragEvent<HTMLDivElement>, target: Pane) => {
|
||||||
event: React.DragEvent<HTMLDivElement>,
|
|
||||||
target: "calendar" | "list" | "map"
|
|
||||||
) => {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const source = event.dataTransfer.getData("text/plain") as
|
const source = event.dataTransfer.getData("text/plain") as
|
||||||
| "calendar"
|
| "calendar"
|
||||||
@@ -443,6 +439,83 @@ export default function CalendarBoard() {
|
|||||||
setDragOver(null);
|
setDragOver(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getPaneFromPoint = (x: number, y: number) => {
|
||||||
|
if (typeof document === "undefined") return null;
|
||||||
|
const element = document.elementFromPoint(x, y);
|
||||||
|
const card = element?.closest<HTMLElement>("[data-pane]");
|
||||||
|
const pane = card?.dataset.pane as Pane | undefined;
|
||||||
|
if (pane === "calendar" || pane === "list" || pane === "map") {
|
||||||
|
return pane;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishPointerDrag = () => {
|
||||||
|
const current = pointerDragRef.current;
|
||||||
|
const handle = dragHandleRef.current;
|
||||||
|
if (current && handle) {
|
||||||
|
try {
|
||||||
|
handle.releasePointerCapture(current.pointerId);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pointerDragRef.current = null;
|
||||||
|
dragOverRef.current = null;
|
||||||
|
dragHandleRef.current = null;
|
||||||
|
setDragSource(null);
|
||||||
|
setDragOver(null);
|
||||||
|
setIsPointerDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDragStart = (
|
||||||
|
event: React.PointerEvent<HTMLDivElement>,
|
||||||
|
section: Pane
|
||||||
|
) => {
|
||||||
|
if (event.pointerType === "mouse") return;
|
||||||
|
event.preventDefault();
|
||||||
|
pointerDragRef.current = { pointerId: event.pointerId, source: section };
|
||||||
|
dragOverRef.current = section;
|
||||||
|
dragHandleRef.current = event.currentTarget;
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
setDragSource(section);
|
||||||
|
setDragOver(section);
|
||||||
|
setIsPointerDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPointerDragging) return;
|
||||||
|
const handleMove = (event: PointerEvent) => {
|
||||||
|
const current = pointerDragRef.current;
|
||||||
|
if (!current || event.pointerId !== current.pointerId) return;
|
||||||
|
if (event.cancelable) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
const target = getPaneFromPoint(event.clientX, event.clientY);
|
||||||
|
if (target !== dragOverRef.current) {
|
||||||
|
dragOverRef.current = target;
|
||||||
|
setDragOver(target);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleUp = (event: PointerEvent) => {
|
||||||
|
const current = pointerDragRef.current;
|
||||||
|
if (!current || event.pointerId !== current.pointerId) return;
|
||||||
|
const target = dragOverRef.current;
|
||||||
|
if (target && target !== current.source) {
|
||||||
|
swapOrder(current.source, target);
|
||||||
|
}
|
||||||
|
finishPointerDrag();
|
||||||
|
};
|
||||||
|
window.addEventListener("pointermove", handleMove);
|
||||||
|
window.addEventListener("pointerup", handleUp);
|
||||||
|
window.addEventListener("pointercancel", handleUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointermove", handleMove);
|
||||||
|
window.removeEventListener("pointerup", handleUp);
|
||||||
|
window.removeEventListener("pointercancel", handleUp);
|
||||||
|
};
|
||||||
|
}, [isPointerDragging, swapOrder]);
|
||||||
|
|
||||||
const openFormForDate = (date: Date | null) => {
|
const openFormForDate = (date: Date | null) => {
|
||||||
if (date) {
|
if (date) {
|
||||||
const prefill = new Date(date);
|
const prefill = new Date(date);
|
||||||
@@ -499,7 +572,7 @@ export default function CalendarBoard() {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ eventId })
|
body: JSON.stringify({ eventId })
|
||||||
});
|
});
|
||||||
await fetchEvents();
|
await fetchEvents(Boolean(data?.user));
|
||||||
window.dispatchEvent(new Event("views-updated"));
|
window.dispatchEvent(new Event("views-updated"));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -519,6 +592,14 @@ export default function CalendarBoard() {
|
|||||||
return date.toISOString();
|
return date.toISOString();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parsePublicOverride = (value: FormDataEntryValue | null) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const raw = String(value);
|
||||||
|
if (raw === "public") return true;
|
||||||
|
if (raw === "private") return false;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const updateEvent = async (event: React.FormEvent<HTMLFormElement>) => {
|
const updateEvent = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setEditStatus(null);
|
setEditStatus(null);
|
||||||
@@ -526,7 +607,7 @@ export default function CalendarBoard() {
|
|||||||
if (!editEvent) return;
|
if (!editEvent) return;
|
||||||
|
|
||||||
const formData = new FormData(event.currentTarget);
|
const formData = new FormData(event.currentTarget);
|
||||||
const payload = {
|
const payload: Record<string, unknown> = {
|
||||||
title: formData.get("title"),
|
title: formData.get("title"),
|
||||||
description: formData.get("description"),
|
description: formData.get("description"),
|
||||||
location: formData.get("location"),
|
location: formData.get("location"),
|
||||||
@@ -537,6 +618,11 @@ export default function CalendarBoard() {
|
|||||||
endAt: toIsoString(formData.get("endAt")),
|
endAt: toIsoString(formData.get("endAt")),
|
||||||
categoryId: formData.get("categoryId")
|
categoryId: formData.get("categoryId")
|
||||||
};
|
};
|
||||||
|
if (showPublicControls) {
|
||||||
|
payload.publicOverride = parsePublicOverride(
|
||||||
|
formData.get("publicOverride")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`/api/events/${editEvent.id}`, {
|
const response = await fetch(`/api/events/${editEvent.id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -553,7 +639,7 @@ export default function CalendarBoard() {
|
|||||||
setEditStatus("Termin aktualisiert.");
|
setEditStatus("Termin aktualisiert.");
|
||||||
setIsEditOpen(false);
|
setIsEditOpen(false);
|
||||||
setEditEvent(null);
|
setEditEvent(null);
|
||||||
await fetchEvents();
|
await fetchEvents(Boolean(data?.user));
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteEvent = async (eventId: string) => {
|
const deleteEvent = async (eventId: string) => {
|
||||||
@@ -568,7 +654,7 @@ export default function CalendarBoard() {
|
|||||||
setEditError(null);
|
setEditError(null);
|
||||||
setIsEditOpen(false);
|
setIsEditOpen(false);
|
||||||
setEditEvent(null);
|
setEditEvent(null);
|
||||||
await fetchEvents();
|
await fetchEvents(Boolean(data?.user));
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleBulkSelection = (eventId: string) => {
|
const toggleBulkSelection = (eventId: string) => {
|
||||||
@@ -603,7 +689,7 @@ export default function CalendarBoard() {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
setBulkSelection(new Set());
|
setBulkSelection(new Set());
|
||||||
await fetchEvents();
|
await fetchEvents(Boolean(data?.user));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -787,52 +873,68 @@ export default function CalendarBoard() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!data?.user) {
|
|
||||||
return (
|
|
||||||
<div className="card-muted text-center">
|
|
||||||
<p className="text-slate-700">
|
|
||||||
Bitte anmelden, um die Vereinskalender zu sehen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4 fade-up">
|
<section className="space-y-4 fade-up">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">Kalender</p>
|
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">Kalender</p>
|
||||||
<h2 className="text-2xl font-semibold">Termine im Blick</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{data?.user && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-accent inline-flex items-center gap-2"
|
||||||
|
onClick={() => openFormForDate(null)}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
Termin
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-accent"
|
onClick={() => fetchEvents(Boolean(data?.user))}
|
||||||
onClick={() => openFormForDate(null)}
|
className="btn-ghost p-2"
|
||||||
|
aria-label="Aktualisieren"
|
||||||
|
title="Aktualisieren"
|
||||||
>
|
>
|
||||||
Neuer Termin
|
<svg
|
||||||
</button>
|
viewBox="0 0 24 24"
|
||||||
<button
|
className="h-4 w-4"
|
||||||
type="button"
|
fill="none"
|
||||||
onClick={fetchEvents}
|
stroke="currentColor"
|
||||||
className="btn-ghost"
|
strokeWidth="2"
|
||||||
>
|
>
|
||||||
Aktualisieren
|
<path
|
||||||
|
d="M20 12a8 8 0 1 1-2.34-5.66"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path d="M20 4v6h-6" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<EventForm
|
{data?.user && (
|
||||||
variant="inline"
|
<EventForm
|
||||||
showTrigger={false}
|
variant="inline"
|
||||||
open={formOpen}
|
showTrigger={false}
|
||||||
onOpenChange={(open) => {
|
open={formOpen}
|
||||||
setFormOpen(open);
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
setFormOpen(open);
|
||||||
setPrefillStartAt(null);
|
if (!open) {
|
||||||
}
|
setPrefillStartAt(null);
|
||||||
}}
|
}
|
||||||
prefillStartAt={prefillStartAt}
|
}}
|
||||||
/>
|
prefillStartAt={prefillStartAt}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
{loading && <p className="text-sm text-slate-500">Lade Termine...</p>}
|
{loading && <p className="text-sm text-slate-500">Lade Termine...</p>}
|
||||||
{viewOrder.map((section) => {
|
{viewOrder.map((section) => {
|
||||||
@@ -841,7 +943,8 @@ export default function CalendarBoard() {
|
|||||||
<div
|
<div
|
||||||
key="calendar"
|
key="calendar"
|
||||||
ref={calendarRef}
|
ref={calendarRef}
|
||||||
className={`card drag-card ${
|
data-pane="calendar"
|
||||||
|
className={`card drag-card calendar-pane ${
|
||||||
dragSource === "calendar" ? "dragging" : ""
|
dragSource === "calendar" ? "dragging" : ""
|
||||||
} ${
|
} ${
|
||||||
dragOver === "calendar" && dragSource && dragSource !== "calendar"
|
dragOver === "calendar" && dragSource && dragSource !== "calendar"
|
||||||
@@ -871,6 +974,7 @@ export default function CalendarBoard() {
|
|||||||
draggable
|
draggable
|
||||||
onDragStart={(event) => onDragStart(event, "calendar")}
|
onDragStart={(event) => onDragStart(event, "calendar")}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
|
onPointerDown={(event) => onPointerDragStart(event, "calendar")}
|
||||||
title="Zum Tauschen ziehen"
|
title="Zum Tauschen ziehen"
|
||||||
aria-label="Kalender verschieben"
|
aria-label="Kalender verschieben"
|
||||||
>
|
>
|
||||||
@@ -909,18 +1013,20 @@ export default function CalendarBoard() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span>{arg.dayNumberText}</span>
|
<span>{arg.dayNumberText}</span>
|
||||||
<button
|
{data?.user && (
|
||||||
type="button"
|
<button
|
||||||
className="rounded-full border border-slate-200 bg-white/80 px-2 py-0.5 text-xs text-slate-600 hover:bg-slate-100"
|
type="button"
|
||||||
aria-label={`Termin am ${arg.date.toLocaleDateString("de-DE")} anlegen`}
|
className="rounded-full border border-slate-200 bg-white/80 px-2 py-0.5 text-xs text-slate-600 hover:bg-slate-100"
|
||||||
onClick={(event) => {
|
aria-label={`Termin am ${arg.date.toLocaleDateString("de-DE")} anlegen`}
|
||||||
event.preventDefault();
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.preventDefault();
|
||||||
openFormForDate(arg.date);
|
event.stopPropagation();
|
||||||
}}
|
openFormForDate(arg.date);
|
||||||
>
|
}}
|
||||||
+
|
>
|
||||||
</button>
|
+
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -941,15 +1047,17 @@ export default function CalendarBoard() {
|
|||||||
{arg.event.extendedProps.category || "Ohne Kategorie"}
|
{arg.event.extendedProps.category || "Ohne Kategorie"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ViewToggleButton
|
{canManageView && (
|
||||||
isSelected={isSelected}
|
<ViewToggleButton
|
||||||
onClick={(event) => {
|
isSelected={isSelected}
|
||||||
event.preventDefault();
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.preventDefault();
|
||||||
toggleEvent(arg.event.id);
|
event.stopPropagation();
|
||||||
}}
|
toggleEvent(arg.event.id);
|
||||||
size="sm"
|
}}
|
||||||
/>
|
size="sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -960,11 +1068,6 @@ export default function CalendarBoard() {
|
|||||||
end: arg.end,
|
end: arg.end,
|
||||||
viewType: arg.view.type
|
viewType: arg.view.type
|
||||||
});
|
});
|
||||||
try {
|
|
||||||
window.localStorage.setItem("calendarLastView", arg.view.type);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -977,6 +1080,7 @@ export default function CalendarBoard() {
|
|||||||
<div
|
<div
|
||||||
key="map"
|
key="map"
|
||||||
ref={mapRef}
|
ref={mapRef}
|
||||||
|
data-pane="map"
|
||||||
className={`card drag-card ${
|
className={`card drag-card ${
|
||||||
dragSource === "map" ? "dragging" : ""
|
dragSource === "map" ? "dragging" : ""
|
||||||
} ${
|
} ${
|
||||||
@@ -1014,6 +1118,7 @@ export default function CalendarBoard() {
|
|||||||
draggable
|
draggable
|
||||||
onDragStart={(event) => onDragStart(event, "map")}
|
onDragStart={(event) => onDragStart(event, "map")}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
|
onPointerDown={(event) => onPointerDragStart(event, "map")}
|
||||||
title="Zum Tauschen ziehen"
|
title="Zum Tauschen ziehen"
|
||||||
aria-label="Karte verschieben"
|
aria-label="Karte verschieben"
|
||||||
>
|
>
|
||||||
@@ -1193,6 +1298,7 @@ export default function CalendarBoard() {
|
|||||||
<div
|
<div
|
||||||
key="list"
|
key="list"
|
||||||
ref={listRef}
|
ref={listRef}
|
||||||
|
data-pane="list"
|
||||||
className={`card space-y-4 drag-card ${
|
className={`card space-y-4 drag-card ${
|
||||||
dragSource === "list" ? "dragging" : ""
|
dragSource === "list" ? "dragging" : ""
|
||||||
} ${
|
} ${
|
||||||
@@ -1205,13 +1311,13 @@ export default function CalendarBoard() {
|
|||||||
onDrop={(event) => onDrop(event, "list")}
|
onDrop={(event) => onDrop(event, "list")}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold text-slate-700">Liste</h3>
|
<h3 className="text-sm font-semibold text-slate-700">Termine</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-full border border-slate-200 px-2 py-1 text-xs text-slate-600"
|
className="rounded-full border border-slate-200 px-2 py-1 text-xs text-slate-600"
|
||||||
onClick={() => toggleCollapse("list")}
|
onClick={() => toggleCollapse("list")}
|
||||||
aria-label={collapsed.list ? "Liste aufklappen" : "Liste zuklappen"}
|
aria-label={collapsed.list ? "Termine aufklappen" : "Termine zuklappen"}
|
||||||
title={collapsed.list ? "Aufklappen" : "Zuklappen"}
|
title={collapsed.list ? "Aufklappen" : "Zuklappen"}
|
||||||
>
|
>
|
||||||
{collapsed.list ? <IconChevronDown /> : <IconChevronUp />}
|
{collapsed.list ? <IconChevronDown /> : <IconChevronUp />}
|
||||||
@@ -1221,8 +1327,9 @@ export default function CalendarBoard() {
|
|||||||
draggable
|
draggable
|
||||||
onDragStart={(event) => onDragStart(event, "list")}
|
onDragStart={(event) => onDragStart(event, "list")}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
|
onPointerDown={(event) => onPointerDragStart(event, "list")}
|
||||||
title="Zum Tauschen ziehen"
|
title="Zum Tauschen ziehen"
|
||||||
aria-label="Liste verschieben"
|
aria-label="Termine verschieben"
|
||||||
>
|
>
|
||||||
<IconGrip />
|
<IconGrip />
|
||||||
</div>
|
</div>
|
||||||
@@ -1413,10 +1520,12 @@ export default function CalendarBoard() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-3 pr-3">
|
<td className="py-3 pr-3">
|
||||||
<div className="flex flex-nowrap gap-1">
|
<div className="flex flex-nowrap gap-1">
|
||||||
<ViewToggleButton
|
{canManageView && (
|
||||||
isSelected={selectedEventIds.has(event.id)}
|
<ViewToggleButton
|
||||||
onClick={() => toggleEvent(event.id)}
|
isSelected={selectedEventIds.has(event.id)}
|
||||||
/>
|
onClick={() => toggleEvent(event.id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1580,7 +1689,7 @@ export default function CalendarBoard() {
|
|||||||
Beschreibung
|
Beschreibung
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-slate-700">
|
<p className="text-sm text-slate-700">
|
||||||
{detailsEvent.description}
|
{renderLinkedText(detailsEvent.description)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1594,22 +1703,6 @@ export default function CalendarBoard() {
|
|||||||
>
|
>
|
||||||
Google Maps
|
Google Maps
|
||||||
</a>
|
</a>
|
||||||
<a
|
|
||||||
className="btn-ghost"
|
|
||||||
href={`https://maps.apple.com/?ll=${detailsEvent.locationLat},${detailsEvent.locationLng}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
Apple Karten
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="btn-ghost"
|
|
||||||
href={`https://www.openstreetmap.org/?mlat=${detailsEvent.locationLat}&mlon=${detailsEvent.locationLng}#map=17/${detailsEvent.locationLat}/${detailsEvent.locationLng}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
OpenStreetMap
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1738,6 +1831,45 @@ export default function CalendarBoard() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{showPublicControls && (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-3 text-sm text-slate-700 md:col-span-2">
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
||||||
|
Öffentlich
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-3">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="publicOverride"
|
||||||
|
value="inherit"
|
||||||
|
defaultChecked={
|
||||||
|
editEvent.publicOverride !== true &&
|
||||||
|
editEvent.publicOverride !== false
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Kategorie übernehmen
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="publicOverride"
|
||||||
|
value="public"
|
||||||
|
defaultChecked={editEvent.publicOverride === true}
|
||||||
|
/>
|
||||||
|
Öffentlich
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="publicOverride"
|
||||||
|
value="private"
|
||||||
|
defaultChecked={editEvent.publicOverride === false}
|
||||||
|
/>
|
||||||
|
Nicht öffentlich
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
name="description"
|
name="description"
|
||||||
@@ -1973,6 +2105,47 @@ function formatLocation(value?: string | null) {
|
|||||||
return main || value;
|
return main || value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderLinkedText(value: string) {
|
||||||
|
const regex = /\b(https?:\/\/[^\s<]+|www\.[^\s<]+)/gi;
|
||||||
|
const parts: Array<string | JSX.Element> = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = regex.exec(value)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
parts.push(value.slice(lastIndex, match.index));
|
||||||
|
}
|
||||||
|
let rawUrl = match[0];
|
||||||
|
let trailing = "";
|
||||||
|
while (/[),.!?]$/.test(rawUrl)) {
|
||||||
|
trailing = rawUrl.slice(-1) + trailing;
|
||||||
|
rawUrl = rawUrl.slice(0, -1);
|
||||||
|
}
|
||||||
|
const href = rawUrl.startsWith("http") ? rawUrl : `https://${rawUrl}`;
|
||||||
|
parts.push(
|
||||||
|
<a
|
||||||
|
key={`link-${match.index}`}
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-emerald-700 underline decoration-emerald-300 underline-offset-2"
|
||||||
|
>
|
||||||
|
{rawUrl}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
if (trailing) {
|
||||||
|
parts.push(trailing);
|
||||||
|
}
|
||||||
|
lastIndex = match.index + match[0].length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < value.length) {
|
||||||
|
parts.push(value.slice(lastIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts : value;
|
||||||
|
}
|
||||||
|
|
||||||
function MapAutoBounds({ points }: { points: Array<{ lat: number; lng: number }> }) {
|
function MapAutoBounds({ points }: { points: Array<{ lat: number; lng: number }> }) {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2026,8 +2199,9 @@ function ViewToggleButton({
|
|||||||
}) {
|
}) {
|
||||||
const base =
|
const base =
|
||||||
size === "sm"
|
size === "sm"
|
||||||
? "event-toggle rounded-full bg-white/80 px-2 py-1 text-[10px] text-slate-700"
|
? "event-toggle rounded-full bg-white/80 px-1.5 py-0.5 text-[9px] text-slate-700"
|
||||||
: "rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-700";
|
: "rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-700";
|
||||||
|
const iconClass = size === "sm" ? "h-[11px] w-[11px]" : "h-4 w-4";
|
||||||
const label = isSelected
|
const label = isSelected
|
||||||
? "Vom Kalenderfeed entfernen"
|
? "Vom Kalenderfeed entfernen"
|
||||||
: "Zum Kalenderfeed hinzufügen";
|
: "Zum Kalenderfeed hinzufügen";
|
||||||
@@ -2039,23 +2213,23 @@ function ViewToggleButton({
|
|||||||
aria-label={label}
|
aria-label={label}
|
||||||
title={label}
|
title={label}
|
||||||
>
|
>
|
||||||
{isSelected ? <IconBell /> : <IconSleep />}
|
{isSelected ? <IconBell className={iconClass} /> : <IconSleep className={iconClass} />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function IconBell() {
|
function IconBell({ className = "h-4 w-4" }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<path d="M6 8a6 6 0 1112 0c0 7 2 7 2 7H4s2 0 2-7" strokeLinecap="round" strokeLinejoin="round" />
|
<path d="M6 8a6 6 0 1112 0c0 7 2 7 2 7H4s2 0 2-7" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
<path d="M10 19a2 2 0 004 0" strokeLinecap="round" />
|
<path d="M10 19a2 2 0 004 0" strokeLinecap="round" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function IconSleep() {
|
function IconSleep({ className = "h-4 w-4" }: { className?: string }) {
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<path d="M3 5h6l-6 6h6" strokeLinecap="round" strokeLinejoin="round" />
|
<path d="M3 5h6l-6 6h6" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
<path d="M9 9h6l-6 6h6" strokeLinecap="round" strokeLinejoin="round" />
|
<path d="M9 9h6l-6 6h6" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
<path d="M15 13h6l-6 6h6" strokeLinecap="round" strokeLinejoin="round" />
|
<path d="M15 13h6l-6 6h6" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export default function NavBar() {
|
|||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const [appName, setAppName] = useState("Vereinskalender");
|
const [appName, setAppName] = useState("Vereinskalender");
|
||||||
|
const hideLoginPaths = new Set(["/login", "/register", "/reset"]);
|
||||||
|
const showLoginButton = !data?.user && !hideLoginPaths.has(pathname);
|
||||||
const linkClass = (href: string) =>
|
const linkClass = (href: string) =>
|
||||||
pathname === href
|
pathname === href
|
||||||
? "nav-link-active rounded-full px-3 py-1"
|
? "nav-link-active rounded-full px-3 py-1"
|
||||||
@@ -182,7 +184,7 @@ export default function NavBar() {
|
|||||||
</svg>
|
</svg>
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : showLoginButton ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => signIn()}
|
onClick={() => signIn()}
|
||||||
@@ -190,7 +192,7 @@ export default function NavBar() {
|
|||||||
>
|
>
|
||||||
Login
|
Login
|
||||||
</button>
|
</button>
|
||||||
)}
|
) : null}
|
||||||
</nav>
|
</nav>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -232,7 +234,7 @@ export default function NavBar() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={`relative z-20 overflow-hidden transition-all duration-300 md:hidden ${
|
className={`mobile-menu-panel relative z-20 overflow-hidden transition-all duration-300 md:hidden ${
|
||||||
mobileOpen ? "max-h-96 opacity-100" : "max-h-0 opacity-0"
|
mobileOpen ? "max-h-96 opacity-100" : "max-h-0 opacity-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -275,15 +277,15 @@ export default function NavBar() {
|
|||||||
</svg>
|
</svg>
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : showLoginButton ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => signIn()}
|
onClick={() => signIn()}
|
||||||
className="btn-accent w-full"
|
className="btn-accent w-full"
|
||||||
>
|
>
|
||||||
Login
|
Login
|
||||||
</button>
|
</button>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
107
lib/system-settings.ts
Normal file
107
lib/system-settings.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
|
export type AccessSettings = {
|
||||||
|
publicAccessEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SystemSettings = AccessSettings & {
|
||||||
|
apiKey: string;
|
||||||
|
provider: "google" | "osm";
|
||||||
|
registrationEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PUBLIC_ACCESS_KEY = "public_access_enabled";
|
||||||
|
const LEGACY_ACCESS_KEYS = [
|
||||||
|
"public_events_enabled",
|
||||||
|
"anonymous_access_enabled"
|
||||||
|
] as const;
|
||||||
|
const SYSTEM_KEYS = [
|
||||||
|
"google_places_api_key",
|
||||||
|
"geocoding_provider",
|
||||||
|
"registration_enabled"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const getSettingMap = async (keys: readonly string[]) => {
|
||||||
|
const records = await prisma.setting.findMany({
|
||||||
|
where: { key: { in: [...keys] } }
|
||||||
|
});
|
||||||
|
return new Map(records.map((record) => [record.key, record.value]));
|
||||||
|
};
|
||||||
|
|
||||||
|
const readBoolean = (value: string | undefined, fallback: boolean) => {
|
||||||
|
if (value === undefined) return fallback;
|
||||||
|
return value !== "false";
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensurePublicAccessSetting = async (
|
||||||
|
prefetched?: Map<string, string>
|
||||||
|
) => {
|
||||||
|
const existingValue = prefetched?.get(PUBLIC_ACCESS_KEY);
|
||||||
|
if (existingValue !== undefined) {
|
||||||
|
return existingValue !== "false";
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.setting.findUnique({
|
||||||
|
where: { key: PUBLIC_ACCESS_KEY }
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
return existing.value !== "false";
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacy = await prisma.setting.findMany({
|
||||||
|
where: { key: { in: [...LEGACY_ACCESS_KEYS] } }
|
||||||
|
});
|
||||||
|
const legacyMap = new Map(legacy.map((record) => [record.key, record.value]));
|
||||||
|
const publicEventsEnabled = readBoolean(
|
||||||
|
legacyMap.get("public_events_enabled"),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
const anonymousAccessEnabled = readBoolean(
|
||||||
|
legacyMap.get("anonymous_access_enabled"),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
const combined = publicEventsEnabled && anonymousAccessEnabled;
|
||||||
|
|
||||||
|
await prisma.setting.upsert({
|
||||||
|
where: { key: PUBLIC_ACCESS_KEY },
|
||||||
|
update: { value: combined ? "true" : "false" },
|
||||||
|
create: { key: PUBLIC_ACCESS_KEY, value: combined ? "true" : "false" }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (legacy.length > 0) {
|
||||||
|
await prisma.setting.deleteMany({
|
||||||
|
where: { key: { in: [...LEGACY_ACCESS_KEYS] } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return combined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getAccessSettings(): Promise<AccessSettings> {
|
||||||
|
const publicAccessEnabled = await ensurePublicAccessSetting();
|
||||||
|
return { publicAccessEnabled };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSystemSettings(): Promise<SystemSettings> {
|
||||||
|
const settings = await getSettingMap(SYSTEM_KEYS);
|
||||||
|
const apiKey = settings.get("google_places_api_key") || "";
|
||||||
|
const storedProvider = settings.get("geocoding_provider");
|
||||||
|
const provider =
|
||||||
|
storedProvider === "google" || storedProvider === "osm"
|
||||||
|
? storedProvider
|
||||||
|
: apiKey
|
||||||
|
? "google"
|
||||||
|
: "osm";
|
||||||
|
const registrationEnabled = readBoolean(
|
||||||
|
settings.get("registration_enabled"),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
const publicAccessEnabled = await ensurePublicAccessSetting(settings);
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiKey,
|
||||||
|
provider,
|
||||||
|
registrationEnabled,
|
||||||
|
publicAccessEnabled
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ model Event {
|
|||||||
startAt DateTime
|
startAt DateTime
|
||||||
endAt DateTime?
|
endAt DateTime?
|
||||||
status String @default("PENDING")
|
status String @default("PENDING")
|
||||||
|
publicOverride Boolean?
|
||||||
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])
|
||||||
@@ -46,6 +47,7 @@ model Event {
|
|||||||
model Category {
|
model Category {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String @unique
|
name String @unique
|
||||||
|
isPublic Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
events Event[]
|
events Event[]
|
||||||
viewSubscriptions UserViewCategory[]
|
viewSubscriptions UserViewCategory[]
|
||||||
|
|||||||
Reference in New Issue
Block a user