Aktueller Stand

This commit is contained in:
2026-01-18 00:40:01 +01:00
parent 68b63b8f06
commit 31aef02558
16 changed files with 352 additions and 43 deletions

View File

@@ -7,6 +7,7 @@ export default function AdminSystemSettings() {
const [provider, setProvider] = useState("osm");
const [registrationEnabled, setRegistrationEnabled] = useState(true);
const [publicAccessEnabled, setPublicAccessEnabled] = useState(true);
const [emailVerificationRequired, setEmailVerificationRequired] = useState(true);
const [appName, setAppName] = useState("Vereinskalender");
const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoVersion, setLogoVersion] = useState(() => Date.now());
@@ -28,6 +29,7 @@ export default function AdminSystemSettings() {
setProvider(payload.provider || "osm");
setRegistrationEnabled(payload.registrationEnabled !== false);
setPublicAccessEnabled(payload.publicAccessEnabled !== false);
setEmailVerificationRequired(payload.emailVerificationRequired !== false);
if (appNameResponse.ok) {
const appPayload = await appNameResponse.json();
setAppName(appPayload.name || "Vereinskalender");
@@ -67,7 +69,8 @@ export default function AdminSystemSettings() {
apiKey,
provider,
registrationEnabled,
publicAccessEnabled
publicAccessEnabled,
emailVerificationRequired
})
}),
fetch("/api/settings/app-name", {
@@ -252,6 +255,16 @@ export default function AdminSystemSettings() {
/>
Registrierung erlauben
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={emailVerificationRequired}
onChange={(event) =>
setEmailVerificationRequired(event.target.checked)
}
/>
E-Mail-Verifizierung erforderlich
</label>
</div>
</div>
<button type="submit" className="btn-accent">

View File

@@ -140,11 +140,20 @@ export default function AdminUserApprovals({ role }: AdminUserApprovalsProps) {
setEditingUser(null);
};
const removeUser = async (user: UserItem) => {
const ok = window.confirm(`Benutzer ${user.email} deaktivieren?`);
const removeUser = async (user: UserItem, mode: "disable" | "delete" = "disable") => {
if (mode === "delete" && !isSuperAdmin) {
setError("Nur Superadmins können Benutzer endgültig löschen.");
return;
}
const ok = window.confirm(
mode === "delete"
? `Benutzer ${user.email} endgültig löschen? Alle Daten werden entfernt.`
: `Benutzer ${user.email} deaktivieren?`
);
if (!ok) return;
const response = await fetch(`/api/users?id=${user.id}`, {
const hardParam = mode === "delete" ? "&hard=true" : "";
const response = await fetch(`/api/users?id=${user.id}${hardParam}`, {
method: "DELETE"
});
@@ -154,7 +163,7 @@ export default function AdminUserApprovals({ role }: AdminUserApprovalsProps) {
return;
}
setStatus("Benutzer deaktiviert.");
setStatus(mode === "delete" ? "Benutzer gelöscht." : "Benutzer deaktiviert.");
loadPending();
loadAll();
};
@@ -303,8 +312,8 @@ export default function AdminUserApprovals({ role }: AdminUserApprovalsProps) {
</IconButton>
)}
<IconButton
label="Deaktivieren"
onClick={() => removeUser(user)}
label={isSuperAdmin ? "Löschen" : "Deaktivieren"}
onClick={() => removeUser(user, isSuperAdmin ? "delete" : "disable")}
>
<IconTrash />
</IconButton>

View File

@@ -1582,7 +1582,134 @@ export default function CalendarBoard() {
</div>
)}
</div>
<div className="overflow-x-auto">
<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>
@@ -2551,9 +2678,9 @@ function SortIcon({
);
}
function IconMapPin() {
function IconMapPin({ className = "h-4 w-4" }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor" strokeWidth="2">
<path d="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>