Aktueller Stand

This commit is contained in:
2026-01-15 16:24:09 +01:00
parent 5d2630a02f
commit 46eae2a2a9
70 changed files with 7866 additions and 447 deletions

View File

@@ -9,18 +9,40 @@ type EventItem = {
endAt: string;
status: string;
location?: string | null;
locationPlaceId?: string | null;
locationLat?: number | null;
locationLng?: number | null;
description?: string | null;
category?: { id: string; name: string } | null;
};
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 [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 [editEvent, setEditEvent] = useState<EventItem | null>(null);
const [editStatus, setEditStatus] = useState<string | null>(null);
const [editError, setEditError] = useState<string | null>(null);
const [isEditOpen, setIsEditOpen] = useState(false);
const [importFile, setImportFile] = useState<File | null>(null);
const [importCategoryId, setImportCategoryId] = useState("");
const [importStatus, setImportStatus] = useState<string | null>(null);
const [importError, setImportError] = useState<string | null>(null);
const load = async () => {
try {
const response = await fetch("/api/events?status=PENDING");
if (!response.ok) {
throw new Error("Vorschlaege konnten nicht geladen werden.");
throw new Error("Vorschläge konnten nicht geladen werden.");
}
setEvents(await response.json());
} catch (err) {
@@ -28,8 +50,34 @@ export default function AdminPanel() {
}
};
const loadAllEvents = async () => {
try {
const response = await fetch("/api/events");
if (!response.ok) {
throw new Error("Termine konnten nicht geladen werden.");
}
setAllEvents(await response.json());
} catch (err) {
setError((err as Error).message);
}
};
const loadCategories = async () => {
try {
const response = await fetch("/api/categories");
if (!response.ok) {
throw new Error("Kategorien konnten nicht geladen werden.");
}
setCategories(await response.json());
} catch (err) {
setCategoryError((err as Error).message);
}
};
useEffect(() => {
load();
loadCategories();
loadAllEvents();
}, []);
const updateStatus = async (id: string, status: "APPROVED" | "REJECTED") => {
@@ -39,18 +87,230 @@ export default function AdminPanel() {
body: JSON.stringify({ status })
});
load();
loadAllEvents();
};
const deleteEvent = async (id: string) => {
const ok = window.confirm("Termin wirklich löschen?");
if (!ok) return;
await fetch(`/api/events/${id}`, { method: "DELETE" });
load();
loadAllEvents();
};
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<HTMLFormElement>) => {
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;
}
const updated = await response.json();
setEditStatus("Termin aktualisiert.");
setEditEvent(updated);
load();
loadAllEvents();
setIsEditOpen(false);
};
const createCategory = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setCategoryError(null);
setCategoryStatus(null);
const formData = new FormData(event.currentTarget);
const rawName = String(formData.get("name") || "").trim();
if (!rawName) {
setCategoryError("Name erforderlich.");
return;
}
const response = await fetch("/api/categories", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: rawName })
});
if (!response.ok) {
const data = await response.json();
setCategoryError(data.error || "Kategorie konnte nicht angelegt werden.");
return;
}
const created = await response.json();
setCategories((prev) => {
if (prev.some((item) => item.id === created.id)) return prev;
return [...prev, created].sort((a, b) => a.name.localeCompare(b.name));
});
event.currentTarget.reset();
setCategoryStatus("Kategorie angelegt.");
};
const openCategoryModal = (category: { id: string; name: string }) => {
setEditingCategory(category);
setCategoryModalError(null);
setCategoryModalStatus(null);
setCategoryModalOpen(true);
};
const closeCategoryModal = () => {
setEditingCategory(null);
setCategoryModalOpen(false);
};
const updateCategory = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!editingCategory) return;
setCategoryModalError(null);
setCategoryModalStatus(null);
const formData = new FormData(event.currentTarget);
const name = String(formData.get("name") || "").trim();
if (!name) {
setCategoryModalError("Name erforderlich.");
return;
}
const response = await fetch("/api/categories", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: editingCategory.id, name })
});
if (!response.ok) {
const data = await response.json();
setCategoryModalError(data.error || "Kategorie konnte nicht aktualisiert werden.");
return;
}
const updated = await response.json();
setCategories((prev) =>
prev
.map((item) => (item.id === updated.id ? updated : item))
.sort((a, b) => a.name.localeCompare(b.name))
);
setCategoryModalStatus("Kategorie aktualisiert.");
setCategoryModalOpen(false);
};
const deleteCategory = async (categoryId: string) => {
const ok = window.confirm("Kategorie wirklich löschen? Zugeordnete Termine bleiben erhalten.");
if (!ok) return;
const response = await fetch(`/api/categories?id=${categoryId}`, {
method: "DELETE"
});
if (!response.ok) {
const data = await response.json();
setCategoryError(data.error || "Kategorie konnte nicht gelöscht werden.");
return;
}
setCategories((prev) => prev.filter((item) => item.id !== categoryId));
setCategoryStatus("Kategorie gelöscht.");
};
const importIcal = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setImportStatus(null);
setImportError(null);
if (!importFile) {
setImportError("Bitte eine iCal-Datei auswählen.");
return;
}
if (!importCategoryId) {
setImportError("Bitte eine Kategorie auswählen.");
return;
}
const formData = new FormData();
formData.append("file", importFile);
formData.append("categoryId", importCategoryId);
const response = await fetch("/api/ical/import", {
method: "POST",
body: formData
});
if (!response.ok) {
const data = await response.json();
setImportError(data.error || "Import fehlgeschlagen.");
return;
}
const data = await response.json();
const details = [
`${data.created || 0} importiert`,
`${data.duplicates || 0} doppelt`,
`${data.skipped || 0} übersprungen`
];
if (data.recurringSkipped) {
details.push(`${data.recurringSkipped} wiederkehrend`);
}
setImportStatus(`Import abgeschlossen: ${details.join(", ")}.`);
setImportFile(null);
setImportCategoryId("");
load();
loadAllEvents();
};
return (
<section className="space-y-4">
<h1 className="text-2xl font-semibold">Adminfreigaben</h1>
<section className="space-y-4 fade-up">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">Admin</p>
<h1 className="text-2xl font-semibold">Offene Vorschläge</h1>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
{events.length === 0 ? (
<p className="text-slate-600">Keine offenen Vorschlaege.</p>
<div className="card-muted">
<p className="text-slate-600">Keine offenen Vorschläge.</p>
</div>
) : (
<div className="space-y-3">
{events.map((event) => (
<div key={event.id} className="rounded border border-slate-200 bg-white p-4">
<div key={event.id} className="card">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-lg font-medium">{event.title}</h2>
@@ -65,14 +325,14 @@ export default function AdminPanel() {
<button
type="button"
onClick={() => updateStatus(event.id, "APPROVED")}
className="rounded bg-emerald-600 px-3 py-1.5 text-white"
className="rounded-full bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white"
>
Freigeben
</button>
<button
type="button"
onClick={() => updateStatus(event.id, "REJECTED")}
className="rounded bg-red-600 px-3 py-1.5 text-white"
className="rounded-full bg-red-600 px-3 py-1.5 text-sm font-semibold text-white"
>
Ablehnen
</button>
@@ -85,6 +345,331 @@ export default function AdminPanel() {
))}
</div>
)}
<section className="card space-y-4">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
Kategorien
</p>
<h2 className="text-lg font-semibold">Kategorien verwalten</h2>
</div>
<form onSubmit={createCategory} className="flex flex-wrap gap-2">
<input
name="name"
required
placeholder="z.B. Training"
className="flex-1 rounded-xl border border-slate-300 px-3 py-2"
/>
<button className="btn-accent" type="submit">
Anlegen
</button>
</form>
{categoryStatus && (
<p className="text-sm text-emerald-600">{categoryStatus}</p>
)}
{categoryError && (
<p className="text-sm text-red-600">{categoryError}</p>
)}
<div className="space-y-2">
{categories.length === 0 ? (
<span className="text-sm text-slate-600">
Noch keine Kategorien.
</span>
) : (
categories.map((category) => (
<div
key={category.id}
className="flex items-center justify-between rounded-xl border border-slate-200 px-3 py-2 text-sm text-slate-700"
>
<span>{category.name}</span>
<div className="flex gap-2">
<button
type="button"
className="rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-700"
onClick={() => openCategoryModal(category)}
>
Bearbeiten
</button>
<button
type="button"
className="rounded-full border border-red-200 px-3 py-1 text-xs text-red-600"
onClick={() => deleteCategory(category.id)}
>
Löschen
</button>
</div>
</div>
))
)}
</div>
</section>
{categoryModalOpen && editingCategory && (
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/40 px-4 py-6">
<div className="card w-full max-w-md">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Kategorie bearbeiten</h3>
<button
type="button"
className="text-sm text-slate-600"
onClick={closeCategoryModal}
>
Schließen
</button>
</div>
<form onSubmit={updateCategory} className="mt-4 space-y-3">
<input
name="name"
defaultValue={editingCategory.name}
required
className="w-full rounded-xl border border-slate-300 px-3 py-2"
/>
<button type="submit" className="btn-accent w-full">
Speichern
</button>
</form>
{categoryModalStatus && (
<p className="mt-3 text-sm text-emerald-600">{categoryModalStatus}</p>
)}
{categoryModalError && (
<p className="mt-3 text-sm text-red-600">{categoryModalError}</p>
)}
</div>
</div>
)}
<section className="card space-y-4">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
iCal
</p>
<h2 className="text-lg font-semibold">iCal-Import</h2>
<p className="text-sm text-slate-600">
Lade eine iCal-Datei hoch, um Termine direkt zu importieren.
</p>
</div>
<form onSubmit={importIcal} className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<input
type="file"
accept=".ics,text/calendar"
onChange={(event) =>
setImportFile(event.currentTarget.files?.[0] || null)
}
className="block text-sm text-slate-600"
/>
<select
value={importCategoryId}
onChange={(event) => setImportCategoryId(event.target.value)}
className="rounded-xl border border-slate-300 px-3 py-2 text-sm"
required
>
<option value="" disabled>
Kategorie wählen
</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
<button type="submit" className="btn-accent">
Import starten
</button>
</div>
{importStatus && <p className="text-sm text-emerald-600">{importStatus}</p>}
{importError && <p className="text-sm text-red-600">{importError}</p>}
</form>
</section>
<section className="card space-y-4">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
Termine
</p>
<h2 className="text-lg font-semibold">Alle Termine verwalten</h2>
</div>
{isEditOpen && editEvent && (
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/40 px-4 py-6">
<div className="card w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Termin bearbeiten</h3>
<button
type="button"
className="text-sm text-slate-600"
onClick={() => {
setEditEvent(null);
setEditError(null);
setEditStatus(null);
setIsEditOpen(false);
}}
>
Schließen
</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 waehlen
</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
</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>
{editStatus && <p className="text-sm text-emerald-600">{editStatus}</p>}
{editError && <p className="text-sm text-red-600">{editError}</p>}
</form>
</div>
</div>
)}
<div className="overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead className="text-xs uppercase tracking-[0.2em] text-slate-500">
<tr>
<th className="pb-2">Datum</th>
<th className="pb-2">Titel</th>
<th className="pb-2">Kategorie</th>
<th className="pb-2">Status</th>
<th className="pb-2">Aktion</th>
</tr>
</thead>
<tbody>
{allEvents.length === 0 ? (
<tr>
<td colSpan={5} className="py-4 text-slate-600">
Keine Termine vorhanden.
</td>
</tr>
) : (
allEvents.map((event) => (
<tr key={event.id} className="border-t border-slate-200">
<td className="py-3 pr-3">
{new Date(event.startAt).toLocaleString("de-DE", {
dateStyle: "medium",
timeStyle: "short"
})}
</td>
<td className="py-3 pr-3 font-medium">{event.title}</td>
<td className="py-3 pr-3">
{event.category?.name || "Ohne Kategorie"}
</td>
<td className="py-3 pr-3">
<div className="flex items-center gap-2">
<StatusIcon status={event.status} />
</div>
</td>
<td className="py-3 pr-3">
<div className="flex flex-wrap gap-2">
<button
type="button"
className="rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-700"
onClick={() => {
setEditEvent(event);
setIsEditOpen(true);
}}
>
Bearbeiten
</button>
<button
type="button"
className="rounded-full border border-red-200 px-3 py-1 text-xs text-red-600"
onClick={() => deleteEvent(event.id)}
>
Löschen
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
</section>
);
}
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>
);
}

View File

@@ -0,0 +1,202 @@
"use client";
import { useEffect, useState } from "react";
export default function AdminSystemSettings() {
const [apiKey, setApiKey] = useState("");
const [provider, setProvider] = useState("osm");
const [registrationEnabled, setRegistrationEnabled] = useState(true);
const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoVersion, setLogoVersion] = useState(() => Date.now());
const [hasLogo, setHasLogo] = useState<boolean | null>(null);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const load = async () => {
try {
const response = await fetch("/api/settings/google-places");
if (!response.ok) {
throw new Error("Einstellungen konnten nicht geladen werden.");
}
const payload = await response.json();
setApiKey(payload.apiKey || "");
setProvider(payload.provider || "osm");
setRegistrationEnabled(payload.registrationEnabled !== false);
} catch (err) {
setError((err as Error).message);
}
};
const loadLogoStatus = async () => {
try {
const response = await fetch("/api/branding/logo", {
method: "HEAD",
cache: "no-store"
});
setHasLogo(response.ok);
} catch {
setHasLogo(false);
}
};
useEffect(() => {
load();
loadLogoStatus();
}, []);
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setStatus(null);
setError(null);
const response = await fetch("/api/settings/google-places", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey, provider, registrationEnabled })
});
if (!response.ok) {
const data = await response.json();
setError(data.error || "Speichern fehlgeschlagen.");
return;
}
setStatus("Gespeichert.");
};
const onLogoUpload = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setStatus(null);
setError(null);
if (!logoFile) {
setError("Bitte ein Logo auswählen.");
return;
}
const formData = new FormData();
formData.append("file", logoFile);
const response = await fetch("/api/settings/logo", {
method: "POST",
body: formData
});
if (!response.ok) {
const data = await response.json();
setError(data.error || "Logo-Upload fehlgeschlagen.");
return;
}
setLogoFile(null);
setLogoVersion(Date.now());
setHasLogo(true);
setStatus("Logo gespeichert.");
};
const onLogoRemove = async () => {
setStatus(null);
setError(null);
const response = await fetch("/api/settings/logo", { method: "DELETE" });
if (!response.ok) {
const data = await response.json();
setError(data.error || "Logo konnte nicht gelöscht werden.");
return;
}
setHasLogo(false);
setLogoVersion(Date.now());
setStatus("Logo entfernt.");
};
return (
<section className="card space-y-4">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">System</p>
<h1 className="text-2xl font-semibold">API Einstellungen</h1>
</div>
<form onSubmit={onLogoUpload} className="space-y-3">
<div>
<p className="text-sm font-medium text-slate-700">App-Logo</p>
<p className="text-xs text-slate-500">
PNG, JPG, WEBP oder SVG. Empfohlen: 240×64 px.
</p>
</div>
{hasLogo ? (
<div className="flex items-center gap-3">
<img
src={`/api/branding/logo?ts=${logoVersion}`}
alt="Aktuelles Logo"
className="h-10 max-w-[180px] rounded-md border border-slate-200 bg-white object-contain px-2"
onError={() => setHasLogo(false)}
/>
<button
type="button"
onClick={onLogoRemove}
className="btn-ghost"
>
Logo entfernen
</button>
</div>
) : (
<p className="text-sm text-slate-500">Kein Logo hinterlegt.</p>
)}
<div className="flex flex-wrap items-center gap-3">
<input
type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
onChange={(event) =>
setLogoFile(event.currentTarget.files?.[0] || null)
}
className="block text-sm text-slate-600"
/>
<button type="submit" className="btn-accent">
Logo hochladen
</button>
</div>
</form>
<form onSubmit={onSubmit} className="space-y-3">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">
Ortsanbieter
</label>
<select
value={provider}
onChange={(event) => setProvider(event.target.value)}
className="w-full rounded-xl border border-slate-300 px-3 py-2"
>
<option value="osm">OpenStreetMap (Nominatim)</option>
<option value="google">Google Places</option>
</select>
</div>
{provider === "google" && (
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">
Google Places API Key
</label>
<input
type="password"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
className="w-full rounded-xl border border-slate-300 px-3 py-2"
placeholder="AIza..."
required
/>
</div>
)}
<label className="flex items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
checked={registrationEnabled}
onChange={(event) => setRegistrationEnabled(event.target.checked)}
/>
Registrierung erlauben
</label>
<button type="submit" className="btn-accent">
Speichern
</button>
</form>
{status && <p className="text-sm text-emerald-600">{status}</p>}
{error && <p className="text-sm text-red-600">{error}</p>}
</section>
);
}

