Aktueller Stand

This commit is contained in:
2026-01-16 23:02:36 +01:00
parent dcf45bac3d
commit b2b23268b2
14 changed files with 768 additions and 226 deletions

View File

@@ -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,12 +70,15 @@ 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 data: { name?: string; isPublic?: boolean } = {};
if (name !== undefined) {
const trimmed = String(name).trim(); const trimmed = String(name).trim();
if (!trimmed) { if (!trimmed) {
return NextResponse.json({ error: "Name erforderlich." }, { status: 400 }); return NextResponse.json({ error: "Name erforderlich." }, { status: 400 });
@@ -83,10 +88,24 @@ export async function PATCH(request: Request) {
if (existing && existing.id !== id) { if (existing && existing.id !== id) {
return NextResponse.json({ error: "Kategorie existiert bereits." }, { status: 409 }); return NextResponse.json({ error: "Kategorie existiert bereits." }, { status: 409 });
} }
data.name = trimmed;
}
const { publicAccessEnabled } = await getAccessSettings();
if (publicAccessEnabled && typeof isPublic === "boolean") {
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);

View File

@@ -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
} }
}); });

View File

@@ -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

View File

@@ -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
}); });
} }

View File

@@ -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;

View File

@@ -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>
{showVerifyLink && (
<p className="mt-2 text-sm text-slate-600"> <p className="mt-2 text-sm text-slate-600">
E-Mail nicht bestätigt?{" "} E-Mail nicht bestätigt?{" "}
<Link href="/verify" className="text-brand-700"> <Link href="/verify" className="text-brand-700">
Link erneut senden Link erneut senden
</Link> </Link>
</p> </p>
)}
</div> </div>
); );
} }

View File

@@ -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>
); );

View File

@@ -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">

View File

@@ -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"

View File

@@ -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,7 +231,20 @@ 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">
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
Zugriff
</p>
<div className="mt-3 space-y-3">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={publicAccessEnabled}
onChange={(event) => setPublicAccessEnabled(event.target.checked)}
/>
Öffentlicher Zugriff erlauben
</label>
<label className="flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
checked={registrationEnabled} checked={registrationEnabled}
@@ -232,6 +252,8 @@ export default function AdminSystemSettings() {
/> />
Registrierung erlauben Registrierung erlauben
</label> </label>
</div>
</div>
<button type="submit" className="btn-accent"> <button type="submit" className="btn-accent">
Speichern Speichern
</button> </button>

View File

@@ -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,40 +873,55 @@ 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 <button
type="button" type="button"
className="btn-accent" className="btn-accent inline-flex items-center gap-2"
onClick={() => openFormForDate(null)} onClick={() => openFormForDate(null)}
> >
Neuer Termin <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 <button
type="button" type="button"
onClick={fetchEvents} onClick={() => fetchEvents(Boolean(data?.user))}
className="btn-ghost" className="btn-ghost p-2"
aria-label="Aktualisieren"
title="Aktualisieren"
> >
Aktualisieren <svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<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>
{data?.user && (
<EventForm <EventForm
variant="inline" variant="inline"
showTrigger={false} showTrigger={false}
@@ -833,6 +934,7 @@ export default function CalendarBoard() {
}} }}
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,6 +1013,7 @@ 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>
{data?.user && (
<button <button
type="button" type="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" className="rounded-full border border-slate-200 bg-white/80 px-2 py-0.5 text-xs text-slate-600 hover:bg-slate-100"
@@ -921,6 +1026,7 @@ export default function CalendarBoard() {
> >
+ +
</button> </button>
)}
</div> </div>
); );
}} }}
@@ -941,6 +1047,7 @@ export default function CalendarBoard() {
{arg.event.extendedProps.category || "Ohne Kategorie"} {arg.event.extendedProps.category || "Ohne Kategorie"}
</div> </div>
</div> </div>
{canManageView && (
<ViewToggleButton <ViewToggleButton
isSelected={isSelected} isSelected={isSelected}
onClick={(event) => { onClick={(event) => {
@@ -950,6 +1057,7 @@ export default function CalendarBoard() {
}} }}
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">
{canManageView && (
<ViewToggleButton <ViewToggleButton
isSelected={selectedEventIds.has(event.id)} isSelected={selectedEventIds.has(event.id)}
onClick={() => toggleEvent(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" />

View File

@@ -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,7 +277,7 @@ export default function NavBar() {
</svg> </svg>
Logout Logout
</button> </button>
) : ( ) : showLoginButton ? (
<button <button
type="button" type="button"
onClick={() => signIn()} onClick={() => signIn()}
@@ -283,7 +285,7 @@ export default function NavBar() {
> >
Login Login
</button> </button>
)} ) : null}
</div> </div>
</div> </div>
</header> </header>

107
lib/system-settings.ts Normal file
View 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
};
}

View File

@@ -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[]