Files
vereinskalender/components/CalendarBoard.tsx
2026-01-18 00:40:01 +01:00

2689 lines
100 KiB
TypeScript

"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;
publicOverride?: boolean | null;
category?: { id: string; name: string; isPublic?: boolean } | 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";
type Pane = "calendar" | "list" | "map";
const MAP_FILTER_STORAGE_KEY = "mapFilters";
const CALENDAR_VIEW_STORAGE_KEY = "calendarViewType";
const LIST_FILTER_STORAGE_KEY = "listQuickFilters";
const DEFAULT_CALENDAR_VIEW = "dayGridMonth";
const ALLOWED_CALENDAR_VIEWS = [
"dayGridMonth",
"timeGridWeek",
"dayGridYear",
"listWeek"
];
export default function CalendarBoard() {
const { data, status } = useSession();
const [events, setEvents] = useState<EventItem[]>([]);
const [view, setView] = useState<ViewItem | null>(null);
const [error, setError] = useState<string | null>(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<EventItem | null>(null);
const [placesKey, setPlacesKey] = useState("");
const [placesProvider, setPlacesProvider] = useState<"google" | "osm">("osm");
const [categories, setCategories] = useState<{ id: string; name: string }[]>(
[]
);
const [editEvent, setEditEvent] = useState<EventItem | null>(null);
const [isEditOpen, setIsEditOpen] = useState(false);
const [editStatus, setEditStatus] = useState<string | null>(null);
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"])
);
const [mapCategoryFilter, setMapCategoryFilter] = useState<Set<string>>(
new Set()
);
const [formOpen, setFormOpen] = useState(false);
const [prefillStartAt, setPrefillStartAt] = useState<string | null>(null);
const [viewOrder, setViewOrder] = useState<Array<Pane>>([
"list",
"calendar",
"map"
]);
const [collapsed, setCollapsed] = useState<Record<Pane, boolean>>({
calendar: false,
list: false,
map: false
});
const [listQuickRange, setListQuickRange] = useState<
"next7" | "next30" | "nextYear" | null
>("next30");
const [hidePastInList, setHidePastInList] = useState(false);
const [initialView, setInitialView] = useState(() => {
if (typeof window === "undefined") return DEFAULT_CALENDAR_VIEW;
try {
const stored = window.localStorage.getItem(CALENDAR_VIEW_STORAGE_KEY);
if (stored && ALLOWED_CALENDAR_VIEWS.includes(stored)) {
return stored;
}
} catch {
// ignore
}
return DEFAULT_CALENDAR_VIEW;
});
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);
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
const detailsModalRef = useRef<HTMLDivElement | null>(null);
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 (includeView: boolean) => {
setLoading(true);
setError(null);
try {
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 (includeView && viewResponse?.ok) {
setView(await viewResponse.json());
} else {
setView(null);
}
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (status === "loading") return;
fetchEvents(Boolean(data?.user));
}, [data?.user, status]);
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");
setPublicAccessEnabled(payload.publicAccessEnabled !== false);
} 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(Boolean(data?.user));
};
window.addEventListener("views-updated", handler);
return () => window.removeEventListener("views-updated", handler);
}, [data?.user]);
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<HTMLElement>(
'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 {
const stored = window.localStorage.getItem(LIST_FILTER_STORAGE_KEY);
if (!stored) return;
const parsed = JSON.parse(stored);
if (
parsed &&
typeof parsed === "object" &&
(parsed.quickRange === "next7" ||
parsed.quickRange === "next30" ||
parsed.quickRange === "nextYear" ||
parsed.quickRange === null) &&
typeof parsed.hidePast === "boolean"
) {
setListQuickRange(parsed.quickRange);
setHidePastInList(parsed.hidePast);
}
} catch {
// ignore
}
}, []);
useEffect(() => {
try {
window.localStorage.setItem(
LIST_FILTER_STORAGE_KEY,
JSON.stringify({ quickRange: listQuickRange, hidePast: hidePastInList })
);
} catch {
// ignore
}
}, [listQuickRange, hidePastInList]);
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<Pane>;
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<Pane>) => {
setViewOrder(nextOrder);
try {
window.localStorage.setItem("calendarViewOrder", JSON.stringify(nextOrder));
} catch {
// ignore
}
};
const toggleCollapse = (section: Pane) => {
const next = { ...collapsed, [section]: !collapsed[section] };
setCollapsed(next);
try {
window.localStorage.setItem("calendarViewCollapsed", JSON.stringify(next));
} catch {
// ignore
}
};
const swapOrder = (source: Pane, target: Pane) => {
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<HTMLDivElement>,
section: Pane
) => {
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: Pane) => {
setDragOver(target);
};
const onDragEnd = () => {
setDragSource(null);
setDragOver(null);
};
const onDrop = (event: React.DragEvent<HTMLDivElement>, target: Pane) => {
event.preventDefault();
const source = event.dataTransfer.getData("text/plain") as
| "calendar"
| "list"
| "map";
if (source) {
swapOrder(source, target);
}
setDragSource(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) => {
if (date) {
const prefill = new Date(date);
prefill.setHours(12, 0, 0, 0);
setPrefillStartAt(prefill.toISOString());
} else {
setPrefillStartAt(null);
}
setFormOpen(true);
};
const applyEventTooltip = (root: HTMLElement, title: string) => {
const elements = Array.from(
root.querySelectorAll<HTMLElement>(".event-shell .truncate")
);
elements.forEach((element) => {
element.removeAttribute("title");
});
root.setAttribute("title", title);
};
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<string>();
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(Boolean(data?.user));
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 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);
setEditError(null);
if (!editEvent) return;
const formData = new FormData(event.currentTarget);
const payload: Record<string, unknown> = {
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")
};
if (showPublicControls) {
payload.publicOverride = parsePublicOverride(
formData.get("publicOverride")
);
}
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(Boolean(data?.user));
};
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(Boolean(data?.user));
};
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(Boolean(data?.user));
};
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 startOfDay = (value: Date) =>
new Date(value.getFullYear(), value.getMonth(), value.getDate());
const addDays = (value: Date, amount: number) => {
const next = new Date(value);
next.setDate(next.getDate() + amount);
return next;
};
const listRange = useMemo(() => {
if (!listQuickRange && !hidePastInList) return null;
const today = startOfDay(new Date());
const start = hidePastInList
? today
: listQuickRange
? addDays(today, -1)
: today;
let end: Date | null = null;
if (listQuickRange === "next7") {
end = addDays(today, 7);
} else if (listQuickRange === "next30") {
end = addDays(today, 30);
} else if (listQuickRange === "nextYear") {
end = addDays(today, 365);
}
return { start, end };
}, [listQuickRange, hidePastInList]);
const filterButtonClass = (active: boolean) =>
`rounded-full border px-3 py-1 text-xs font-semibold transition ${
active
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-200 text-slate-700 hover:bg-slate-100"
}`;
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 (!listRange) {
return filteredEvents;
}
return filteredEvents.filter((event) => {
const start = new Date(event.startAt);
if (Number.isNaN(start.getTime())) return false;
if (start < listRange.start) return false;
if (listRange.end && start >= listRange.end) return false;
return true;
});
}, [filteredEvents, listRange]);
const tableWidths = isAdmin
? {
checkbox: "w-[5%]",
date: "w-[18%]",
title: "w-[26%]",
category: "w-[16%]",
location: "w-[23%]",
actions: "w-[12%]"
}
: {
date: "w-[20%]",
title: "w-[30%]",
category: "w-[18%]",
location: "w-[24%]",
actions: "w-[8%]"
};
useEffect(() => {
setBulkSelection((prev) => (prev.size > 0 ? new Set() : prev));
}, [query, categoryFilter, listRange]);
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
}
};
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>
</div>
<div className="flex items-center gap-2">
{data?.user && (
<button
type="button"
className="btn-accent inline-flex items-center gap-2"
onClick={() => openFormForDate(null)}
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M12 5v14M5 12h14" strokeLinecap="round" />
</svg>
Termin
</button>
)}
<button
type="button"
onClick={() => fetchEvents(Boolean(data?.user))}
className="btn-ghost p-2"
aria-label="Aktualisieren"
title="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}
open={formOpen}
onOpenChange={(open) => {
setFormOpen(open);
if (!open) {
setPrefillStartAt(null);
}
}}
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) => {
if (section === "calendar") {
return (
<div
key="calendar"
ref={calendarRef}
data-pane="calendar"
className={`card drag-card calendar-pane ${
dragSource === "calendar" ? "dragging" : ""
} ${
dragOver === "calendar" && dragSource && dragSource !== "calendar"
? "drag-target"
: ""
} ${
dragSource === "list" && dragOver === "calendar" ? "shift-down" : ""
}`}
onDragOver={(event) => event.preventDefault()}
onDragEnter={() => onDragEnter("calendar")}
onDrop={(event) => onDrop(event, "calendar")}
>
<div
className={`flex items-center justify-between ${
collapsed.calendar ? "" : "mb-3"
}`}
>
<h3 className="text-sm font-semibold text-slate-700">Kalender</h3>
<div className="flex items-center gap-2">
<button
type="button"
className="rounded-full border border-slate-200 p-2 text-slate-600"
onClick={() => toggleCollapse("calendar")}
aria-label={collapsed.calendar ? "Kalender aufklappen" : "Kalender zuklappen"}
title={collapsed.calendar ? "Aufklappen" : "Zuklappen"}
>
{collapsed.calendar ? <IconChevronDown /> : <IconChevronUp />}
</button>
<div
className="drag-handle"
draggable
onDragStart={(event) => onDragStart(event, "calendar")}
onDragEnd={onDragEnd}
onPointerDown={(event) => onPointerDragStart(event, "calendar")}
title="Zum Tauschen ziehen"
aria-label="Kalender verschieben"
>
<IconGrip />
</div>
</div>
</div>
{!collapsed.calendar && (
<FullCalendar
plugins={[dayGridPlugin, timeGridPlugin, listPlugin, interactionPlugin]}
locale={deLocale}
initialView={initialView}
firstDay={1}
headerToolbar={{
left: "prev,next today",
center: "title",
right: "timeGridWeek,dayGridMonth,dayGridYear,listWeek"
}}
views={{
dayGridMonth: { buttonText: "Monat" },
timeGridWeek: {
buttonText: "Woche",
slotDuration: "02:00:00",
slotLabelInterval: "02:00:00",
slotLabelFormat: { hour: "2-digit", minute: "2-digit", hour12: false }
},
dayGridYear: { buttonText: "Jahr" },
listWeek: { buttonText: "Übersicht" }
}}
buttonText={{ today: "Heute" }}
events={calendarEvents}
dayCellContent={(arg) => {
if (arg.view.type !== "dayGridMonth") {
return arg.dayNumberText;
}
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/70 px-2 py-0.5 text-[11px] font-medium text-slate-500 hover:bg-slate-100"
aria-label={`Termin am ${arg.date.toLocaleDateString("de-DE")} anlegen`}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
openFormForDate(arg.date);
}}
>
+Termin
</button>
)}
</div>
);
}}
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 (
<div className="event-shell">
<div className="min-w-0">
<div className="text-sm font-medium truncate">{arg.event.title}</div>
<div className="text-xs text-slate-600 truncate">
{arg.event.extendedProps.category || "Ohne Kategorie"}
</div>
</div>
{canManageView && (
<ViewToggleButton
isSelected={isSelected}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
toggleEvent(arg.event.id);
}}
size="sm"
/>
)}
</div>
);
}}
eventDidMount={(info) => {
if (!info.el) return;
const startLabel = info.event.allDay
? "Ganztägig"
: info.event.start
? info.event.start.toLocaleTimeString("de-DE", {
hour: "2-digit",
minute: "2-digit"
})
: "";
const tooltip = startLabel
? `${info.event.title} - ${startLabel}`
: info.event.title;
requestAnimationFrame(() =>
applyEventTooltip(info.el, tooltip)
);
}}
height="auto"
datesSet={(arg) => {
if (ALLOWED_CALENDAR_VIEWS.includes(arg.view.type)) {
setInitialView(arg.view.type);
try {
window.localStorage.setItem(
CALENDAR_VIEW_STORAGE_KEY,
arg.view.type
);
} catch {
// ignore
}
}
}}
/>
)}
</div>
);
}
if (section === "map") {
return (
<div
key="map"
ref={mapRef}
data-pane="map"
className={`card drag-card ${
dragSource === "map" ? "dragging" : ""
} ${
dragOver === "map" && dragSource && dragSource !== "map"
? "drag-target"
: ""
} ${dragSource === "calendar" && dragOver === "map" ? "shift-down" : ""} ${
dragSource === "list" && dragOver === "map" ? "shift-up" : ""
}`}
onDragOver={(event) => event.preventDefault()}
onDragEnter={() => onDragEnter("map")}
onDrop={(event) => onDrop(event, "map")}
>
<div
className={`flex items-center justify-between ${
collapsed.map ? "" : "mb-3"
}`}
>
<h3 className="text-sm font-semibold text-slate-700">Karte</h3>
<div className="flex items-center gap-2">
<button
type="button"
className="rounded-full border border-slate-200 p-2 text-slate-600"
onClick={() => toggleCollapse("map")}
aria-label={collapsed.map ? "Karte aufklappen" : "Karte zuklappen"}
title={collapsed.map ? "Aufklappen" : "Zuklappen"}
>
{collapsed.map ? <IconChevronDown /> : <IconChevronUp />}
</button>
<button
type="button"
className="rounded-full border border-slate-200 p-2 text-slate-600"
onClick={() => setMapFullscreen(true)}
aria-label="Karte im Vollbild öffnen"
title="Vollbild"
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M4 9V4h5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M20 9V4h-5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M4 15v5h5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M20 15v5h-5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<div
className="drag-handle"
draggable
onDragStart={(event) => onDragStart(event, "map")}
onDragEnd={onDragEnd}
onPointerDown={(event) => onPointerDragStart(event, "map")}
title="Zum Tauschen ziehen"
aria-label="Karte verschieben"
>
<IconGrip />
</div>
</div>
</div>
{!collapsed.map && (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-600">
<button
type="button"
onClick={() => {
setMapDateFilter(new Set(["past", "today", "tomorrow", "future"]));
setMapCategoryFilter(new Set());
}}
className="inline-flex items-center rounded-full border border-slate-200 bg-white px-2 py-1"
>
Alles
</button>
<button
type="button"
onClick={() => {
setMapDateFilter(new Set());
setMapCategoryFilter(new Set(mapCategoryOptions));
}}
className="inline-flex items-center rounded-full border border-slate-200 bg-white px-2 py-1"
>
Nichts
</button>
<button
type="button"
onClick={() => toggleMapDateFilter("today")}
className={`inline-flex items-center gap-2 rounded-full border px-2 py-1 ${
mapDateFilter.has("today")
? "border-amber-300 bg-amber-50 text-amber-900"
: "border-slate-200 bg-white"
}`}
>
<span className="h-2.5 w-2.5 rounded-full border border-amber-500 bg-amber-200"></span>
Heute
</button>
<button
type="button"
onClick={() => toggleMapDateFilter("tomorrow")}
className={`inline-flex items-center gap-2 rounded-full border px-2 py-1 ${
mapDateFilter.has("tomorrow")
? "border-emerald-300 bg-emerald-50 text-emerald-900"
: "border-slate-200 bg-white"
}`}
>
<span className="h-2.5 w-2.5 rounded-full border border-emerald-500 bg-emerald-200"></span>
Morgen
</button>
<button
type="button"
onClick={() => toggleMapDateFilter("past")}
className={`inline-flex items-center gap-2 rounded-full border px-2 py-1 ${
mapDateFilter.has("past")
? "border-slate-300 bg-slate-100 text-slate-700"
: "border-slate-200 bg-white"
}`}
>
<span className="h-2.5 w-2.5 rounded-full border border-slate-300 bg-slate-200"></span>
Vergangenheit
</button>
<button
type="button"
onClick={() => toggleMapDateFilter("future")}
className={`inline-flex items-center gap-2 rounded-full border px-2 py-1 ${
mapDateFilter.has("future")
? "border-sky-200 bg-sky-50 text-sky-900"
: "border-slate-200 bg-white"
}`}
>
<span className="h-2.5 w-2.5 rounded-full border border-slate-300 bg-slate-100"></span>
Zukunft
</button>
{mapCategoryOptions.map((category) => {
const active = mapCategoryFilter.has(category);
return (
<button
key={category}
type="button"
onClick={() => toggleMapCategoryFilter(category)}
className={`inline-flex items-center gap-2 rounded-full border px-2 py-1 ${
active
? "border-slate-300 bg-slate-100 text-slate-900"
: "border-slate-200 bg-white"
}`}
>
<span
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: getCategoryColor(category) }}
></span>
{category}
</button>
);
})}
</div>
<div
className="h-80 overflow-hidden rounded-xl overscroll-contain"
onWheel={(event) => event.stopPropagation()}
>
<MapContainer
center={[52.52, 13.405]}
zoom={5}
scrollWheelZoom
className="h-full w-full"
>
<TileLayer
attribution={
isDarkTheme
? '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>'
: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}
url={
isDarkTheme
? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
}
/>
<MapAutoBounds points={mapPoints} />
{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 (
<CircleMarker
key={event.id}
center={[event.locationLat as number, event.locationLng as number]}
radius={isToday || isTomorrow ? 8 : 6}
color={strokeColor}
fillColor={fillColor}
fillOpacity={isPast ? 0.35 : 0.75}
weight={isToday || isTomorrow ? 2 : 1}
>
<Popup>
<div className="space-y-1">
<p className="text-sm font-semibold">{event.title}</p>
<p className="text-xs text-slate-600">
{new Date(event.startAt).toLocaleString("de-DE", {
dateStyle: "medium",
timeStyle: "short"
})}
</p>
<p className="text-xs text-slate-600">
{event.categoryName}
</p>
{event.location && (
<p className="text-xs text-slate-600">
{event.location}
</p>
)}
</div>
</Popup>
</CircleMarker>
);
})}
</MapContainer>
</div>
</div>
)}
</div>
);
}
return (
<div
key="list"
ref={listRef}
data-pane="list"
className={`card space-y-4 drag-card ${
dragSource === "list" ? "dragging" : ""
} ${
dragOver === "list" && dragSource && dragSource !== "list" ? "drag-target" : ""
} ${
dragSource === "calendar" && dragOver === "list" ? "shift-up" : ""
}`}
onDragOver={(event) => event.preventDefault()}
onDragEnter={() => onDragEnter("list")}
onDrop={(event) => onDrop(event, "list")}
>
<div className="flex items-center justify-between">
<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 p-2 text-slate-600"
onClick={() => toggleCollapse("list")}
aria-label={collapsed.list ? "Termine aufklappen" : "Termine zuklappen"}
title={collapsed.list ? "Aufklappen" : "Zuklappen"}
>
{collapsed.list ? <IconChevronDown /> : <IconChevronUp />}
</button>
<div
className="drag-handle"
draggable
onDragStart={(event) => onDragStart(event, "list")}
onDragEnd={onDragEnd}
onPointerDown={(event) => onPointerDragStart(event, "list")}
title="Zum Tauschen ziehen"
aria-label="Termine verschieben"
>
<IconGrip />
</div>
</div>
</div>
{!collapsed.list && (
<>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Suchen..."
className="min-w-[220px] rounded-xl border border-slate-300 px-3 py-2 text-sm"
/>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className={filterButtonClass(listQuickRange === "next7")}
onClick={() =>
setListQuickRange((current) =>
current === "next7" ? null : "next7"
)
}
>
7 Tage
</button>
<button
type="button"
className={filterButtonClass(listQuickRange === "next30")}
onClick={() =>
setListQuickRange((current) =>
current === "next30" ? null : "next30"
)
}
>
30 Tage
</button>
<button
type="button"
className={filterButtonClass(listQuickRange === "nextYear")}
onClick={() =>
setListQuickRange((current) =>
current === "nextYear" ? null : "nextYear"
)
}
>
1 Jahr
</button>
<button
type="button"
className={filterButtonClass(hidePastInList)}
onClick={() => setHidePastInList((current) => !current)}
>
Alte ausblenden
</button>
</div>
</div>
{isAdmin && (
<div className="flex flex-wrap items-center gap-2">
{bulkSelection.size > 0 && (
<button
type="button"
className="rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-700"
onClick={() => toggleSelectAllVisible(false)}
>
Auswahl löschen
</button>
)}
{bulkSelection.size > 0 && (
<span className="text-xs text-slate-500">
{bulkSelection.size} ausgewählt
</span>
)}
{bulkSelection.size > 0 && (
<button
type="button"
className="rounded-full border border-red-200 p-2 text-red-600"
onClick={deleteSelectedEvents}
aria-label="Ausgewählte löschen"
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M3 6h18" strokeLinecap="round" />
<path d="M8 6V4h8v2" strokeLinecap="round" />
<path d="M19 6l-1 14H6L5 6" strokeLinecap="round" />
<path d="M10 11v6M14 11v6" strokeLinecap="round" />
</svg>
</button>
)}
</div>
)}
</div>
<div className="md:hidden space-y-2">
{displayedEvents.length === 0 ? (
<p className="rounded-xl border border-slate-200 bg-white px-3 py-4 text-sm text-slate-600">
Keine Termine für die aktuelle Auswahl.
</p>
) : (
displayedEvents.map((event) => {
const categoryName = event.category?.name || "Ohne Kategorie";
const locationLabel = formatLocation(event.location);
const dateLabel = new Date(event.startAt).toLocaleString("de-DE", {
dateStyle: "medium",
timeStyle: "short"
});
const bucket = getDateBucket(event.startAt);
const bucketClass =
bucket === "past"
? "bg-slate-50 text-slate-500"
: bucket === "today"
? "bg-amber-50/60"
: bucket === "tomorrow"
? "bg-emerald-50/60"
: "bg-sky-50/40";
return (
<div
key={event.id}
className={`rounded-xl border border-slate-200 p-2 ${bucketClass}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 space-y-0.5">
<p className="text-[11px] uppercase tracking-[0.2em] text-slate-500">
{dateLabel}
</p>
<p className="text-sm font-semibold" title={event.title}>
{event.title}
</p>
<div className="flex flex-wrap items-center gap-2 text-[11px] text-slate-600">
<span className="truncate" title={categoryName}>
{categoryName}
</span>
<span></span>
<span className="truncate" title={locationLabel}>
{locationLabel}
</span>
</div>
</div>
{isAdmin && (
<input
type="checkbox"
aria-label={`${event.title} auswählen`}
checked={bulkSelection.has(event.id)}
onChange={() => toggleBulkSelection(event.id)}
/>
)}
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex flex-nowrap items-center gap-1">
{canManageView && (
<ViewToggleButton
isSelected={selectedEventIds.has(event.id)}
onClick={() => toggleEvent(event.id)}
size="sm"
/>
)}
{isAdmin && (
<button
type="button"
className="rounded-full border border-slate-200 p-1.5 text-slate-600"
onClick={() => {
setEditStatus(null);
setEditError(null);
setEditEvent(event);
setIsEditOpen(true);
}}
aria-label="Termin bearbeiten"
>
<svg
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path
d="M4 20h4l10-10-4-4L4 16v4z"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M13 7l4 4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
)}
<button
type="button"
className="rounded-full border border-slate-200 p-1.5 text-slate-600"
onClick={() => setDetailsEvent(event)}
aria-label="Termin Details"
>
<svg
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="9" />
<path d="M12 8h.01M12 12v4" strokeLinecap="round" />
</svg>
</button>
</div>
{event.locationLat && event.locationLng && (
<a
className="inline-flex items-center justify-center rounded-full border border-slate-200 p-1.5 text-slate-600"
href={`https://maps.google.com/?q=${event.locationLat},${event.locationLng}&z=14`}
target="_blank"
rel="noreferrer"
title="Google Maps"
aria-label="Google Maps"
>
<IconMapPin className="h-3.5 w-3.5" />
</a>
)}
</div>
</div>
);
})
)}
</div>
<div className="hidden overflow-x-auto md:block">
<table className="list-table w-full table-fixed text-left text-sm">
<thead className="text-xs uppercase tracking-[0.2em] text-slate-500">
<tr>
{isAdmin && (
<th className={`pb-2 pl-2 ${tableWidths.checkbox}`}>
<input
type="checkbox"
aria-label="Alle sichtbaren Termine auswählen"
checked={
displayedEvents.length > 0 &&
displayedEvents.every((event) =>
bulkSelection.has(event.id)
)
}
onChange={(event) =>
toggleSelectAllVisible(event.target.checked)
}
/>
</th>
)}
<th className={`pb-2 whitespace-nowrap ${tableWidths.date}`}>
<SortButton
label="Datum"
active={sortKey === "startAt"}
direction={sortDir}
onClick={() => toggleSort("startAt")}
/>
</th>
<th className={`pb-2 ${tableWidths.title}`}>
<SortButton
label="Titel"
active={sortKey === "title"}
direction={sortDir}
onClick={() => toggleSort("title")}
/>
</th>
<th className={`pb-2 ${tableWidths.category}`}>
<div className="flex flex-wrap items-center gap-2">
<SortButton
label="Kategorie"
active={sortKey === "category"}
direction={sortDir}
onClick={() => toggleSort("category")}
/>
<select
className="max-w-full rounded-full border border-slate-200 bg-white px-2 py-0.5 text-xs text-slate-600"
value={categoryFilter}
onChange={(event) => setCategoryFilter(event.target.value)}
aria-label="Kategorie filtern"
>
<option value="ALL">Alle</option>
{categoryOptions.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
</div>
</th>
<th className={`pb-2 ${tableWidths.location}`}>
<SortButton
label="Ort"
active={sortKey === "location"}
direction={sortDir}
onClick={() => toggleSort("location")}
/>
</th>
<th className={`pb-2 whitespace-nowrap ${tableWidths.actions}`}>
Aktionen
</th>
</tr>
</thead>
<tbody>
{displayedEvents.length === 0 ? (
<tr>
<td colSpan={isAdmin ? 6 : 5} className="py-4 text-slate-600">
Keine Termine für die aktuelle Auswahl.
</td>
</tr>
) : (
displayedEvents.map((event) => {
const categoryName = event.category?.name || "Ohne Kategorie";
const locationLabel = formatLocation(event.location);
const dateLabel = new Date(event.startAt).toLocaleString("de-DE", {
dateStyle: "medium",
timeStyle: "short"
});
return (
<tr
key={event.id}
data-bucket={getDateBucket(event.startAt)}
className={`border-t border-slate-200 ${
getDateBucket(event.startAt) === "past"
? "bg-slate-50 text-slate-500"
: getDateBucket(event.startAt) === "today"
? "bg-amber-50/60"
: getDateBucket(event.startAt) === "tomorrow"
? "bg-emerald-50/60"
: "bg-sky-50/40"
}`}
>
{isAdmin && (
<td className="py-2 pr-3 pl-2">
<input
type="checkbox"
aria-label={`${event.title} auswählen`}
checked={bulkSelection.has(event.id)}
onChange={() => toggleBulkSelection(event.id)}
/>
</td>
)}
<td className="py-2 pr-3 pl-2">
<span className="block w-full truncate whitespace-nowrap" title={dateLabel}>
{dateLabel}
</span>
</td>
<td className="py-2 pr-3 font-medium">
<span
className="block w-full truncate"
title={event.title}
>
{event.title}
</span>
</td>
<td className="py-2 pr-3">
<span
className="block w-full truncate"
title={categoryName}
>
{categoryName}
</span>
</td>
<td className="py-2 pr-3">
<div className="flex items-center justify-between gap-2">
<span
className="min-w-0 flex-1 truncate"
title={locationLabel}
>
{locationLabel}
</span>
{event.locationLat && event.locationLng && (
<a
className="inline-flex items-center justify-center rounded-full border border-slate-200 p-1 text-slate-600 hover:bg-slate-100"
href={`https://maps.google.com/?q=${event.locationLat},${event.locationLng}&z=14`}
target="_blank"
rel="noreferrer"
title="Google Maps"
aria-label="Google Maps"
>
<IconMapPin />
</a>
)}
</div>
</td>
<td className="py-2 pr-3 whitespace-nowrap">
<div className="flex flex-nowrap justify-end gap-1">
{canManageView && (
<ViewToggleButton
isSelected={selectedEventIds.has(event.id)}
onClick={() => toggleEvent(event.id)}
size="sm"
/>
)}
{isAdmin && (
<button
type="button"
className="rounded-full border border-slate-200 p-2 text-slate-600"
onClick={() => {
setEditStatus(null);
setEditError(null);
setEditEvent(event);
setIsEditOpen(true);
}}
aria-label="Termin bearbeiten"
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path
d="M4 20h4l10-10-4-4L4 16v4z"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M13 7l4 4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
)}
<button
type="button"
className="rounded-full border border-slate-200 p-2 text-slate-600"
onClick={() => setDetailsEvent(event)}
aria-label="Termin Details"
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="9" />
<path d="M12 8h.01M12 12v4" strokeLinecap="round" />
</svg>
</button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</>
)}
</div>
);
})}
{detailsEvent && portalRoot
? createPortal(
<div
className="fixed inset-0 z-30 flex items-center justify-center bg-black/40 px-4 py-6"
onClick={() => setDetailsEvent(null)}
>
<div
className="card w-full max-w-2xl max-h-[90vh] overflow-y-auto"
ref={detailsModalRef}
role="dialog"
aria-modal="true"
tabIndex={-1}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key !== "Tab") return;
const node = detailsModalRef.current;
if (!node) return;
const focusable = node.querySelectorAll<HTMLElement>(
'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();
}
}}
>
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Termin Details</h3>
<div className="flex items-center gap-2">
{isAdmin && (
<button
type="button"
className="rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-700"
onClick={() => {
setEditStatus(null);
setEditError(null);
setEditEvent(detailsEvent);
setIsEditOpen(true);
setDetailsEvent(null);
}}
>
Bearbeiten
</button>
)}
<button
type="button"
className="rounded-full border border-slate-200 p-2 text-slate-600 hover:bg-slate-100"
onClick={() => setDetailsEvent(null)}
aria-label="Schließen"
title="Schließen"
>
<IconClose />
</button>
</div>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
Titel
</p>
<p className="text-sm font-semibold">{detailsEvent.title}</p>
</div>
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
Zeitpunkt
</p>
<p className="text-sm">
{new Date(detailsEvent.startAt).toLocaleString("de-DE", {
dateStyle: "full",
timeStyle: "short"
})}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
Ort
</p>
<p className="text-sm">{detailsEvent.location || "-"}</p>
</div>
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
Kategorie
</p>
<p className="text-sm">
{detailsEvent.category?.name || "Ohne Kategorie"}
</p>
</div>
{detailsEvent.description && (
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
Beschreibung
</p>
<p className="text-sm text-slate-700">
{renderLinkedText(detailsEvent.description)}
</p>
</div>
)}
{detailsEvent.locationLat && detailsEvent.locationLng && (
<div className="flex flex-wrap gap-2">
<a
className="btn-ghost"
href={`https://maps.google.com/?q=${detailsEvent.locationLat},${detailsEvent.locationLng}&z=14`}
target="_blank"
rel="noreferrer"
>
Google Maps
</a>
</div>
)}
</div>
<div className="min-h-[240px] overflow-hidden rounded-xl border border-slate-200 bg-slate-100">
{placesProvider === "google" &&
placesKey &&
(detailsEvent.locationPlaceId ||
(detailsEvent.locationLat && detailsEvent.locationLng)) ? (
<iframe
title="Karte"
className="h-[320px] w-full"
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
src={
detailsEvent.locationPlaceId
? `https://www.google.com/maps/embed/v1/place?key=${placesKey}&q=place_id:${detailsEvent.locationPlaceId}&zoom=14`
: `https://www.google.com/maps/embed/v1/place?key=${placesKey}&q=${detailsEvent.locationLat},${detailsEvent.locationLng}&zoom=14`
}
/>
) : placesProvider === "osm" &&
detailsEvent.locationLat &&
detailsEvent.locationLng ? (
<iframe
title="Karte"
className="h-[320px] w-full"
loading="lazy"
src={`https://www.openstreetmap.org/export/embed.html?bbox=${
detailsEvent.locationLng - 0.0045
},${detailsEvent.locationLat - 0.0045},${
detailsEvent.locationLng + 0.0045
},${detailsEvent.locationLat + 0.0045}&layer=mapnik&marker=${detailsEvent.locationLat},${detailsEvent.locationLng}`}
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-slate-500">
Keine Karte verfügbar.
</div>
)}
</div>
</div>
</div>
</div>,
portalRoot
)
: null}
{isEditOpen && editEvent && portalRoot
? createPortal(
<div
className="fixed inset-0 z-30 flex items-center justify-center bg-black/40 px-4 py-6"
onClick={() => {
setIsEditOpen(false);
setEditEvent(null);
}}
>
<div
className="card w-full max-w-2xl max-h-[90vh] overflow-y-auto"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Termin bearbeiten</h3>
<button
type="button"
className="rounded-full border border-slate-200 p-2 text-slate-600 hover:bg-slate-100"
onClick={() => {
setIsEditOpen(false);
setEditEvent(null);
}}
aria-label="Schließen"
title="Schließen"
>
<IconClose />
</button>
</div>
<form onSubmit={updateEvent} className="mt-4 space-y-3">
<div className="grid gap-3 md:grid-cols-2">
<input
name="title"
required
defaultValue={editEvent.title}
className="rounded-xl border border-slate-300 px-3 py-2"
placeholder="Titel"
/>
<input
name="location"
defaultValue={editEvent.location || ""}
className="rounded-xl border border-slate-300 px-3 py-2"
placeholder="Ort"
/>
<input
type="hidden"
name="locationPlaceId"
value={editEvent.locationPlaceId || ""}
/>
<input
type="hidden"
name="locationLat"
value={editEvent.locationLat ?? ""}
/>
<input
type="hidden"
name="locationLng"
value={editEvent.locationLng ?? ""}
/>
<input
name="startAt"
type="datetime-local"
required
defaultValue={formatLocalDateTime(editEvent.startAt)}
className="rounded-xl border border-slate-300 px-3 py-2"
/>
<input
name="endAt"
type="datetime-local"
defaultValue={formatLocalDateTime(editEvent.endAt)}
className="rounded-xl border border-slate-300 px-3 py-2"
/>
<select
name="categoryId"
required
defaultValue={editEvent.category?.id || ""}
className="rounded-xl border border-slate-300 px-3 py-2 md:col-span-2"
>
<option value="" disabled>
Kategorie wählen
</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</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"
defaultValue={editEvent.description || ""}
placeholder="Beschreibung"
className="min-h-[96px] rounded-xl border border-slate-300 px-3 py-2"
/>
<button type="submit" className="btn-accent">
Speichern
</button>
<button
type="button"
className="rounded-full border border-red-200 px-3 py-1 text-xs text-red-600"
onClick={() => deleteEvent(editEvent.id)}
>
Löschen
</button>
{editStatus && (
<p className="text-sm text-emerald-600">{editStatus}</p>
)}
{editError && (
<p className="text-sm text-red-600">{editError}</p>
)}
</form>
</div>
</div>,
portalRoot
)
: null}
{mapFullscreen && portalRoot
? createPortal(
<div
className="fixed inset-0 z-40 flex flex-col bg-slate-950/40 p-4"
onClick={() => setMapFullscreen(false)}
>
<div
className="card flex h-full w-full flex-col"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Karte</h3>
<button
type="button"
className="rounded-full border border-slate-200 p-2 text-slate-600 hover:bg-slate-100"
onClick={() => setMapFullscreen(false)}
aria-label="Schließen"
title="Schließen"
>
<IconClose />
</button>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-slate-600">
<button
type="button"
onClick={() => {
setMapDateFilter(new Set(["past", "today", "tomorrow", "future"]));
setMapCategoryFilter(new Set());
}}
className="inline-flex items-center rounded-full border border-slate-200 bg-white px-2 py-1"
>
Alles
</button>
<button
type="button"
onClick={() => {
setMapDateFilter(new Set());
setMapCategoryFilter(new Set(mapCategoryOptions));
}}
className="inline-flex items-center rounded-full border border-slate-200 bg-white px-2 py-1"
>
Nichts
</button>
<button
type="button"
onClick={() => toggleMapDateFilter("today")}
className={`inline-flex items-center gap-2 rounded-full border px-2 py-1 ${
mapDateFilter.has("today")
? "border-amber-300 bg-amber-50 text-amber-900"
: "border-slate-200 bg-white"
}`}
>
<span className="h-2.5 w-2.5 rounded-full border border-amber-500 bg-amber-200"></span>
Heute
</button>
<button
type="button"
onClick={() => toggleMapDateFilter("tomorrow")}
className={`inline-flex items-center gap-2 rounded-full border px-2 py-1 ${
mapDateFilter.has("tomorrow")
? "border-emerald-300 bg-emerald-50 text-emerald-900"
: "border-slate-200 bg-white"
}`}
>
<span className="h-2.5 w-2.5 rounded-full border border-emerald-500 bg-emerald-200"></span>
Morgen
</button>
<button
type="button"
onClick={() => toggleMapDateFilter("past")}
className={`inline-flex items-center gap-2 rounded-full border px-2 py-1 ${
mapDateFilter.has("past")
? "border-slate-300 bg-slate-100 text-slate-700"
: "border-slate-200 bg-white"
}`}
>
<span className="h-2.5 w-2.5 rounded-full border border-slate-300 bg-slate-200"></span>
Vergangenheit
</button>
<button
type="button"
onClick={() => toggleMapDateFilter("future")}
className={`inline-flex items-center gap-2 rounded-full border px-2 py-1 ${
mapDateFilter.has("future")
? "border-sky-200 bg-sky-50 text-sky-900"
: "border-slate-200 bg-white"
}`}
>
<span className="h-2.5 w-2.5 rounded-full border border-slate-300 bg-slate-100"></span>
Zukunft
</button>
{mapCategoryOptions.map((category) => {
const active = mapCategoryFilter.has(category);
return (
<button
key={category}
type="button"
onClick={() => toggleMapCategoryFilter(category)}
className={`inline-flex items-center gap-2 rounded-full border px-2 py-1 ${
active
? "border-slate-300 bg-slate-100 text-slate-900"
: "border-slate-200 bg-white"
}`}
>
<span
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: getCategoryColor(category) }}
></span>
{category}
</button>
);
})}
</div>
<div
className="mt-4 flex-1 overflow-hidden rounded-xl overscroll-contain"
onWheel={(event) => event.stopPropagation()}
>
<MapContainer
center={[52.52, 13.405]}
zoom={5}
scrollWheelZoom
className="h-full w-full"
>
<TileLayer
attribution={
isDarkTheme
? '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>'
: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}
url={
isDarkTheme
? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
}
/>
<MapAutoBounds points={mapPoints} />
{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 (
<CircleMarker
key={event.id}
center={[event.locationLat as number, event.locationLng as number]}
radius={isToday || isTomorrow ? 8 : 6}
color={strokeColor}
fillColor={fillColor}
fillOpacity={isPast ? 0.35 : 0.75}
weight={isToday || isTomorrow ? 2 : 1}
>
<Popup>
<div className="space-y-1">
<p className="text-sm font-semibold">{event.title}</p>
<p className="text-xs text-slate-600">
{new Date(event.startAt).toLocaleString("de-DE", {
dateStyle: "medium",
timeStyle: "short"
})}
</p>
<p className="text-xs text-slate-600">
{event.categoryName}
</p>
{event.location && (
<p className="text-xs text-slate-600">
{event.location}
</p>
)}
</div>
</Popup>
</CircleMarker>
);
})}
</MapContainer>
</div>
</div>
</div>,
portalRoot
)
: null}
</section>
);
}
function formatLocation(value?: string | null) {
if (!value) return "-";
const parts = value.split(",").map((part) => part.trim()).filter(Boolean);
if (parts.length === 0) return value;
const postal = parts.find((part) => /\b\d{5}\b/.test(part));
const street = parts[0] || "";
const city = parts[1] || "";
if (postal && city) {
return street ? `${street}, ${postal} ${city}` : `${postal} ${city}`;
}
if (postal) {
return street ? `${street}, ${postal}` : postal;
}
const main = parts.slice(0, 2).join(", ");
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(() => {
if (points.length === 0) return;
const bounds = points.map((point) => [point.lat, point.lng]) as [
number,
number
][];
map.fitBounds(bounds, { padding: [24, 24] });
}, [map, points]);
return null;
}
function StatusIcon({ status }: { status: string }) {
if (status === "APPROVED") {
return (
<span title="Freigegeben" aria-label="Freigegeben" className="text-emerald-600">
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 12l4 4L19 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
);
}
if (status === "REJECTED") {
return (
<span title="Abgelehnt" aria-label="Abgelehnt" className="text-red-600">
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M6 6l12 12M18 6l-12 12" strokeLinecap="round" />
</svg>
</span>
);
}
return (
<span title="Offen" aria-label="Offen" className="text-amber-600">
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 6v6l4 2" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="12" cy="12" r="9" />
</svg>
</span>
);
}
function ViewToggleButton({
isSelected,
onClick,
size = "md"
}: {
isSelected: boolean;
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
size?: "sm" | "md";
}) {
const base =
size === "sm"
? "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";
return (
<button
type="button"
className={base}
onClick={onClick}
aria-label={label}
title={label}
>
{isSelected ? <IconBell className={iconClass} /> : <IconSleep className={iconClass} />}
</button>
);
}
function IconBell({ className = "h-4 w-4" }: { className?: string }) {
return (
<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({ className = "h-4 w-4" }: { className?: string }) {
return (
<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" />
</svg>
);
}
function IconGrip() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="8" cy="6" r="1" />
<circle cx="16" cy="6" r="1" />
<circle cx="8" cy="12" r="1" />
<circle cx="16" cy="12" r="1" />
<circle cx="8" cy="18" r="1" />
<circle cx="16" cy="18" r="1" />
</svg>
);
}
function IconClose() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M6 6l12 12M18 6l-12 12" strokeLinecap="round" />
</svg>
);
}
function IconChevronDown() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M6 9l6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function IconChevronUp() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M6 15l6-6 6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function SortButton({
label,
active,
direction,
onClick
}: {
label: string;
active: boolean;
direction: "asc" | "desc";
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`inline-flex items-center gap-1 text-xs uppercase tracking-[0.2em] ${
active ? "text-slate-900" : "text-slate-500"
}`}
title="Sortieren"
>
{label}
<SortIcon direction={direction} active={active} />
</button>
);
}
function SortIcon({
direction,
active
}: {
direction: "asc" | "desc";
active: boolean;
}) {
return (
<svg
viewBox="0 0 24 24"
className={`h-3 w-3 ${active ? "opacity-100" : "opacity-40"}`}
fill="none"
stroke="currentColor"
strokeWidth="2"
>
{direction === "asc" ? (
<path d="M6 15l6-6 6 6" strokeLinecap="round" strokeLinejoin="round" />
) : (
<path d="M6 9l6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
)}
</svg>
);
}
function IconMapPin({ className = "h-4 w-4" }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 21s6-6.5 6-11a6 6 0 10-12 0c0 4.5 6 11 6 11z" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="12" cy="10" r="2.5" />
</svg>
);
}