"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import FullCalendar from "@fullcalendar/react"; import dayGridPlugin from "@fullcalendar/daygrid"; import timeGridPlugin from "@fullcalendar/timegrid"; import listPlugin from "@fullcalendar/list"; import interactionPlugin from "@fullcalendar/interaction"; import deLocale from "@fullcalendar/core/locales/de"; import { useSession } from "next-auth/react"; import { CircleMarker, MapContainer, Popup, TileLayer, useMap } from "react-leaflet"; import EventForm from "./EventForm"; type EventItem = { id: string; title: string; description?: string | null; location?: string | null; locationPlaceId?: string | null; locationLat?: number | null; locationLng?: number | null; startAt: string; endAt?: string | null; status: string; category?: { id: string; name: string } | null; }; type ViewItem = { id: string; token: string; items: { eventId: string }[]; categories: { categoryId: string; category?: { name: string } }[]; exclusions: { eventId: string }[]; }; type DateBucket = "past" | "today" | "tomorrow" | "future"; const MAP_FILTER_STORAGE_KEY = "mapFilters"; export default function CalendarBoard() { const { data } = useSession(); const [events, setEvents] = useState([]); const [view, setView] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [query, setQuery] = useState(""); const [categoryFilter, setCategoryFilter] = useState("ALL"); const [sortKey, setSortKey] = useState("startAt"); const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); const [detailsEvent, setDetailsEvent] = useState(null); const [placesKey, setPlacesKey] = useState(""); const [placesProvider, setPlacesProvider] = useState<"google" | "osm">("osm"); const [categories, setCategories] = useState<{ id: string; name: string }[]>( [] ); const [editEvent, setEditEvent] = useState(null); const [isEditOpen, setIsEditOpen] = useState(false); const [editStatus, setEditStatus] = useState(null); const [editError, setEditError] = useState(null); const [bulkSelection, setBulkSelection] = useState>(new Set()); const [mapFullscreen, setMapFullscreen] = useState(false); const [isDarkTheme, setIsDarkTheme] = useState(false); const [mapDateFilter, setMapDateFilter] = useState>( new Set(["past", "today", "tomorrow", "future"]) ); const [mapCategoryFilter, setMapCategoryFilter] = useState>( new Set() ); const [formOpen, setFormOpen] = useState(false); const [prefillStartAt, setPrefillStartAt] = useState(null); const [viewOrder, setViewOrder] = useState< Array<"calendar" | "list" | "map"> >(["calendar", "map", "list"]); const [collapsed, setCollapsed] = useState<{ calendar: boolean; list: boolean; map: boolean; }>({ calendar: false, list: false, map: false }); const [activeRange, setActiveRange] = useState<{ 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); const calendarRef = useRef(null); const listRef = useRef(null); const mapRef = useRef(null); const [portalRoot, setPortalRoot] = useState(null); const detailsModalRef = useRef(null); const lastActiveElementRef = useRef(null); const isAdmin = data?.user?.role === "ADMIN" || data?.user?.role === "SUPERADMIN"; const fetchEvents = async () => { setLoading(true); setError(null); try { const [eventsResponse, viewResponse] = await Promise.all([ fetch("/api/events"), fetch("/api/views/default") ]); if (!eventsResponse.ok) { throw new Error("Events konnten nicht geladen werden."); } const payload = await eventsResponse.json(); setEvents(payload); if (viewResponse.ok) { setView(await viewResponse.json()); } } catch (err) { setError((err as Error).message); } finally { setLoading(false); } }; useEffect(() => { if (data?.user) { fetchEvents(); } }, [data?.user]); useEffect(() => { const loadKey = async () => { try { const response = await fetch("/api/settings/system"); if (!response.ok) return; const payload = await response.json(); setPlacesKey(payload.apiKey || ""); setPlacesProvider(payload.provider === "google" ? "google" : "osm"); } catch { // ignore } }; if (data?.user) { loadKey(); } }, [data?.user]); useEffect(() => { if (!data?.user || !isAdmin) return; const loadCategories = async () => { try { const response = await fetch("/api/categories"); if (!response.ok) return; const payload = await response.json(); setCategories(payload); } catch { // ignore } }; loadCategories(); }, [data?.user, isAdmin]); useEffect(() => { const handler = () => { fetchEvents(); }; 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 } }, []); useEffect(() => { if (typeof document === "undefined") return; setPortalRoot(document.body); }, []); useEffect(() => { try { const stored = window.localStorage.getItem(MAP_FILTER_STORAGE_KEY); if (!stored) return; const parsed = JSON.parse(stored); const allowed: DateBucket[] = ["past", "today", "tomorrow", "future"]; if (parsed?.dates && Array.isArray(parsed.dates)) { const nextDates = parsed.dates.filter((value: string) => allowed.includes(value as DateBucket) ); if (nextDates.length > 0) { setMapDateFilter(new Set(nextDates as DateBucket[])); } } if (parsed?.categories && Array.isArray(parsed.categories)) { setMapCategoryFilter(new Set(parsed.categories.filter(Boolean))); } } catch { // ignore } }, []); useEffect(() => { try { const payload = { dates: Array.from(mapDateFilter), categories: Array.from(mapCategoryFilter) }; window.localStorage.setItem(MAP_FILTER_STORAGE_KEY, JSON.stringify(payload)); } catch { // ignore } }, [mapDateFilter, mapCategoryFilter]); useEffect(() => { if (typeof document === "undefined") return; const root = document.documentElement; const updateTheme = () => { setIsDarkTheme(root.dataset.theme === "dark"); }; updateTheme(); const observer = new MutationObserver(updateTheme); observer.observe(root, { attributes: true, attributeFilter: ["data-theme"] }); return () => observer.disconnect(); }, []); useEffect(() => { if (!detailsEvent) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setDetailsEvent(null); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [detailsEvent]); useEffect(() => { if (!isEditOpen) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setIsEditOpen(false); setEditEvent(null); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [isEditOpen]); useEffect(() => { if (!mapFullscreen) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { setMapFullscreen(false); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [mapFullscreen]); useEffect(() => { if (!detailsEvent) return; lastActiveElementRef.current = document.activeElement as HTMLElement | null; const node = detailsModalRef.current; if (!node) return; const focusable = node.querySelectorAll( 'button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])' ); const target = focusable[0] || node; target.focus(); }, [detailsEvent]); useEffect(() => { if (detailsEvent) return; const previous = lastActiveElementRef.current; if (previous) { previous.focus(); lastActiveElementRef.current = null; } }, [detailsEvent]); useEffect(() => { try { const stored = window.localStorage.getItem("listSort"); if (!stored) return; const parsed = JSON.parse(stored); if ( parsed && typeof parsed === "object" && typeof parsed.key === "string" && (parsed.dir === "asc" || parsed.dir === "desc") ) { setSortKey(parsed.key); setSortDir(parsed.dir); } } catch { // ignore } }, []); useEffect(() => { try { window.localStorage.setItem( "listSort", JSON.stringify({ key: sortKey, dir: sortDir }) ); } catch { // ignore } }, [sortKey, sortDir]); useEffect(() => { try { const stored = window.localStorage.getItem("calendarViewOrder"); if (!stored) return; const parsed = JSON.parse(stored); if (Array.isArray(parsed)) { const normalized = [ ...new Set( parsed.filter((item) => ["calendar", "list", "map"].includes(String(item)) ) ) ] as Array<"calendar" | "list" | "map">; if (normalized.includes("calendar") && normalized.includes("list")) { if (!normalized.includes("map")) { normalized.splice(1, 0, "map"); } setViewOrder(normalized); } } } catch { // ignore } }, []); useEffect(() => { try { const stored = window.localStorage.getItem("calendarViewCollapsed"); if (!stored) return; const parsed = JSON.parse(stored); if ( parsed && typeof parsed === "object" && typeof parsed.calendar === "boolean" && typeof parsed.list === "boolean" ) { setCollapsed({ calendar: parsed.calendar, list: parsed.list, map: typeof parsed.map === "boolean" ? parsed.map : false }); } } catch { // ignore } }, []); const persistOrder = (nextOrder: Array<"calendar" | "list" | "map">) => { setViewOrder(nextOrder); try { window.localStorage.setItem("calendarViewOrder", JSON.stringify(nextOrder)); } catch { // ignore } }; const toggleCollapse = (section: "calendar" | "list" | "map") => { const next = { ...collapsed, [section]: !collapsed[section] }; setCollapsed(next); try { window.localStorage.setItem("calendarViewCollapsed", JSON.stringify(next)); } catch { // ignore } }; const swapOrder = ( source: "calendar" | "list" | "map", target: "calendar" | "list" | "map" ) => { if (source === target) return; const next = [...viewOrder]; const sourceIndex = next.indexOf(source); const targetIndex = next.indexOf(target); if (sourceIndex === -1 || targetIndex === -1) return; next[sourceIndex] = target; next[targetIndex] = source; persistOrder(next); }; const onDragStart = ( event: React.DragEvent, section: "calendar" | "list" | "map" ) => { event.dataTransfer.setData("text/plain", section); event.dataTransfer.effectAllowed = "move"; setDragSource(section); setDragOver(section); const node = section === "calendar" ? calendarRef.current : section === "list" ? listRef.current : mapRef.current; if (node) { event.dataTransfer.setDragImage(node, 20, 20); } }; const onDragEnter = (target: "calendar" | "list" | "map") => { setDragOver(target); }; const onDragEnd = () => { setDragSource(null); setDragOver(null); }; const onDrop = ( event: React.DragEvent, target: "calendar" | "list" | "map" ) => { event.preventDefault(); const source = event.dataTransfer.getData("text/plain") as | "calendar" | "list" | "map"; if (source) { swapOrder(source, target); } setDragSource(null); setDragOver(null); }; const openFormForDate = (date: Date | null) => { if (date) { const prefill = new Date(date); prefill.setHours(12, 0, 0, 0); setPrefillStartAt(prefill.toISOString()); } else { setPrefillStartAt(null); } setFormOpen(true); }; const calendarEvents = useMemo( () => events.map((event) => ({ id: event.id, title: event.title, start: event.startAt, end: event.endAt || undefined, extendedProps: { status: event.status, location: event.location, description: event.description, category: event.category?.name } })), [events] ); const selectedEventIds = useMemo(() => { if (!view) return new Set(); const ids = new Set(view.items.map((item) => item.eventId)); const excluded = new Set(view.exclusions.map((item) => item.eventId)); const subscribedCategoryIds = new Set( view.categories.map((item) => item.categoryId) ); events.forEach((event) => { if (event.category?.id && subscribedCategoryIds.has(event.category.id)) { ids.add(event.id); } }); excluded.forEach((eventId) => ids.delete(eventId)); return ids; }, [view, events]); const subscribedCategoryIds = useMemo(() => { return new Set(view?.categories.map((item) => item.categoryId) || []); }, [view]); const toggleEvent = async (eventId: string) => { if (!view) return; const isSelected = selectedEventIds.has(eventId); await fetch(`/api/views/${view.id}/items`, { method: isSelected ? "DELETE" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ eventId }) }); await fetchEvents(); window.dispatchEvent(new Event("views-updated")); }; const formatLocalDateTime = (value?: string | null) => { if (!value) return ""; const date = new Date(value); const offset = date.getTimezoneOffset() * 60000; return new Date(date.getTime() - offset).toISOString().slice(0, 16); }; const toIsoString = (value: FormDataEntryValue | null) => { if (!value) return null; const raw = String(value); if (!raw) return null; const date = new Date(raw); if (Number.isNaN(date.getTime())) return null; return date.toISOString(); }; const updateEvent = async (event: React.FormEvent) => { event.preventDefault(); setEditStatus(null); setEditError(null); if (!editEvent) return; const formData = new FormData(event.currentTarget); const payload = { title: formData.get("title"), description: formData.get("description"), location: formData.get("location"), locationPlaceId: formData.get("locationPlaceId"), locationLat: formData.get("locationLat"), locationLng: formData.get("locationLng"), startAt: toIsoString(formData.get("startAt")), endAt: toIsoString(formData.get("endAt")), categoryId: formData.get("categoryId") }; const response = await fetch(`/api/events/${editEvent.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); if (!response.ok) { const data = await response.json(); setEditError(data.error || "Termin konnte nicht aktualisiert werden."); return; } setEditStatus("Termin aktualisiert."); setIsEditOpen(false); setEditEvent(null); await fetchEvents(); }; const deleteEvent = async (eventId: string) => { const ok = window.confirm("Termin wirklich löschen?"); if (!ok) return; const response = await fetch(`/api/events/${eventId}`, { method: "DELETE" }); if (!response.ok) { setEditError("Termin konnte nicht gelöscht werden."); return; } setEditStatus(null); setEditError(null); setIsEditOpen(false); setEditEvent(null); await fetchEvents(); }; const toggleBulkSelection = (eventId: string) => { setBulkSelection((prev) => { const next = new Set(prev); if (next.has(eventId)) { next.delete(eventId); } else { next.add(eventId); } return next; }); }; const toggleSelectAllVisible = (checked: boolean) => { if (!checked) { setBulkSelection(new Set()); return; } setBulkSelection(new Set(displayedEvents.map((event) => event.id))); }; const deleteSelectedEvents = async () => { if (bulkSelection.size === 0) return; const ok = window.confirm( `Wirklich ${bulkSelection.size} Termin(e) löschen?` ); if (!ok) return; await Promise.all( Array.from(bulkSelection).map((eventId) => fetch(`/api/events/${eventId}`, { method: "DELETE" }) ) ); setBulkSelection(new Set()); await fetchEvents(); }; const filteredEvents = useMemo(() => { const normalizedQuery = query.trim().toLowerCase(); const filtered = events.filter((event) => { const categoryName = event.category?.name || "Ohne Kategorie"; if (categoryFilter !== "ALL" && categoryName !== categoryFilter) { return false; } if (!normalizedQuery) return true; return ( event.title.toLowerCase().includes(normalizedQuery) || (event.description || "").toLowerCase().includes(normalizedQuery) || (event.location || "").toLowerCase().includes(normalizedQuery) || categoryName.toLowerCase().includes(normalizedQuery) ); }); const sorted = [...filtered].sort((a, b) => { const dir = sortDir === "asc" ? 1 : -1; if (sortKey === "title") { return a.title.localeCompare(b.title) * dir; } if (sortKey === "category") { const aCat = a.category?.name || "Ohne Kategorie"; const bCat = b.category?.name || "Ohne Kategorie"; return aCat.localeCompare(bCat) * dir; } if (sortKey === "location") { const aLoc = a.location || ""; const bLoc = b.location || ""; return aLoc.localeCompare(bLoc) * dir; } if (sortKey === "status") { return a.status.localeCompare(b.status) * dir; } const aDate = new Date(a.startAt).getTime(); const bDate = new Date(b.startAt).getTime(); return (aDate - bDate) * dir; }); return sorted; }, [events, query, categoryFilter, sortKey, sortDir]); const categoryOptions = useMemo(() => { const list = events .map((event) => event.category?.name || "Ohne Kategorie") .filter(Boolean); return Array.from(new Set(list)).sort(); }, [events]); const getDateBucket = (value: string): DateBucket => { const date = new Date(value); if (Number.isNaN(date.getTime())) return "future"; const now = new Date(); const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const startTomorrow = new Date(startToday); startTomorrow.setDate(startTomorrow.getDate() + 1); const startAfterTomorrow = new Date(startTomorrow); startAfterTomorrow.setDate(startAfterTomorrow.getDate() + 1); if (date < startToday) return "past"; if (date >= startToday && date < startTomorrow) return "today"; if (date >= startTomorrow && date < startAfterTomorrow) return "tomorrow"; return "future"; }; const getCategoryColor = (value?: string | null) => { const palette = [ "#2563eb", "#7c3aed", "#db2777", "#ea580c", "#16a34a", "#0f766e", "#0ea5e9", "#65a30d" ]; const name = (value || "Ohne Kategorie").toLowerCase(); let hash = 0; for (let i = 0; i < name.length; i += 1) { hash = (hash * 31 + name.charCodeAt(i)) % 997; } return palette[hash % palette.length]; }; const mapEvents = useMemo(() => { const currentYear = new Date().getFullYear(); return events .filter((event) => { if (!event.locationLat || !event.locationLng) return false; const start = new Date(event.startAt); return start.getFullYear() === currentYear; }) .map((event) => ({ ...event, bucket: getDateBucket(event.startAt), categoryName: event.category?.name || "Ohne Kategorie" })); }, [events]); const filteredMapEvents = useMemo(() => { return mapEvents.filter((event) => { if (!mapDateFilter.has(event.bucket)) return false; if (mapCategoryFilter.size === 0) return true; return mapCategoryFilter.has(event.categoryName); }); }, [mapEvents, mapDateFilter, mapCategoryFilter]); const mapPoints = useMemo( () => filteredMapEvents.map((event) => ({ lat: event.locationLat as number, lng: event.locationLng as number })), [filteredMapEvents] ); const mapCategoryOptions = useMemo(() => { return Array.from( new Set(mapEvents.map((event) => event.categoryName)) ).sort((a, b) => a.localeCompare(b)); }, [mapEvents]); const toggleMapDateFilter = (key: DateBucket) => { setMapDateFilter((prev) => { const next = new Set(prev); if (next.has(key)) { next.delete(key); } else { next.add(key); } return next; }); }; const toggleMapCategoryFilter = (category: string) => { setMapCategoryFilter((prev) => { const next = new Set(prev); if (next.has(category)) { next.delete(category); } else { next.add(category); } return next; }); }; const displayedEvents = useMemo(() => { if (!activeRange) { return filteredEvents; } return filteredEvents.filter((event) => { const start = new Date(event.startAt); return start >= activeRange.start && start < activeRange.end; }); }, [filteredEvents, activeRange]); useEffect(() => { setBulkSelection((prev) => (prev.size > 0 ? new Set() : prev)); }, [query, categoryFilter, activeRange]); const toggleSort = (nextKey: string) => { let nextDir: "asc" | "desc" = "asc"; if (sortKey === nextKey) { nextDir = sortDir === "asc" ? "desc" : "asc"; setSortDir(nextDir); } else { setSortKey(nextKey); setSortDir("asc"); } try { const key = sortKey === nextKey ? sortKey : nextKey; window.localStorage.setItem( "listSort", JSON.stringify({ key, dir: nextDir }) ); } catch { // ignore } }; if (!data?.user) { return (