View File

@@ -0,0 +1,482 @@
"use client";
import { useEffect, useState } from "react";
import type { ReactNode } from "react";
type UserItem = {
id: string;
email: string;
name?: string | null;
status: string;
role: string;
emailVerified: boolean;
createdAt: string;
loginStats?: {
attempts: number;
lastAttempt: string | null;
lockedUntil: string | null;
};
};
type AdminUserApprovalsProps = {
role?: string | null;
};
export default function AdminUserApprovals({ role }: AdminUserApprovalsProps) {
const [users, setUsers] = useState<UserItem[]>([]);
const [allUsers, setAllUsers] = useState<UserItem[]>([]);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [modalError, setModalError] = useState<string | null>(null);
const [modalStatus, setModalStatus] = useState<string | null>(null);
const [editingUser, setEditingUser] = useState<UserItem | null>(null);
const loadPending = async () => {
try {
const response = await fetch("/api/users?status=PENDING");
if (!response.ok) {
throw new Error("Nutzer konnten nicht geladen werden.");
}
setUsers(await response.json());
} catch (err) {
setError((err as Error).message);
}
};
const loadAll = async () => {
if (!role) return;
try {
const response = await fetch("/api/users");
if (!response.ok) {
throw new Error("Übersicht konnte nicht geladen werden.");
}
setAllUsers(await response.json());
} catch (err) {
setError((err as Error).message);
}
};
useEffect(() => {
loadPending();
loadAll();
}, [role]);
const isSuperAdmin = role === "SUPERADMIN";
const canManageUsers = role === "ADMIN" || role === "SUPERADMIN";
const approveUser = async (userId: string) => {
setError(null);
setStatus(null);
const response = await fetch("/api/users", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, status: "ACTIVE" })
});
if (!response.ok) {
const data = await response.json();
setError(data.error || "Freischaltung fehlgeschlagen.");
return;
}
setUsers((prev) => prev.filter((user) => user.id !== userId));
setStatus("Benutzer freigeschaltet.");
loadAll();
};
const openCreate = () => {
setEditingUser(null);
setModalError(null);
setModalStatus(null);
setModalOpen(true);
};
const openEdit = (user: UserItem) => {
setEditingUser(user);
setModalError(null);
setModalStatus(null);
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
setEditingUser(null);
};
const saveUser = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setModalError(null);
setModalStatus(null);
const formData = new FormData(event.currentTarget);
const payload = {
email: formData.get("email"),
name: formData.get("name"),
role: formData.get("role"),
status: formData.get("status"),
emailVerified: formData.get("emailVerified") === "on",
password: formData.get("password")
};
const response = await fetch("/api/users", {
method: editingUser ? "PATCH" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
editingUser ? { ...payload, userId: editingUser.id } : payload
)
});
if (!response.ok) {
const data = await response.json();
setModalError(data.error || "Speichern fehlgeschlagen.");
return;
}
setModalStatus("Gespeichert.");
loadPending();
loadAll();
setModalOpen(false);
setEditingUser(null);
};
const removeUser = async (user: UserItem) => {
const ok = window.confirm(`Benutzer ${user.email} deaktivieren?`);
if (!ok) return;
const response = await fetch(`/api/users?id=${user.id}`, {
method: "DELETE"
});
if (!response.ok) {
const data = await response.json();
setError(data.error || "Löschen fehlgeschlagen.");
return;
}
setStatus("Benutzer deaktiviert.");
loadPending();
loadAll();
};
const resetLoginAttempts = async (user: UserItem) => {
setError(null);
setStatus(null);
const response = await fetch("/api/users", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: user.id, resetLoginAttempts: true })
});
if (!response.ok) {
const data = await response.json();
setError(data.error || "Zurücksetzen fehlgeschlagen.");
return;
}
setStatus("Loginversuche zurückgesetzt.");
loadAll();
};
const manageableUsers = isSuperAdmin
? allUsers
: allUsers.filter((user) => user.role === "USER");
return (
<div className="space-y-6">
<section className="card space-y-4">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
Freischaltung
</p>
<h2 className="text-lg font-semibold">Neue Registrierungen</h2>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
{status && <p className="text-sm text-emerald-600">{status}</p>}
{users.length === 0 ? (
<p className="text-sm text-slate-600">Keine offenen Registrierungen.</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead className="text-xs uppercase tracking-[0.2em] text-slate-500">
<tr>
<th className="pb-2">E-Mail</th>
<th className="pb-2">Name</th>
<th className="pb-2">Rolle</th>
<th className="pb-2">Erstellt</th>
<th className="pb-2">Aktion</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id} className="border-t border-slate-200">
<td className="py-3 pr-3">{user.email}</td>
<td className="py-3 pr-3">{user.name || "-"}</td>
<td className="py-3 pr-3">{user.role}</td>
<td className="py-3 pr-3">
{new Date(user.createdAt).toLocaleDateString("de-DE")}
</td>
<td className="py-3 pr-3">
<IconButton
label="Freigeben"
onClick={() => approveUser(user.id)}
>
<IconCheck />
</IconButton>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{canManageUsers && (
<section className="card space-y-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
Übersicht
</p>
<h2 className="text-lg font-semibold">Alle Benutzer</h2>
</div>
<IconButton label="Benutzer anlegen" onClick={openCreate}>
<IconPlus />
</IconButton>
</div>
{manageableUsers.length === 0 ? (
<p className="text-sm text-slate-600">Keine Benutzer vorhanden.</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead className="text-xs uppercase tracking-[0.2em] text-slate-500">
<tr>
<th className="pb-2">E-Mail</th>
<th className="pb-2">Name</th>
<th className="pb-2">Rolle</th>
<th className="pb-2">Status</th>
<th className="pb-2">Verifiziert</th>
<th className="pb-2">Fehlversuche</th>
<th className="pb-2">Letzter Versuch</th>
<th className="pb-2">Gesperrt bis</th>
<th className="pb-2">Erstellt</th>
<th className="pb-2">Aktion</th>
</tr>
</thead>
<tbody>
{manageableUsers.map((user) => (
<tr key={user.id} className="border-t border-slate-200">
<td className="py-3 pr-3">{user.email}</td>
<td className="py-3 pr-3">{user.name || "-"}</td>
<td className="py-3 pr-3">{user.role}</td>
<td className="py-3 pr-3">{user.status}</td>
<td className="py-3 pr-3">
{user.emailVerified ? "Ja" : "Nein"}
</td>
<td className="py-3 pr-3">{user.loginStats?.attempts ?? 0}</td>
<td className="py-3 pr-3">
{user.loginStats?.lastAttempt
? new Date(user.loginStats.lastAttempt).toLocaleString("de-DE")
: "-"}
</td>
<td className="py-3 pr-3">
{user.loginStats?.lockedUntil
? new Date(user.loginStats.lockedUntil).toLocaleString("de-DE")
: "-"}
</td>
<td className="py-3 pr-3">
{new Date(user.createdAt).toLocaleDateString("de-DE")}
</td>
<td className="py-3 pr-3">
<div className="flex flex-nowrap gap-2">
<IconButton
label="Bearbeiten"
onClick={() => openEdit(user)}
>
<IconEdit />
</IconButton>
{isSuperAdmin && (
<IconButton
label="Loginversuche zurücksetzen"
onClick={() => resetLoginAttempts(user)}
>
<IconUnlock />
</IconButton>
)}
<IconButton
label="Deaktivieren"
onClick={() => removeUser(user)}
>
<IconTrash />
</IconButton>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)}
{modalOpen && (
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/40 px-4 py-6">
<div className="card w-full max-w-xl max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">
{editingUser ? "Benutzer bearbeiten" : "Benutzer anlegen"}
</h3>
<IconButton label="Schließen" onClick={closeModal}>
<IconClose />
</IconButton>
</div>
<form onSubmit={saveUser} className="mt-4 grid gap-3 md:grid-cols-2">
<input
name="email"
type="email"
required
defaultValue={editingUser?.email || ""}
placeholder="E-Mail"
className="rounded-xl border border-slate-300 px-3 py-2 md:col-span-2"
/>
<input
name="name"
defaultValue={editingUser?.name || ""}
placeholder="Name"
className="rounded-xl border border-slate-300 px-3 py-2 md:col-span-2"
/>
{isSuperAdmin ? (
<select
name="role"
defaultValue={editingUser?.role || "USER"}
className="rounded-xl border border-slate-300 px-3 py-2"
>
<option value="USER">USER</option>
<option value="ADMIN">ADMIN</option>
<option value="SUPERADMIN">SUPERADMIN</option>
</select>
) : (
<input type="hidden" name="role" value="USER" />
)}
<select
name="status"
defaultValue={editingUser?.status || "PENDING"}
className="rounded-xl border border-slate-300 px-3 py-2"
>
<option value="ACTIVE">ACTIVE</option>
<option value="PENDING">PENDING</option>
<option value="DISABLED">DISABLED</option>
</select>
<label className="flex items-center gap-2 text-sm md:col-span-2">
<input
type="checkbox"
name="emailVerified"
defaultChecked={editingUser?.emailVerified || false}
/>
E-Mail verifiziert
</label>
<input
name="password"
type="password"
placeholder={
editingUser ? "Neues Passwort (optional)" : "Passwort"
}
required={!editingUser}
className="rounded-xl border border-slate-300 px-3 py-2 md:col-span-2"
/>
<button
type="submit"
className="btn-accent md:col-span-2 flex items-center justify-center"
aria-label="Speichern"
title="Speichern"
>
<IconCheck />
<span className="sr-only">Speichern</span>
</button>
</form>
{modalStatus && (
<p className="mt-3 text-sm text-emerald-600">{modalStatus}</p>
)}
{modalError && (
<p className="mt-3 text-sm text-red-600">{modalError}</p>
)}
</div>
</div>
)}
</div>
);
}
function IconButton({
label,
onClick,
children
}: {
label: string;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
title={label}
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-slate-200 text-slate-700 hover:bg-slate-100"
>
{children}
</button>
);
}
function IconCheck() {
return (
<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>
);
}
function IconEdit() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 20h9" strokeLinecap="round" />
<path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4L16.5 3.5z" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function IconTrash() {
return (
<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="M7 6l1 14h8l1-14" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
function IconPlus() {
return (
<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>
);
}
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 IconUnlock() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M7 11V7a5 5 0 0110 0" strokeLinecap="round" strokeLinejoin="round" />
<rect x="3" y="11" width="18" height="10" rx="2" strokeLinecap="round" strokeLinejoin="round" />
<path d="M12 15v2" strokeLinecap="round" />
</svg>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,217 @@
"use client";
import { useState } from "react";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { createPortal } from "react-dom";
export default function EventForm() {
const MapPicker = dynamic(() => import("./MapPicker"), { ssr: false });
type Category = {
id: string;
name: string;
};
type PlaceResult = {
description: string;
place_id: string;
};
type EventFormProps = {
variant?: "card" | "inline";
open?: boolean;
onOpenChange?: (open: boolean) => void;
prefillStartAt?: string | Date | null;
showTrigger?: boolean;
};
export default function EventForm({
variant = "card",
open,
onOpenChange,
prefillStartAt,
showTrigger = true
}: EventFormProps) {
const { data } = useSession();
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [categories, setCategories] = useState<Category[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [startAt, setStartAt] = useState("");
const [endAt, setEndAt] = useState("");
const [placesProvider, setPlacesProvider] = useState<"google" | "osm">("osm");
const [placesKey, setPlacesKey] = useState("");
const [placeQuery, setPlaceQuery] = useState("");
const [placeResults, setPlaceResults] = useState<PlaceResult[]>([]);
const [placeId, setPlaceId] = useState<string | null>(null);
const [placeLat, setPlaceLat] = useState<number | null>(null);
const [placeLng, setPlaceLng] = useState<number | null>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
const loadCategories = async () => {
try {
const response = await fetch("/api/categories");
if (!response.ok) return;
setCategories(await response.json());
} catch {
// Ignore category load errors in UI.
}
};
loadCategories();
}, []);
const isControlled = open !== undefined;
const modalOpen = isControlled ? open : isOpen;
const setModalOpen = (value: boolean) => {
if (isControlled) {
onOpenChange?.(value);
} else {
setIsOpen(value);
}
};
const formatLocalDateTime = (value: Date) => {
const offset = value.getTimezoneOffset() * 60000;
return new Date(value.getTime() - offset).toISOString().slice(0, 16);
};
useEffect(() => {
const loadKey = async () => {
try {
const response = await fetch("/api/settings/google-places");
if (!response.ok) return;
const payload = await response.json();
setPlacesKey(payload.apiKey || "");
setPlacesProvider(payload.provider === "google" ? "google" : "osm");
} catch {
// ignore
}
};
if (modalOpen) {
loadKey();
}
}, [modalOpen]);
useEffect(() => {
const fetchPlaces = async () => {
if (placeQuery.trim().length < 3) {
setPlaceResults([]);
return;
}
if (placesProvider === "google" && !placesKey) {
setPlaceResults([]);
return;
}
const response = await fetch(
`/api/places/autocomplete?input=${encodeURIComponent(placeQuery)}&countries=de,fr,ch,at`
);
if (!response.ok) return;
const payload = await response.json();
setPlaceResults(payload.predictions || []);
};
const timer = window.setTimeout(() => {
fetchPlaces();
}, 350);
return () => window.clearTimeout(timer);
}, [placeQuery, placesKey, placesProvider]);
useEffect(() => {
if (!modalOpen) return;
if (prefillStartAt) {
const startDate =
prefillStartAt instanceof Date
? prefillStartAt
: new Date(prefillStartAt);
if (Number.isNaN(startDate.getTime())) return;
const startValue = formatLocalDateTime(startDate);
setStartAt(startValue);
const endDate = new Date(startDate.getTime() + 3 * 60 * 60 * 1000);
setEndAt(formatLocalDateTime(endDate));
return;
}
const startDate = new Date();
startDate.setHours(12, 0, 0, 0);
setStartAt(formatLocalDateTime(startDate));
const endDate = new Date(startDate.getTime() + 3 * 60 * 60 * 1000);
setEndAt(formatLocalDateTime(endDate));
}, [modalOpen, prefillStartAt]);
const selectPlace = async (place: PlaceResult) => {
setPlaceResults([]);
setPlaceQuery(place.description);
setPlaceId(place.place_id);
const response = await fetch(
`/api/places/details?placeId=${encodeURIComponent(place.place_id)}`
);
if (!response.ok) return;
const payload = await response.json();
setPlaceLat(payload.lat ?? null);
setPlaceLng(payload.lng ?? null);
};
const reverseLookup = async (lat: number, lng: number) => {
try {
const response = await fetch(
`/api/places/reverse?lat=${lat}&lng=${lng}`
);
if (!response.ok) return;
const payload = await response.json();
if (payload.label) {
setPlaceQuery(payload.label);
}
if (payload.placeId) {
setPlaceId(payload.placeId);
}
} catch {
// ignore
}
};
const pickOnMap = (lat: number, lng: number) => {
setPlaceLat(lat);
setPlaceLng(lng);
setPlaceResults([]);
reverseLookup(lat, lng);
};
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 onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = event.currentTarget;
setStatus(null);
setError(null);
const formData = new FormData(event.currentTarget);
const formData = new FormData(form);
const payload = {
title: formData.get("title"),
description: formData.get("description"),
location: formData.get("location"),
startAt: formData.get("startAt"),
endAt: formData.get("endAt")
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")
};
try {
@@ -32,54 +226,210 @@ export default function EventForm() {
throw new Error(data.error || "Fehler beim Speichern.");
}
event.currentTarget.reset();
setStatus("Termin vorgeschlagen. Ein Admin bestaetigt ihn.");
form.reset();
setStartAt("");
setEndAt("");
setStatus("Termin vorgeschlagen. Ein Admin bestätigt ihn.");
setModalOpen(false);
window.dispatchEvent(new Event("views-updated"));
setPlaceQuery("");
setPlaceResults([]);
setPlaceId(null);
setPlaceLat(null);
setPlaceLng(null);
} catch (err) {
setError((err as Error).message);
}
};
const triggerButton = showTrigger ? (
<button
type="button"
className="btn-accent"
onClick={() => {
setModalOpen(true);
setPlaceQuery("");
setPlaceResults([]);
setPlaceId(null);
setPlaceLat(null);
setPlaceLng(null);
}}
>
Neuer Termin
</button>
) : null;
return (
<section className="rounded bg-white p-4 shadow-sm">
<h2 className="text-lg font-semibold">Termin vorschlagen</h2>
<form onSubmit={onSubmit} className="mt-4 grid gap-3 md:grid-cols-2">
<input
name="title"
placeholder="Titel"
required
className="rounded border border-slate-300 px-3 py-2"
/>
<input
name="location"
placeholder="Ort"
className="rounded border border-slate-300 px-3 py-2"
/>
<input
name="startAt"
type="datetime-local"
required
className="rounded border border-slate-300 px-3 py-2"
/>
<input
name="endAt"
type="datetime-local"
required
className="rounded border border-slate-300 px-3 py-2"
/>
<textarea
name="description"
placeholder="Beschreibung"
className="min-h-[96px] rounded border border-slate-300 px-3 py-2 md:col-span-2"
/>
<button
type="submit"
className="rounded bg-brand-500 px-4 py-2 text-white md:col-span-2"
>
Vorschlag senden
</button>
</form>
{status && <p className="mt-3 text-sm text-emerald-600">{status}</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
</section>
<>
{variant === "card" ? (
<section className="card fade-up-delay">
<div className="flex flex-wrap items-start justify-between gap-3">
{triggerButton}
</div>
{categories.length === 0 && (
<p className="mt-3 text-sm text-amber-600">
Noch keine Kategorien vorhanden. Bitte Admin um Anlage.
</p>
)}
{status && <p className="mt-3 text-sm text-emerald-600">{status}</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
</section>
) : triggerButton ? (
<div className="flex flex-col items-start gap-2">
{triggerButton}
{status && <p className="text-xs text-emerald-600">{status}</p>}
{error && <p className="text-xs text-red-600">{error}</p>}
</div>
) : null}
{modalOpen &&
mounted &&
createPortal(
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/50 px-4 py-6">
<div className="card w-full max-w-xl max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Neuen Termin anlegen</h3>
<button
type="button"
className="text-sm text-slate-600"
onClick={() => setModalOpen(false)}
>
Schließen
</button>
</div>
{data?.user?.role === "USER" && (
<p className="mt-2 text-sm text-slate-600">
Hinweis: Terminvorschläge müssen von einem Admin freigegeben werden.
</p>
)}
<form onSubmit={onSubmit} className="mt-4 grid gap-3 md:grid-cols-2">
<input
name="title"
placeholder="Titel"
required
className="rounded-xl border border-slate-300 px-3 py-2"
/>
<div className="relative">
<input
name="location"
placeholder="Ort"
value={placeQuery}
onChange={(event) => {
setPlaceQuery(event.target.value);
setPlaceId(null);
setPlaceLat(null);
setPlaceLng(null);
}}
className="w-full rounded-xl border border-slate-300 px-3 py-2"
/>
{placeResults.length > 0 && (
<div className="absolute z-10 mt-2 max-h-56 w-full overflow-y-auto rounded-xl border border-slate-200 bg-white shadow">
{placeResults.map((place) => (
<button
key={place.place_id}
type="button"
className="block w-full px-3 py-2 text-left text-sm hover:bg-slate-100"
onClick={() => selectPlace(place)}
>
{place.description}
</button>
))}
</div>
)}
{placesProvider === "google" && !placesKey && placeQuery.length > 0 && (
<div className="mt-2 text-xs text-slate-500">
Google Places ist nicht konfiguriert.
</div>
)}
</div>
<div className="md:col-span-2">
<label className="text-xs uppercase tracking-[0.2em] text-slate-500">
Ort per Karte wählen
</label>
<div className="mt-2 overflow-hidden rounded-xl border border-slate-200">
<MapPicker
value={
placeLat !== null && placeLng !== null
? { lat: placeLat, lng: placeLng }
: null
}
onChange={(coords) => pickOnMap(coords.lat, coords.lng)}
/>
</div>
<p className="mt-2 text-xs text-slate-500">
Klick auf die Karte, um den Ort zu setzen.
</p>
</div>
<input type="hidden" name="locationPlaceId" value={placeId || ""} />
<input type="hidden" name="locationLat" value={placeLat ?? ""} />
<input type="hidden" name="locationLng" value={placeLng ?? ""} />
<input
name="startAt"
type="datetime-local"
required
value={startAt}
onChange={(event) => {
const nextStart = event.target.value;
setStartAt(nextStart);
if (nextStart) {
const startDate = new Date(nextStart);
const endDate = new Date(startDate.getTime() + 3 * 60 * 60 * 1000);
const offset = endDate.getTimezoneOffset() * 60000;
const local = new Date(endDate.getTime() - offset)
.toISOString()
.slice(0, 16);
setEndAt(local);
} else {
setEndAt("");
}
}}
className="rounded-xl border border-slate-300 px-3 py-2"
/>
<input
name="endAt"
type="datetime-local"
value={endAt}
onChange={(event) => setEndAt(event.target.value)}
className="rounded-xl border border-slate-300 px-3 py-2"
/>
<select
name="categoryId"
required
className="rounded-xl border border-slate-300 px-3 py-2"
disabled={categories.length === 0}
>
<option value="" disabled>
Kategorie wählen
</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
<textarea
name="description"
placeholder="Beschreibung"
className="min-h-[96px] rounded-xl border border-slate-300 px-3 py-2 md:col-span-2"
/>
<button
type="submit"
className="btn-accent md:col-span-2"
disabled={categories.length === 0}
>
Vorschlag senden
</button>
</form>
{categories.length === 0 && (
<p className="mt-3 text-sm text-amber-600">
Noch keine Kategorien vorhanden. Bitte Admin um Anlage.
</p>
)}
{status && <p className="mt-3 text-sm text-emerald-600">{status}</p>}
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
</div>
</div>,
document.body
)}
</>
);
}

65
components/MapPicker.tsx Normal file
View File

@@ -0,0 +1,65 @@
"use client";
import { useEffect } from "react";
import { MapContainer, Marker, TileLayer, useMapEvents } from "react-leaflet";
import L from "leaflet";
type LatLng = { lat: number; lng: number };
let iconConfigured = false;
const ensureLeafletIcons = () => {
if (iconConfigured) return;
delete (L.Icon.Default.prototype as { _getIconUrl?: () => string })._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl:
"https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png"
});
iconConfigured = true;
};
function MapClickHandler({ onPick }: { onPick: (coords: LatLng) => void }) {
useMapEvents({
click(event) {
onPick({ lat: event.latlng.lat, lng: event.latlng.lng });
}
});
return null;
}
export default function MapPicker({
value,
onChange
}: {
value: LatLng | null;
onChange: (coords: LatLng) => void;
}) {
useEffect(() => {
ensureLeafletIcons();
}, []);
const center = value || { lat: 52.52, lng: 13.405 };
return (
<div
className="h-64 w-full overflow-hidden rounded-xl overscroll-contain"
onWheel={(event) => event.stopPropagation()}
>
<MapContainer
center={center}
zoom={value ? 14 : 5}
scrollWheelZoom
className="h-full w-full"
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapClickHandler onPick={onChange} />
{value && <Marker position={value} />}
</MapContainer>
</div>
);
}

View File

@@ -1,36 +1,77 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { signIn, signOut, useSession } from "next-auth/react";
export default function NavBar() {
const { data } = useSession();
const isAdmin = data?.user?.role === "ADMIN";
const pathname = usePathname();
const isAdmin = data?.user?.role === "ADMIN" || data?.user?.role === "SUPERADMIN";
const isSuperAdmin = data?.user?.role === "SUPERADMIN";
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const linkClass = (href: string) =>
pathname === href
? "rounded-full bg-slate-900 px-3 py-1 text-white"
: "rounded-full px-3 py-1 text-slate-700 hover:bg-slate-100";
useEffect(() => {
const loadLogo = async () => {
try {
const response = await fetch("/api/branding/logo", {
method: "HEAD",
cache: "no-store"
});
if (response.ok) {
setLogoUrl(`/api/branding/logo?ts=${Date.now()}`);
} else {
setLogoUrl(null);
}
} catch {
setLogoUrl(null);
}
};
loadLogo();
}, []);
return (
<header className="border-b border-slate-200/70 bg-white/80 backdrop-blur">
<header className="sticky top-0 z-20 border-b border-slate-200/70 bg-white/70 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
<Link href="/" className="text-lg font-semibold text-slate-900">
Vereinskalender
<Link href="/" className="flex items-center gap-3 text-lg font-semibold tracking-tight text-slate-900">
{logoUrl && (
<img
src={logoUrl}
alt="Vereinskalender Logo"
className="h-8 w-auto max-w-[140px] object-contain"
onError={() => setLogoUrl(null)}
/>
)}
<span>Vereinskalender</span>
</Link>
<nav className="flex items-center gap-4 text-sm">
<nav className="flex items-center gap-3 text-sm">
{data?.user && (
<>
<Link href="/views" className="text-slate-700 hover:text-slate-900">
Meine Ansichten
</Link>
{isAdmin && (
<Link href="/admin" className="text-slate-700 hover:text-slate-900">
Admin
</Link>
<>
<Link href="/admin" className={linkClass("/admin")}>
Admin
</Link>
<Link href="/admin/users" className={linkClass("/admin/users")}>
Registrierungen
</Link>
</>
)}
<Link href="/settings" className={linkClass("/settings")}>
Einstellungen
</Link>
</>
)}
{data?.user ? (
<button
type="button"
onClick={() => signOut()}
className="rounded bg-slate-900 px-3 py-1.5 text-white"
className="btn-primary"
>
Logout
</button>
@@ -38,7 +79,7 @@ export default function NavBar() {
<button
type="button"
onClick={() => signIn()}
className="rounded bg-brand-500 px-3 py-1.5 text-white"
className="btn-accent"
>
Login
</button>

View File

@@ -1,46 +1,20 @@
"use client";
import { useEffect, useMemo, useState } from "react";
type EventItem = {
id: string;
title: string;
startAt: string;
endAt: string;
status: string;
};
type ViewItem = {
id: string;
name: string;
token: string;
items: { event: EventItem }[];
};
import { useEffect, useState } from "react";
export default function ViewManager() {
const [views, setViews] = useState<ViewItem[]>([]);
const [events, setEvents] = useState<EventItem[]>([]);
const [selectedView, setSelectedView] = useState<string | null>(null);
const [view, setView] = useState<{ id: string; name: string; token: string } | null>(
null
);
const [error, setError] = useState<string | null>(null);
const load = async () => {
try {
const [viewsRes, eventsRes] = await Promise.all([
fetch("/api/views"),
fetch("/api/events?status=APPROVED")
]);
if (!viewsRes.ok || !eventsRes.ok) {
const viewRes = await fetch("/api/views/default");
if (!viewRes.ok) {
throw new Error("Daten konnten nicht geladen werden.");
}
const viewsData = await viewsRes.json();
const eventsData = await eventsRes.json();
setViews(viewsData);
setEvents(eventsData);
if (viewsData.length > 0 && !selectedView) {
setSelectedView(viewsData[0].id);
}
setView(await viewRes.json());
} catch (err) {
setError((err as Error).message);
}
@@ -50,149 +24,39 @@ export default function ViewManager() {
load();
}, []);
useEffect(() => {
const handler = () => load();
window.addEventListener("views-updated", handler);
return () => window.removeEventListener("views-updated", handler);
}, []);
const icalBase = typeof window === "undefined" ? "" : window.location.origin;
const activeView = useMemo(
() => views.find((view) => view.id === selectedView) || null,
[views, selectedView]
);
const createView = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const name = formData.get("name");
const response = await fetch("/api/views", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name })
});
if (response.ok) {
event.currentTarget.reset();
load();
}
};
const addItem = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selectedView) return;
const formData = new FormData(event.currentTarget);
const eventId = formData.get("eventId");
await fetch(`/api/views/${selectedView}/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ eventId })
});
load();
};
const removeItem = async (eventId: string) => {
if (!selectedView) return;
await fetch(`/api/views/${selectedView}/items`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ eventId })
});
load();
};
return (
<section className="space-y-6">
<section className="space-y-6 fade-up">
<div>
<h1 className="text-2xl font-semibold">Meine Kalenderansichten</h1>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">Persönlich</p>
<h1 className="text-2xl font-semibold">Meine Terminansicht</h1>
<p className="text-slate-600">
Stelle dir eine persoenliche Terminansicht zusammen und abonnieren sie als iCal.
Stelle dir eine persönliche Terminansicht zusammen und abonniere sie als iCal.
</p>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-4 rounded bg-white p-4 shadow-sm">
<h2 className="text-lg font-semibold">Neue Ansicht</h2>
<form onSubmit={createView} className="flex gap-2">
<input
name="name"
required
placeholder="z.B. Jugendtermine"
className="flex-1 rounded border border-slate-300 px-3 py-2"
/>
<button className="rounded bg-brand-500 px-3 py-2 text-white" type="submit">
Anlegen
</button>
</form>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">Ansicht waehlen</label>
<select
className="w-full rounded border border-slate-300 px-3 py-2"
value={selectedView || ""}
onChange={(event) => setSelectedView(event.target.value)}
>
{views.map((view) => (
<option key={view.id} value={view.id}>
{view.name}
</option>
))}
</select>
<div className="card space-y-4">
<h2 className="text-lg font-semibold">Deine Standardansicht</h2>
<p className="text-sm text-slate-600">
Diese Ansicht kannst du im Kalender oder in der Listenansicht direkt befüllen.
</p>
{view && (
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 text-sm">
<p className="font-medium">iCal URL</p>
<p className="break-all text-slate-700">
{icalBase}/api/ical/{view.token}
</p>
</div>
{activeView && (
<div className="rounded border border-slate-200 bg-slate-50 p-3 text-sm">
<p className="font-medium">iCal URL</p>
<p className="break-all text-slate-700">
{icalBase}/api/ical/{activeView.token}
</p>
</div>
)}
</div>
<div className="space-y-4 rounded bg-white p-4 shadow-sm">
<h2 className="text-lg font-semibold">Termine hinzufuegen</h2>
<form onSubmit={addItem} className="space-y-3">
<select
name="eventId"
className="w-full rounded border border-slate-300 px-3 py-2"
required
>
<option value="" disabled>
Termin waehlen
</option>
{events.map((event) => (
<option key={event.id} value={event.id}>
{event.title} ({new Date(event.startAt).toLocaleDateString()})
</option>
))}
</select>
<button className="w-full rounded bg-slate-900 px-3 py-2 text-white" type="submit">
Zur Ansicht hinzufuegen
</button>
</form>
<div className="space-y-2">
<h3 className="text-sm font-semibold text-slate-700">Inhalte der Ansicht</h3>
{activeView?.items.length ? (
activeView.items.map((item) => (
<div
key={item.event.id}
className="flex items-center justify-between rounded border border-slate-200 px-3 py-2"
>
<span className="text-sm">{item.event.title}</span>
<button
type="button"
className="text-xs text-red-600"
onClick={() => removeItem(item.event.id)}
>
Entfernen
</button>
</div>
))
) : (
<p className="text-sm text-slate-500">Noch keine Termine.</p>
)}
</div>
</div>
)}
</div>
</section>
);