Aktueller Stand
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { isAdminSession, requireSession } from "../../../lib/auth-helpers";
|
||||
import { getAccessSettings } from "../../../lib/system-settings";
|
||||
|
||||
export async function GET() {
|
||||
const { session } = await requireSession();
|
||||
@@ -26,7 +27,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { name } = body || {};
|
||||
const { name, isPublic } = body || {};
|
||||
|
||||
if (!name) {
|
||||
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 });
|
||||
}
|
||||
|
||||
const { publicAccessEnabled } = await getAccessSettings();
|
||||
const category = await prisma.category.create({
|
||||
data: { name }
|
||||
data: { name, isPublic: publicAccessEnabled && isPublic === true }
|
||||
});
|
||||
|
||||
const views = await prisma.userView.findMany({
|
||||
@@ -68,12 +70,15 @@ export async function PATCH(request: Request) {
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { id, name } = body || {};
|
||||
const { id, name, isPublic } = body || {};
|
||||
|
||||
if (!id || !name) {
|
||||
return NextResponse.json({ error: "ID und Name erforderlich." }, { status: 400 });
|
||||
if (!id) {
|
||||
return NextResponse.json({ error: "ID erforderlich." }, { status: 400 });
|
||||
}
|
||||
|
||||
const data: { name?: string; isPublic?: boolean } = {};
|
||||
|
||||
if (name !== undefined) {
|
||||
const trimmed = String(name).trim();
|
||||
if (!trimmed) {
|
||||
return NextResponse.json({ error: "Name erforderlich." }, { status: 400 });
|
||||
@@ -83,10 +88,24 @@ export async function PATCH(request: Request) {
|
||||
if (existing && existing.id !== id) {
|
||||
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({
|
||||
where: { id },
|
||||
data: { name: trimmed }
|
||||
data
|
||||
});
|
||||
|
||||
return NextResponse.json(category);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { isAdminSession, requireSession } from "../../../../lib/auth-helpers";
|
||||
import { getAccessSettings } from "../../../../lib/system-settings";
|
||||
|
||||
export async function PATCH(request: Request, context: { params: { id: string } }) {
|
||||
const { session } = await requireSession();
|
||||
@@ -23,7 +24,8 @@ export async function PATCH(request: Request, context: { params: { id: string }
|
||||
locationLng,
|
||||
startAt,
|
||||
endAt,
|
||||
categoryId
|
||||
categoryId,
|
||||
publicOverride
|
||||
} = body || {};
|
||||
|
||||
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 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({
|
||||
where: { id: context.params.id },
|
||||
@@ -56,7 +65,8 @@ export async function PATCH(request: Request, context: { params: { id: string }
|
||||
locationLng: locationLng ? Number(locationLng) : null,
|
||||
startAt: startDate,
|
||||
endAt: endDate,
|
||||
category: { connect: { id: categoryId } }
|
||||
category: { connect: { id: categoryId } },
|
||||
publicOverride: overrideValue
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,16 +1,66 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { prisma } from "../../../lib/prisma";
|
||||
import { isAdminSession, requireSession } from "../../../lib/auth-helpers";
|
||||
import { authOptions } from "../../../lib/auth";
|
||||
import { getAccessSettings } from "../../../lib/system-settings";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
const session = await getServerSession(authOptions);
|
||||
if (session?.user?.status && session.user.status !== "ACTIVE") {
|
||||
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 status = searchParams.get("status");
|
||||
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
|
||||
? status
|
||||
|
||||
@@ -1,29 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "../../../../lib/prisma";
|
||||
import { isSuperAdminSession, requireSession } from "../../../../lib/auth-helpers";
|
||||
import { getSystemSettings } from "../../../../lib/system-settings";
|
||||
|
||||
export async function GET() {
|
||||
const { session } = await requireSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const apiKeySetting = await prisma.setting.findUnique({
|
||||
where: { key: "google_places_api_key" }
|
||||
});
|
||||
const providerSetting = await prisma.setting.findUnique({
|
||||
where: { key: "geocoding_provider" }
|
||||
});
|
||||
const registrationSetting = await prisma.setting.findUnique({
|
||||
where: { key: "registration_enabled" }
|
||||
});
|
||||
|
||||
const apiKey = apiKeySetting?.value || "";
|
||||
const provider =
|
||||
providerSetting?.value || (apiKey ? "google" : "osm");
|
||||
const registrationEnabled = registrationSetting?.value !== "false";
|
||||
|
||||
return NextResponse.json({ apiKey, provider, registrationEnabled });
|
||||
const settings = await getSystemSettings();
|
||||
return NextResponse.json(settings);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@@ -37,7 +23,12 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { apiKey, provider, registrationEnabled } = body || {};
|
||||
const {
|
||||
apiKey,
|
||||
provider,
|
||||
registrationEnabled,
|
||||
publicAccessEnabled
|
||||
} = body || {};
|
||||
|
||||
if (!provider || !["google", "osm"].includes(provider)) {
|
||||
return NextResponse.json({ error: "Provider erforderlich." }, { status: 400 });
|
||||
@@ -68,9 +59,26 @@ export async function POST(request: Request) {
|
||||
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({
|
||||
apiKey: apiKeySetting.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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
border-color: rgba(71, 85, 105, 0.5);
|
||||
@@ -387,6 +399,9 @@ html[data-theme="dark"] .drag-handle:hover {
|
||||
color: #475569;
|
||||
background: #ffffff;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -436,6 +451,10 @@ html[data-theme="dark"] .drag-handle:hover {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.calendar-pane {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fc .fc-toolbar {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
@@ -8,10 +8,12 @@ import { useState } from "react";
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showVerifyLink, setShowVerifyLink] = useState(false);
|
||||
|
||||
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setShowVerifyLink(false);
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const email = formData.get("email") as string;
|
||||
const password = formData.get("password") as string;
|
||||
@@ -29,6 +31,7 @@ export default function LoginPage() {
|
||||
}
|
||||
if (result.error === "EMAIL_NOT_VERIFIED") {
|
||||
setError("Bitte bestätige zuerst deine E-Mail.");
|
||||
setShowVerifyLink(true);
|
||||
return;
|
||||
}
|
||||
if (result.error === "LOCKED") {
|
||||
@@ -44,6 +47,7 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
if (result?.ok) {
|
||||
setShowVerifyLink(false);
|
||||
router.push("/");
|
||||
}
|
||||
};
|
||||
@@ -83,12 +87,14 @@ export default function LoginPage() {
|
||||
Zurücksetzen
|
||||
</Link>
|
||||
</p>
|
||||
{showVerifyLink && (
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
E-Mail nicht bestätigt?{" "}
|
||||
<Link href="/verify" className="text-brand-700">
|
||||
Link erneut senden
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
14
app/page.tsx
14
app/page.tsx
@@ -2,21 +2,27 @@ import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import CalendarBoard from "../components/CalendarBoard";
|
||||
import { authOptions } from "../lib/auth";
|
||||
import { getAccessSettings } from "../lib/system-settings";
|
||||
|
||||
export default async function HomePage() {
|
||||
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 (
|
||||
<div className="space-y-8">
|
||||
{session?.user?.status === "ACTIVE" ? (
|
||||
<CalendarBoard />
|
||||
) : session?.user ? (
|
||||
{isBlocked ? (
|
||||
<div className="card-muted text-center">
|
||||
<p className="text-slate-700">
|
||||
Dein Konto wartet auf Freischaltung durch einen Admin.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
redirect("/login")
|
||||
<CalendarBoard />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -220,6 +220,16 @@ export default function SettingsPage() {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<section className="card space-y-4">
|
||||
|
||||
@@ -14,23 +14,28 @@ type EventItem = {
|
||||
locationLat?: number | null;
|
||||
locationLng?: number | 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;
|
||||
};
|
||||
|
||||
type CategoryItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
isPublic: boolean;
|
||||
};
|
||||
|
||||
export default function AdminPanel() {
|
||||
const [events, setEvents] = useState<EventItem[]>([]);
|
||||
const [allEvents, setAllEvents] = useState<EventItem[]>([]);
|
||||
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 [categoryStatus, setCategoryStatus] = useState<string | null>(null);
|
||||
const [categoryModalOpen, setCategoryModalOpen] = useState(false);
|
||||
const [categoryModalError, setCategoryModalError] = useState<string | null>(null);
|
||||
const [categoryModalStatus, setCategoryModalStatus] = useState<string | null>(null);
|
||||
const [editingCategory, setEditingCategory] = useState<{ id: string; name: string } | null>(null);
|
||||
const [editingCategory, setEditingCategory] = useState<CategoryItem | null>(null);
|
||||
const [editEvent, setEditEvent] = useState<EventItem | null>(null);
|
||||
const [editStatus, setEditStatus] = useState<string | null>(null);
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
@@ -45,6 +50,7 @@ export default function AdminPanel() {
|
||||
const [importCategoryId, setImportCategoryId] = useState("");
|
||||
const [importStatus, setImportStatus] = useState<string | null>(null);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [publicAccessEnabled, setPublicAccessEnabled] = useState<boolean | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
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(() => {
|
||||
load();
|
||||
loadCategories();
|
||||
loadAllEvents();
|
||||
loadSystemSettings();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -97,6 +115,7 @@ export default function AdminPanel() {
|
||||
}, [pageSize]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(allEvents.length / pageSize));
|
||||
const showPublicControls = publicAccessEnabled === true;
|
||||
|
||||
const sortedEvents = [...allEvents].sort((a, b) => {
|
||||
const dir = sortDir === "asc" ? 1 : -1;
|
||||
@@ -160,6 +179,14 @@ export default function AdminPanel() {
|
||||
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>) => {
|
||||
event.preventDefault();
|
||||
setEditStatus(null);
|
||||
@@ -167,7 +194,7 @@ export default function AdminPanel() {
|
||||
|
||||
if (!editEvent) return;
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const payload = {
|
||||
const payload: Record<string, unknown> = {
|
||||
title: formData.get("title"),
|
||||
description: formData.get("description"),
|
||||
location: formData.get("location"),
|
||||
@@ -178,6 +205,11 @@ export default function AdminPanel() {
|
||||
endAt: toIsoString(formData.get("endAt")),
|
||||
categoryId: formData.get("categoryId")
|
||||
};
|
||||
if (showPublicControls) {
|
||||
payload.publicOverride = parsePublicOverride(
|
||||
formData.get("publicOverride")
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/events/${editEvent.id}`, {
|
||||
method: "PATCH",
|
||||
@@ -205,15 +237,21 @@ export default function AdminPanel() {
|
||||
setCategoryStatus(null);
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const rawName = String(formData.get("name") || "").trim();
|
||||
const isPublic = formData.get("isPublic") === "on";
|
||||
if (!rawName) {
|
||||
setCategoryError("Name erforderlich.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = { name: rawName };
|
||||
if (showPublicControls) {
|
||||
payload.isPublic = isPublic;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/categories", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: rawName })
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -231,7 +269,7 @@ export default function AdminPanel() {
|
||||
setCategoryStatus("Kategorie angelegt.");
|
||||
};
|
||||
|
||||
const openCategoryModal = (category: { id: string; name: string }) => {
|
||||
const openCategoryModal = (category: CategoryItem) => {
|
||||
setEditingCategory(category);
|
||||
setCategoryModalError(null);
|
||||
setCategoryModalStatus(null);
|
||||
@@ -251,15 +289,24 @@ export default function AdminPanel() {
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const name = String(formData.get("name") || "").trim();
|
||||
const isPublic = formData.get("isPublic") === "on";
|
||||
if (!name) {
|
||||
setCategoryModalError("Name erforderlich.");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
id: editingCategory.id,
|
||||
name
|
||||
};
|
||||
if (showPublicControls) {
|
||||
payload.isPublic = isPublic;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/categories", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: editingCategory.id, name })
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -411,6 +458,12 @@ export default function AdminPanel() {
|
||||
placeholder="z.B. Training"
|
||||
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">
|
||||
Anlegen
|
||||
</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"
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
className="rounded-full border border-slate-200 p-1 text-slate-600"
|
||||
@@ -500,6 +558,16 @@ export default function AdminPanel() {
|
||||
required
|
||||
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">
|
||||
Speichern
|
||||
</button>
|
||||
@@ -656,6 +724,45 @@ export default function AdminPanel() {
|
||||
</option>
|
||||
))}
|
||||
</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>
|
||||
<textarea
|
||||
name="description"
|
||||
|
||||
@@ -6,6 +6,7 @@ export default function AdminSystemSettings() {
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [provider, setProvider] = useState("osm");
|
||||
const [registrationEnabled, setRegistrationEnabled] = useState(true);
|
||||
const [publicAccessEnabled, setPublicAccessEnabled] = useState(true);
|
||||
const [appName, setAppName] = useState("Vereinskalender");
|
||||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||
const [logoVersion, setLogoVersion] = useState(() => Date.now());
|
||||
@@ -26,6 +27,7 @@ export default function AdminSystemSettings() {
|
||||
setApiKey(payload.apiKey || "");
|
||||
setProvider(payload.provider || "osm");
|
||||
setRegistrationEnabled(payload.registrationEnabled !== false);
|
||||
setPublicAccessEnabled(payload.publicAccessEnabled !== false);
|
||||
if (appNameResponse.ok) {
|
||||
const appPayload = await appNameResponse.json();
|
||||
setAppName(appPayload.name || "Vereinskalender");
|
||||
@@ -61,7 +63,12 @@ export default function AdminSystemSettings() {
|
||||
fetch("/api/settings/system", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ apiKey, provider, registrationEnabled })
|
||||
body: JSON.stringify({
|
||||
apiKey,
|
||||
provider,
|
||||
registrationEnabled,
|
||||
publicAccessEnabled
|
||||
})
|
||||
}),
|
||||
fetch("/api/settings/app-name", {
|
||||
method: "POST",
|
||||
@@ -224,7 +231,20 @@ export default function AdminSystemSettings() {
|
||||
/>
|
||||
</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
|
||||
type="checkbox"
|
||||
checked={registrationEnabled}
|
||||
@@ -232,6 +252,8 @@ export default function AdminSystemSettings() {
|
||||
/>
|
||||
Registrierung erlauben
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" className="btn-accent">
|
||||
Speichern
|
||||
</button>
|
||||
|
||||
@@ -23,7 +23,8 @@ type EventItem = {
|
||||
startAt: string;
|
||||
endAt?: string | null;
|
||||
status: string;
|
||||
category?: { id: string; name: string } | null;
|
||||
publicOverride?: boolean | null;
|
||||
category?: { id: string; name: string; isPublic?: boolean } | null;
|
||||
};
|
||||
|
||||
type ViewItem = {
|
||||
@@ -35,11 +36,12 @@ type ViewItem = {
|
||||
};
|
||||
|
||||
type DateBucket = "past" | "today" | "tomorrow" | "future";
|
||||
type Pane = "calendar" | "list" | "map";
|
||||
|
||||
const MAP_FILTER_STORAGE_KEY = "mapFilters";
|
||||
|
||||
export default function CalendarBoard() {
|
||||
const { data } = useSession();
|
||||
const { data, status } = useSession();
|
||||
const [events, setEvents] = useState<EventItem[]>([]);
|
||||
const [view, setView] = useState<ViewItem | 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 [bulkSelection, setBulkSelection] = useState<Set<string>>(new Set());
|
||||
const [mapFullscreen, setMapFullscreen] = useState(false);
|
||||
const [publicAccessEnabled, setPublicAccessEnabled] = useState<boolean | null>(null);
|
||||
const [isDarkTheme, setIsDarkTheme] = useState(false);
|
||||
const [mapDateFilter, setMapDateFilter] = useState<Set<DateBucket>>(
|
||||
new Set(["past", "today", "tomorrow", "future"])
|
||||
@@ -69,14 +72,12 @@ export default function CalendarBoard() {
|
||||
);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [prefillStartAt, setPrefillStartAt] = useState<string | null>(null);
|
||||
const [viewOrder, setViewOrder] = useState<
|
||||
Array<"calendar" | "list" | "map">
|
||||
>(["calendar", "map", "list"]);
|
||||
const [collapsed, setCollapsed] = useState<{
|
||||
calendar: boolean;
|
||||
list: boolean;
|
||||
map: boolean;
|
||||
}>({
|
||||
const [viewOrder, setViewOrder] = useState<Array<Pane>>([
|
||||
"list",
|
||||
"map",
|
||||
"calendar"
|
||||
]);
|
||||
const [collapsed, setCollapsed] = useState<Record<Pane, boolean>>({
|
||||
calendar: false,
|
||||
list: false,
|
||||
map: false
|
||||
@@ -85,14 +86,21 @@ export default function CalendarBoard() {
|
||||
start: Date;
|
||||
end: Date;
|
||||
viewType: string;
|
||||
} | null>(null);
|
||||
const [initialView, setInitialView] = useState("dayGridMonth");
|
||||
const [dragSource, setDragSource] = useState<
|
||||
"calendar" | "list" | "map" | null
|
||||
>(null);
|
||||
const [dragOver, setDragOver] = useState<
|
||||
"calendar" | "list" | "map" | null
|
||||
>(null);
|
||||
} | null>(() => {
|
||||
const now = new Date();
|
||||
return {
|
||||
start: new Date(now.getFullYear(), 0, 1),
|
||||
end: new Date(now.getFullYear() + 1, 0, 1),
|
||||
viewType: "dayGridYear"
|
||||
};
|
||||
});
|
||||
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 listRef = 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 isAdmin =
|
||||
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);
|
||||
setError(null);
|
||||
try {
|
||||
const [eventsResponse, viewResponse] = await Promise.all([
|
||||
fetch("/api/events"),
|
||||
fetch("/api/views/default")
|
||||
]);
|
||||
const requests = [fetch("/api/events")];
|
||||
if (includeView) {
|
||||
requests.push(fetch("/api/views/default"));
|
||||
}
|
||||
const [eventsResponse, viewResponse] = await Promise.all(requests);
|
||||
if (!eventsResponse.ok) {
|
||||
throw new Error("Events konnten nicht geladen werden.");
|
||||
}
|
||||
const payload = await eventsResponse.json();
|
||||
setEvents(payload);
|
||||
if (viewResponse.ok) {
|
||||
if (includeView && viewResponse?.ok) {
|
||||
setView(await viewResponse.json());
|
||||
} else {
|
||||
setView(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
@@ -126,10 +139,9 @@ export default function CalendarBoard() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.user) {
|
||||
fetchEvents();
|
||||
}
|
||||
}, [data?.user]);
|
||||
if (status === "loading") return;
|
||||
fetchEvents(Boolean(data?.user));
|
||||
}, [data?.user, status]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadKey = async () => {
|
||||
@@ -139,6 +151,7 @@ export default function CalendarBoard() {
|
||||
const payload = await response.json();
|
||||
setPlacesKey(payload.apiKey || "");
|
||||
setPlacesProvider(payload.provider === "google" ? "google" : "osm");
|
||||
setPublicAccessEnabled(payload.publicAccessEnabled !== false);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -166,22 +179,11 @@ export default function CalendarBoard() {
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
fetchEvents();
|
||||
fetchEvents(Boolean(data?.user));
|
||||
};
|
||||
window.addEventListener("views-updated", handler);
|
||||
return () => window.removeEventListener("views-updated", handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = window.localStorage.getItem("calendarLastView");
|
||||
if (stored) {
|
||||
setInitialView(stored);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
}, [data?.user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
@@ -331,7 +333,7 @@ export default function CalendarBoard() {
|
||||
["calendar", "list", "map"].includes(String(item))
|
||||
)
|
||||
)
|
||||
] as Array<"calendar" | "list" | "map">;
|
||||
] as Array<Pane>;
|
||||
if (normalized.includes("calendar") && normalized.includes("list")) {
|
||||
if (!normalized.includes("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);
|
||||
try {
|
||||
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] };
|
||||
setCollapsed(next);
|
||||
try {
|
||||
@@ -385,10 +387,7 @@ export default function CalendarBoard() {
|
||||
}
|
||||
};
|
||||
|
||||
const swapOrder = (
|
||||
source: "calendar" | "list" | "map",
|
||||
target: "calendar" | "list" | "map"
|
||||
) => {
|
||||
const swapOrder = (source: Pane, target: Pane) => {
|
||||
if (source === target) return;
|
||||
const next = [...viewOrder];
|
||||
const sourceIndex = next.indexOf(source);
|
||||
@@ -401,7 +400,7 @@ export default function CalendarBoard() {
|
||||
|
||||
const onDragStart = (
|
||||
event: React.DragEvent<HTMLDivElement>,
|
||||
section: "calendar" | "list" | "map"
|
||||
section: Pane
|
||||
) => {
|
||||
event.dataTransfer.setData("text/plain", section);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
@@ -418,7 +417,7 @@ export default function CalendarBoard() {
|
||||
}
|
||||
};
|
||||
|
||||
const onDragEnter = (target: "calendar" | "list" | "map") => {
|
||||
const onDragEnter = (target: Pane) => {
|
||||
setDragOver(target);
|
||||
};
|
||||
|
||||
@@ -427,10 +426,7 @@ export default function CalendarBoard() {
|
||||
setDragOver(null);
|
||||
};
|
||||
|
||||
const onDrop = (
|
||||
event: React.DragEvent<HTMLDivElement>,
|
||||
target: "calendar" | "list" | "map"
|
||||
) => {
|
||||
const onDrop = (event: React.DragEvent<HTMLDivElement>, target: Pane) => {
|
||||
event.preventDefault();
|
||||
const source = event.dataTransfer.getData("text/plain") as
|
||||
| "calendar"
|
||||
@@ -443,6 +439,83 @@ export default function CalendarBoard() {
|
||||
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) => {
|
||||
if (date) {
|
||||
const prefill = new Date(date);
|
||||
@@ -499,7 +572,7 @@ export default function CalendarBoard() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ eventId })
|
||||
});
|
||||
await fetchEvents();
|
||||
await fetchEvents(Boolean(data?.user));
|
||||
window.dispatchEvent(new Event("views-updated"));
|
||||
};
|
||||
|
||||
@@ -519,6 +592,14 @@ export default function CalendarBoard() {
|
||||
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>) => {
|
||||
event.preventDefault();
|
||||
setEditStatus(null);
|
||||
@@ -526,7 +607,7 @@ export default function CalendarBoard() {
|
||||
if (!editEvent) return;
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const payload = {
|
||||
const payload: Record<string, unknown> = {
|
||||
title: formData.get("title"),
|
||||
description: formData.get("description"),
|
||||
location: formData.get("location"),
|
||||
@@ -537,6 +618,11 @@ export default function CalendarBoard() {
|
||||
endAt: toIsoString(formData.get("endAt")),
|
||||
categoryId: formData.get("categoryId")
|
||||
};
|
||||
if (showPublicControls) {
|
||||
payload.publicOverride = parsePublicOverride(
|
||||
formData.get("publicOverride")
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/events/${editEvent.id}`, {
|
||||
method: "PATCH",
|
||||
@@ -553,7 +639,7 @@ export default function CalendarBoard() {
|
||||
setEditStatus("Termin aktualisiert.");
|
||||
setIsEditOpen(false);
|
||||
setEditEvent(null);
|
||||
await fetchEvents();
|
||||
await fetchEvents(Boolean(data?.user));
|
||||
};
|
||||
|
||||
const deleteEvent = async (eventId: string) => {
|
||||
@@ -568,7 +654,7 @@ export default function CalendarBoard() {
|
||||
setEditError(null);
|
||||
setIsEditOpen(false);
|
||||
setEditEvent(null);
|
||||
await fetchEvents();
|
||||
await fetchEvents(Boolean(data?.user));
|
||||
};
|
||||
|
||||
const toggleBulkSelection = (eventId: string) => {
|
||||
@@ -603,7 +689,7 @@ export default function CalendarBoard() {
|
||||
)
|
||||
);
|
||||
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 (
|
||||
<section className="space-y-4 fade-up">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<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 className="flex items-center gap-2">
|
||||
{data?.user && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-accent"
|
||||
className="btn-accent inline-flex items-center gap-2"
|
||||
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
|
||||
type="button"
|
||||
onClick={fetchEvents}
|
||||
className="btn-ghost"
|
||||
onClick={() => fetchEvents(Boolean(data?.user))}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
{data?.user && (
|
||||
<EventForm
|
||||
variant="inline"
|
||||
showTrigger={false}
|
||||
@@ -833,6 +934,7 @@ export default function CalendarBoard() {
|
||||
}}
|
||||
prefillStartAt={prefillStartAt}
|
||||
/>
|
||||
)}
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
{loading && <p className="text-sm text-slate-500">Lade Termine...</p>}
|
||||
{viewOrder.map((section) => {
|
||||
@@ -841,7 +943,8 @@ export default function CalendarBoard() {
|
||||
<div
|
||||
key="calendar"
|
||||
ref={calendarRef}
|
||||
className={`card drag-card ${
|
||||
data-pane="calendar"
|
||||
className={`card drag-card calendar-pane ${
|
||||
dragSource === "calendar" ? "dragging" : ""
|
||||
} ${
|
||||
dragOver === "calendar" && dragSource && dragSource !== "calendar"
|
||||
@@ -871,6 +974,7 @@ export default function CalendarBoard() {
|
||||
draggable
|
||||
onDragStart={(event) => onDragStart(event, "calendar")}
|
||||
onDragEnd={onDragEnd}
|
||||
onPointerDown={(event) => onPointerDragStart(event, "calendar")}
|
||||
title="Zum Tauschen ziehen"
|
||||
aria-label="Kalender verschieben"
|
||||
>
|
||||
@@ -909,6 +1013,7 @@ export default function CalendarBoard() {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{arg.dayNumberText}</span>
|
||||
{data?.user && (
|
||||
<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"
|
||||
@@ -921,6 +1026,7 @@ export default function CalendarBoard() {
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
@@ -941,6 +1047,7 @@ export default function CalendarBoard() {
|
||||
{arg.event.extendedProps.category || "Ohne Kategorie"}
|
||||
</div>
|
||||
</div>
|
||||
{canManageView && (
|
||||
<ViewToggleButton
|
||||
isSelected={isSelected}
|
||||
onClick={(event) => {
|
||||
@@ -950,6 +1057,7 @@ export default function CalendarBoard() {
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
@@ -960,11 +1068,6 @@ export default function CalendarBoard() {
|
||||
end: arg.end,
|
||||
viewType: arg.view.type
|
||||
});
|
||||
try {
|
||||
window.localStorage.setItem("calendarLastView", arg.view.type);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -977,6 +1080,7 @@ export default function CalendarBoard() {
|
||||
<div
|
||||
key="map"
|
||||
ref={mapRef}
|
||||
data-pane="map"
|
||||
className={`card drag-card ${
|
||||
dragSource === "map" ? "dragging" : ""
|
||||
} ${
|
||||
@@ -1014,6 +1118,7 @@ export default function CalendarBoard() {
|
||||
draggable
|
||||
onDragStart={(event) => onDragStart(event, "map")}
|
||||
onDragEnd={onDragEnd}
|
||||
onPointerDown={(event) => onPointerDragStart(event, "map")}
|
||||
title="Zum Tauschen ziehen"
|
||||
aria-label="Karte verschieben"
|
||||
>
|
||||
@@ -1193,6 +1298,7 @@ export default function CalendarBoard() {
|
||||
<div
|
||||
key="list"
|
||||
ref={listRef}
|
||||
data-pane="list"
|
||||
className={`card space-y-4 drag-card ${
|
||||
dragSource === "list" ? "dragging" : ""
|
||||
} ${
|
||||
@@ -1205,13 +1311,13 @@ export default function CalendarBoard() {
|
||||
onDrop={(event) => onDrop(event, "list")}
|
||||
>
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-slate-200 px-2 py-1 text-xs text-slate-600"
|
||||
onClick={() => toggleCollapse("list")}
|
||||
aria-label={collapsed.list ? "Liste aufklappen" : "Liste zuklappen"}
|
||||
aria-label={collapsed.list ? "Termine aufklappen" : "Termine zuklappen"}
|
||||
title={collapsed.list ? "Aufklappen" : "Zuklappen"}
|
||||
>
|
||||
{collapsed.list ? <IconChevronDown /> : <IconChevronUp />}
|
||||
@@ -1221,8 +1327,9 @@ export default function CalendarBoard() {
|
||||
draggable
|
||||
onDragStart={(event) => onDragStart(event, "list")}
|
||||
onDragEnd={onDragEnd}
|
||||
onPointerDown={(event) => onPointerDragStart(event, "list")}
|
||||
title="Zum Tauschen ziehen"
|
||||
aria-label="Liste verschieben"
|
||||
aria-label="Termine verschieben"
|
||||
>
|
||||
<IconGrip />
|
||||
</div>
|
||||
@@ -1413,10 +1520,12 @@ export default function CalendarBoard() {
|
||||
</td>
|
||||
<td className="py-3 pr-3">
|
||||
<div className="flex flex-nowrap gap-1">
|
||||
{canManageView && (
|
||||
<ViewToggleButton
|
||||
isSelected={selectedEventIds.has(event.id)}
|
||||
onClick={() => toggleEvent(event.id)}
|
||||
/>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1580,7 +1689,7 @@ export default function CalendarBoard() {
|
||||
Beschreibung
|
||||
</p>
|
||||
<p className="text-sm text-slate-700">
|
||||
{detailsEvent.description}
|
||||
{renderLinkedText(detailsEvent.description)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -1594,22 +1703,6 @@ export default function CalendarBoard() {
|
||||
>
|
||||
Google Maps
|
||||
</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>
|
||||
@@ -1738,6 +1831,45 @@ export default function CalendarBoard() {
|
||||
</option>
|
||||
))}
|
||||
</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>
|
||||
<textarea
|
||||
name="description"
|
||||
@@ -1973,6 +2105,47 @@ function formatLocation(value?: string | null) {
|
||||
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 }> }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
@@ -2026,8 +2199,9 @@ function ViewToggleButton({
|
||||
}) {
|
||||
const base =
|
||||
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";
|
||||
const iconClass = size === "sm" ? "h-[11px] w-[11px]" : "h-4 w-4";
|
||||
const label = isSelected
|
||||
? "Vom Kalenderfeed entfernen"
|
||||
: "Zum Kalenderfeed hinzufügen";
|
||||
@@ -2039,23 +2213,23 @@ function ViewToggleButton({
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
{isSelected ? <IconBell /> : <IconSleep />}
|
||||
{isSelected ? <IconBell className={iconClass} /> : <IconSleep className={iconClass} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function IconBell() {
|
||||
function IconBell({ className = "h-4 w-4" }: { className?: string }) {
|
||||
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="M10 19a2 2 0 004 0" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconSleep() {
|
||||
function IconSleep({ className = "h-4 w-4" }: { className?: string }) {
|
||||
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="M9 9h6l-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 [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [appName, setAppName] = useState("Vereinskalender");
|
||||
const hideLoginPaths = new Set(["/login", "/register", "/reset"]);
|
||||
const showLoginButton = !data?.user && !hideLoginPaths.has(pathname);
|
||||
const linkClass = (href: string) =>
|
||||
pathname === href
|
||||
? "nav-link-active rounded-full px-3 py-1"
|
||||
@@ -182,7 +184,7 @@ export default function NavBar() {
|
||||
</svg>
|
||||
Logout
|
||||
</button>
|
||||
) : (
|
||||
) : showLoginButton ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signIn()}
|
||||
@@ -190,7 +192,7 @@ export default function NavBar() {
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
</nav>
|
||||
<button
|
||||
type="button"
|
||||
@@ -232,7 +234,7 @@ export default function NavBar() {
|
||||
/>
|
||||
)}
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
@@ -275,7 +277,7 @@ export default function NavBar() {
|
||||
</svg>
|
||||
Logout
|
||||
</button>
|
||||
) : (
|
||||
) : showLoginButton ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signIn()}
|
||||
@@ -283,7 +285,7 @@ export default function NavBar() {
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</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
|
||||
endAt DateTime?
|
||||
status String @default("PENDING")
|
||||
publicOverride Boolean?
|
||||
createdAt DateTime @default(now())
|
||||
createdById String
|
||||
createdBy User @relation("EventCreator", fields: [createdById], references: [id])
|
||||
@@ -46,6 +47,7 @@ model Event {
|
||||
model Category {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
isPublic Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
events Event[]
|
||||
viewSubscriptions UserViewCategory[]
|
||||
|
||||
Reference in New Issue
Block a user