Bitte anmelden, um die Vereinskalender zu sehen.

); } return (

Kalender

Termine im Blick

{ setFormOpen(open); if (!open) { setPrefillStartAt(null); } }} prefillStartAt={prefillStartAt} /> {error &&

{error}

} {loading &&

Lade Termine...

} {viewOrder.map((section) => { if (section === "calendar") { return (
event.preventDefault()} onDragEnter={() => onDragEnter("calendar")} onDrop={(event) => onDrop(event, "calendar")} >

Kalender

onDragStart(event, "calendar")} onDragEnd={onDragEnd} title="Zum Tauschen ziehen" aria-label="Kalender verschieben" >
{!collapsed.calendar && ( { if (arg.view.type !== "dayGridMonth") { return arg.dayNumberText; } return (
{arg.dayNumberText}
); }} eventClick={(info) => { const match = events.find((event) => event.id === info.event.id); if (match) { setDetailsEvent(match); } }} eventContent={(arg) => { const status = arg.event.extendedProps.status as string; const isSelected = selectedEventIds.has(arg.event.id); return (
{arg.event.title}
{arg.event.extendedProps.category || "Ohne Kategorie"}
{ event.preventDefault(); event.stopPropagation(); toggleEvent(arg.event.id); }} size="sm" />
); }} height="auto" datesSet={(arg) => { setActiveRange({ start: arg.start, end: arg.end, viewType: arg.view.type }); try { window.localStorage.setItem("calendarLastView", arg.view.type); } catch { // ignore } }} /> )}
); } if (section === "map") { return (
event.preventDefault()} onDragEnter={() => onDragEnter("map")} onDrop={(event) => onDrop(event, "map")} >

Karte

onDragStart(event, "map")} onDragEnd={onDragEnd} title="Zum Tauschen ziehen" aria-label="Karte verschieben" >
{!collapsed.map && (
{mapCategoryOptions.map((category) => { const active = mapCategoryFilter.has(category); return ( ); })}
event.stopPropagation()} > OpenStreetMap © CARTO' : '© OpenStreetMap' } url={ isDarkTheme ? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" : "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" } /> {filteredMapEvents.map((event) => { const fillColor = getCategoryColor(event.categoryName); const isPast = event.bucket === "past"; const isToday = event.bucket === "today"; const isTomorrow = event.bucket === "tomorrow"; const strokeColor = isPast ? "#94a3b8" : isToday ? "#f59e0b" : isTomorrow ? "#10b981" : fillColor; return (

{event.title}

{new Date(event.startAt).toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" })}

{event.categoryName}

{event.location && (

{event.location}

)}
); })}
)}
); } return (
event.preventDefault()} onDragEnter={() => onDragEnter("list")} onDrop={(event) => onDrop(event, "list")} >

Liste

onDragStart(event, "list")} onDragEnd={onDragEnd} title="Zum Tauschen ziehen" aria-label="Liste verschieben" >
{!collapsed.list && ( <>
setQuery(event.target.value)} placeholder="Suchen..." className="min-w-[220px] rounded-xl border border-slate-300 px-3 py-2 text-sm" /> {isAdmin && (
{bulkSelection.size} ausgewählt
)}
{isAdmin && ( )} {displayedEvents.length === 0 ? ( ) : ( displayedEvents.map((event) => ( {isAdmin && ( )} )) )}
0 && displayedEvents.every((event) => bulkSelection.has(event.id) ) } onChange={(event) => toggleSelectAllVisible(event.target.checked) } /> toggleSort("startAt")} /> toggleSort("title")} />
toggleSort("category")} />
toggleSort("location")} /> Aktionen
Keine Termine für die aktuelle Auswahl.
toggleBulkSelection(event.id)} /> {new Date(event.startAt).toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" })} {event.title} {event.category?.name || "Ohne Kategorie"}
{formatLocation(event.location)} {event.locationLat && event.locationLng && ( )}
toggleEvent(event.id)} /> {isAdmin && ( )}
)}
); })} {detailsEvent && portalRoot ? createPortal(
setDetailsEvent(null)} >
event.stopPropagation()} onKeyDown={(event) => { if (event.key !== "Tab") return; const node = detailsModalRef.current; if (!node) return; const focusable = node.querySelectorAll( 'button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])' ); if (focusable.length === 0) { event.preventDefault(); return; } const first = focusable[0]; const last = focusable[focusable.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } }} >

Termin Details

{isAdmin && ( )}

Titel

{detailsEvent.title}

Zeitpunkt

{new Date(detailsEvent.startAt).toLocaleString("de-DE", { dateStyle: "full", timeStyle: "short" })}

Ort

{detailsEvent.location || "-"}

Kategorie

{detailsEvent.category?.name || "Ohne Kategorie"}

{detailsEvent.description && (

Beschreibung

{detailsEvent.description}

)} {detailsEvent.locationLat && detailsEvent.locationLng && ( )}
{placesProvider === "google" && placesKey && (detailsEvent.locationPlaceId || (detailsEvent.locationLat && detailsEvent.locationLng)) ? (