feat(termine+matrix): Nutzer-Termin-Einreichung (Draft) + Matrix-Notifications
- /termin-melden: öffentliches Formular → /api/termin legt calendar_item als Draft an (scoped Write-Key), Admin bestätigt per draft→published - /api/cms-webhook: empfängt RustyCMS-Webhooks (X-Webhook-Secret) → Matrix - src/lib/server/matrix.ts: notifyMatrix() Helper - Form-Proxies (stellungnahme/contact/newsletter/mitmachen) pingen Matrix - deploy.yml: Matrix/Webhook/Termin-Key Env-Vars Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -91,6 +91,11 @@ jobs:
|
|||||||
PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.PUBLIC_UMAMI_WEBSITE_ID }}
|
PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.PUBLIC_UMAMI_WEBSITE_ID }}
|
||||||
PUBLIC_TURNSTILE_SITE_KEY=${{ secrets.PUBLIC_TURNSTILE_SITE_KEY }}
|
PUBLIC_TURNSTILE_SITE_KEY=${{ secrets.PUBLIC_TURNSTILE_SITE_KEY }}
|
||||||
TURNSTILE_SECRET=${{ secrets.TURNSTILE_SECRET }}
|
TURNSTILE_SECRET=${{ secrets.TURNSTILE_SECRET }}
|
||||||
|
MATRIX_NOTIFY_URL=${{ secrets.MATRIX_NOTIFY_URL }}
|
||||||
|
MATRIX_NOTIFY_TOKEN=${{ secrets.MATRIX_NOTIFY_TOKEN }}
|
||||||
|
MATRIX_NOTIFY_ROOM=${{ secrets.MATRIX_NOTIFY_ROOM }}
|
||||||
|
CMS_WEBHOOK_SECRET=${{ secrets.CMS_WEBHOOK_SECRET }}
|
||||||
|
RUSTYCMS_TERMIN_API_KEY=${{ secrets.RUSTYCMS_TERMIN_API_KEY }}
|
||||||
ENVEOF
|
ENVEOF
|
||||||
|
|
||||||
- name: Pull and restart container
|
- name: Pull and restart container
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { env } from "$env/dynamic/private";
|
||||||
|
|
||||||
|
const MATRIX_URL = (env.MATRIX_NOTIFY_URL || "").replace(/\/$/, "");
|
||||||
|
const MATRIX_TOKEN = env.MATRIX_NOTIFY_TOKEN || "";
|
||||||
|
const MATRIX_ROOM = env.MATRIX_NOTIFY_ROOM || ""; // raw, e.g. !abc:matrix.pm86.de
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire-and-forget Matrix notification into the configured room.
|
||||||
|
* No-op (silently) when not configured, so local dev / preview never break.
|
||||||
|
* Sends both plain `body` and an HTML `formatted_body` (Markdown-ish links).
|
||||||
|
*/
|
||||||
|
export async function notifyMatrix(text: string, html?: string): Promise<void> {
|
||||||
|
if (!MATRIX_URL || !MATRIX_TOKEN || !MATRIX_ROOM) return;
|
||||||
|
const room = encodeURIComponent(MATRIX_ROOM);
|
||||||
|
const txn = `ww-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||||
|
const body: Record<string, unknown> = { msgtype: "m.text", body: text };
|
||||||
|
if (html) {
|
||||||
|
body.format = "org.matrix.custom.html";
|
||||||
|
body.formatted_body = html;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fetch(`${MATRIX_URL}/_matrix/client/v3/rooms/${room}/send/m.room.message/${txn}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { Authorization: `Bearer ${MATRIX_TOKEN}`, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(8_000),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[matrix] notify failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { json } from "@sveltejs/kit";
|
||||||
|
import { env as publicEnv } from "$env/dynamic/public";
|
||||||
|
import { env as privateEnv } from "$env/dynamic/private";
|
||||||
|
import type { RequestHandler } from "./$types";
|
||||||
|
import { notifyMatrix } from "$lib/server/matrix";
|
||||||
|
|
||||||
|
// Shared secret RustyCMS sends in X-Webhook-Secret (configured per webhook target).
|
||||||
|
const WEBHOOK_SECRET = privateEnv.CMS_WEBHOOK_SECRET || "";
|
||||||
|
// Optional read key to resolve a human title for the message (best-effort).
|
||||||
|
const READ_KEY = privateEnv.RUSTYCMS_TERMIN_API_KEY || privateEnv.RUSTYCMS_API_KEY || "";
|
||||||
|
|
||||||
|
const CMS_ADMIN = "https://cms.pm86.de/admin/content";
|
||||||
|
|
||||||
|
const EVENT_LABEL: Record<string, string> = {
|
||||||
|
"content.created": "🆕 neu angelegt",
|
||||||
|
"content.updated": "✏️ aktualisiert",
|
||||||
|
"content.deleted": "🗑️ gelöscht",
|
||||||
|
"schema.created": "🧩 Schema neu",
|
||||||
|
"schema.updated": "🧩 Schema geändert",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function resolveTitle(collection: string, slug: string, environment: string): Promise<string | null> {
|
||||||
|
if (!READ_KEY) return null;
|
||||||
|
const base = (publicEnv.PUBLIC_CMS_URL || "https://cms.pm86.de").replace(/\/$/, "");
|
||||||
|
const url = `${base}/api/content/${collection}/${encodeURIComponent(slug)}?_environment=${encodeURIComponent(environment)}`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${READ_KEY}` },
|
||||||
|
signal: AbortSignal.timeout(6_000),
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = (await res.json()) as Record<string, unknown>;
|
||||||
|
const t = data.title ?? data.headline ?? data.name;
|
||||||
|
return typeof t === "string" ? t : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
if (!WEBHOOK_SECRET || request.headers.get("x-webhook-secret") !== WEBHOOK_SECRET) {
|
||||||
|
return json({ error: "unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json().catch(() => null)) as Record<string, unknown> | null;
|
||||||
|
if (!body || typeof body !== "object") {
|
||||||
|
return json({ ok: true }); // ignore malformed, don't make CMS retry
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = typeof body.event === "string" ? body.event : "";
|
||||||
|
const collection = typeof body.collection === "string" ? body.collection : "";
|
||||||
|
const slug = typeof body.slug === "string" ? body.slug : "";
|
||||||
|
const environment = typeof body.environment === "string" ? body.environment : "";
|
||||||
|
|
||||||
|
// Only chat about content events; schema noise is opt-in elsewhere.
|
||||||
|
if (!event.startsWith("content.") || !collection || !slug) {
|
||||||
|
return json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = EVENT_LABEL[event] ?? event;
|
||||||
|
const title = await resolveTitle(collection, slug, environment);
|
||||||
|
const adminUrl = `${CMS_ADMIN}/${collection}/${slug}`;
|
||||||
|
const titlePart = title ? `„${title}"` : slug;
|
||||||
|
const draftHint = collection === "calendar_item" && event === "content.created"
|
||||||
|
? " — bitte prüfen & veröffentlichen"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
await notifyMatrix(
|
||||||
|
`RustyCMS ${label}: ${collection} ${titlePart}${draftHint}\n${adminUrl}`,
|
||||||
|
`RustyCMS <b>${label}</b>: <code>${collection}</code> ${title ? `„${escapeHtml(title)}"` : `<code>${escapeHtml(slug)}</code>`}${draftHint} — <a href="${adminUrl}">im Admin öffnen</a>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return json({ ok: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { json } from "@sveltejs/kit";
|
|||||||
import { env as publicEnv } from "$env/dynamic/public";
|
import { env as publicEnv } from "$env/dynamic/public";
|
||||||
import { env as privateEnv } from "$env/dynamic/private";
|
import { env as privateEnv } from "$env/dynamic/private";
|
||||||
import type { RequestHandler } from "./$types";
|
import type { RequestHandler } from "./$types";
|
||||||
|
import { notifyMatrix } from "$lib/server/matrix";
|
||||||
import {
|
import {
|
||||||
HONEYPOT_FIELDS,
|
HONEYPOT_FIELDS,
|
||||||
LIMITS,
|
LIMITS,
|
||||||
@@ -123,6 +124,9 @@ export const POST: RequestHandler = async ({ request, getClientAddress }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
void notifyMatrix(
|
||||||
|
`✉️ Neue Kontakt-Nachricht von ${payload.name} (${payload.email})${payload.subject ? `: ${payload.subject}` : ""}`,
|
||||||
|
);
|
||||||
return json({ ok: true }, { status: 201 });
|
return json({ ok: true }, { status: 201 });
|
||||||
}
|
}
|
||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { json } from "@sveltejs/kit";
|
|||||||
import { env as publicEnv } from "$env/dynamic/public";
|
import { env as publicEnv } from "$env/dynamic/public";
|
||||||
import { env as privateEnv } from "$env/dynamic/private";
|
import { env as privateEnv } from "$env/dynamic/private";
|
||||||
import type { RequestHandler } from "./$types";
|
import type { RequestHandler } from "./$types";
|
||||||
|
import { notifyMatrix } from "$lib/server/matrix";
|
||||||
import {
|
import {
|
||||||
cleanText,
|
cleanText,
|
||||||
HONEYPOT_FIELDS,
|
HONEYPOT_FIELDS,
|
||||||
@@ -141,6 +142,9 @@ export const POST: RequestHandler = async ({ request, getClientAddress }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
void notifyMatrix(
|
||||||
|
`🤝 Neue Mitmachen-Einsendung: „${payload.title}"${payload.contact_name ? ` — ${payload.contact_name}` : ""}${payload.email ? ` (${payload.email})` : ""}`,
|
||||||
|
);
|
||||||
return json({ ok: true }, { status: 201 });
|
return json({ ok: true }, { status: 201 });
|
||||||
}
|
}
|
||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
LIMITS,
|
LIMITS,
|
||||||
validateNewsletter,
|
validateNewsletter,
|
||||||
} from "$lib/components/internal/newsletter-form-validation";
|
} from "$lib/components/internal/newsletter-form-validation";
|
||||||
|
import { notifyMatrix } from "$lib/server/matrix";
|
||||||
|
|
||||||
const TURNSTILE_SECRET = privateEnv.TURNSTILE_SECRET ?? "";
|
const TURNSTILE_SECRET = privateEnv.TURNSTILE_SECRET ?? "";
|
||||||
const TURNSTILE_ENABLED = TURNSTILE_SECRET !== "";
|
const TURNSTILE_ENABLED = TURNSTILE_SECRET !== "";
|
||||||
@@ -156,6 +157,8 @@ export const POST: RequestHandler = async ({ request, getClientAddress }) => {
|
|||||||
ip,
|
ip,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
void notifyMatrix(`📰 Neue Newsletter-Anmeldung: ${email}${name ? ` (${name})` : ""}`);
|
||||||
|
|
||||||
return json({ ok: true }, { status: 201 });
|
return json({ ok: true }, { status: 201 });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { json } from "@sveltejs/kit";
|
import { json } from "@sveltejs/kit";
|
||||||
import { env as publicEnv } from "$env/dynamic/public";
|
import { env as publicEnv } from "$env/dynamic/public";
|
||||||
import type { RequestHandler } from "./$types";
|
import type { RequestHandler } from "./$types";
|
||||||
|
import { notifyMatrix } from "$lib/server/matrix";
|
||||||
|
|
||||||
// Honeypot field — must be empty (bot trap)
|
// Honeypot field — must be empty (bot trap)
|
||||||
const HONEYPOT = "website";
|
const HONEYPOT = "website";
|
||||||
@@ -97,6 +98,9 @@ export const POST: RequestHandler = async ({ request, getClientAddress }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
void notifyMatrix(
|
||||||
|
`📄 Neue Stellungnahme-Einsendung: ${fields.name} (${fields.email})${fields.vorranggebiet ? `, Gebiet ${fields.vorranggebiet}` : ""} — Forms-Plugin „stellungnahme"`,
|
||||||
|
);
|
||||||
return json({ ok: true }, { status: 201 });
|
return json({ ok: true }, { status: 201 });
|
||||||
}
|
}
|
||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { json } from "@sveltejs/kit";
|
||||||
|
import { env as publicEnv } from "$env/dynamic/public";
|
||||||
|
import { env as privateEnv } from "$env/dynamic/private";
|
||||||
|
import type { RequestHandler } from "./$types";
|
||||||
|
|
||||||
|
const API_KEY = privateEnv.RUSTYCMS_TERMIN_API_KEY || "";
|
||||||
|
const HONEYPOT = "website";
|
||||||
|
const MIN_CLIENT_AGE_MS = 3_000;
|
||||||
|
|
||||||
|
const ALLOWED_ORIGINS = new Set([
|
||||||
|
"https://windwiderstand.de",
|
||||||
|
"https://www.windwiderstand.de",
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://localhost:4173",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Tiny in-memory per-IP rate limit (single-node container). Curbs draft spam.
|
||||||
|
const RATE_MAX = 5;
|
||||||
|
const RATE_WINDOW_MS = 10 * 60_000;
|
||||||
|
const hits = new Map<string, number[]>();
|
||||||
|
function rateLimited(ip: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const arr = (hits.get(ip) ?? []).filter((t) => now - t < RATE_WINDOW_MS);
|
||||||
|
arr.push(now);
|
||||||
|
hits.set(ip, arr);
|
||||||
|
return arr.length > RATE_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(s: string): string {
|
||||||
|
return s
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[̀-ͯ]/g, "")
|
||||||
|
.replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request, getClientAddress }) => {
|
||||||
|
const origin = request.headers.get("origin") ?? "";
|
||||||
|
const referer = request.headers.get("referer") ?? "";
|
||||||
|
const refOrigin = referer ? safeOrigin(referer) : "";
|
||||||
|
if (!ALLOWED_ORIGINS.has(origin) && !ALLOWED_ORIGINS.has(refOrigin)) {
|
||||||
|
return json({ error: "Ungültige Anfrage." }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (!API_KEY) {
|
||||||
|
console.error("[termin] RUSTYCMS_TERMIN_API_KEY not configured");
|
||||||
|
return json({ error: "Einreichen derzeit nicht möglich." }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = (await request.json().catch(() => null)) as Record<string, unknown> | null;
|
||||||
|
if (!raw) return json({ error: "Ungültige Anfrage." }, { status: 400 });
|
||||||
|
const str = (k: string) => (typeof raw[k] === "string" ? (raw[k] as string) : "");
|
||||||
|
|
||||||
|
if (str(HONEYPOT).trim() !== "") return json({ ok: true }, { status: 201 }); // bot trap
|
||||||
|
const age = Number(raw._client_age_ms);
|
||||||
|
if (!Number.isFinite(age) || age < MIN_CLIENT_AGE_MS) {
|
||||||
|
return json({ error: "Bitte etwas mehr Zeit lassen." }, { status: 429 });
|
||||||
|
}
|
||||||
|
if (rateLimited(getClientAddress())) {
|
||||||
|
return json({ error: "Zu viele Einsendungen. Bitte später erneut." }, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
const title = str("title").trim();
|
||||||
|
const terminZeit = str("terminZeit").trim();
|
||||||
|
const terminEnde = str("terminEnde").trim();
|
||||||
|
const locationText = str("locationText").trim();
|
||||||
|
const description = str("description").trim();
|
||||||
|
const sourceUrl = str("sourceUrl").trim();
|
||||||
|
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
if (title.length < 3 || title.length > 200) errors.title = "Titel fehlt oder zu lang.";
|
||||||
|
const start = new Date(terminZeit);
|
||||||
|
if (!terminZeit || Number.isNaN(start.getTime())) errors.terminZeit = "Datum/Uhrzeit ungültig.";
|
||||||
|
if (terminEnde && Number.isNaN(new Date(terminEnde).getTime())) errors.terminEnde = "Enddatum ungültig.";
|
||||||
|
if (locationText.length > 300) errors.locationText = "Ort zu lang.";
|
||||||
|
if (description.length > 5000) errors.description = "Beschreibung zu lang.";
|
||||||
|
if (sourceUrl && !/^https?:\/\/\S+$/.test(sourceUrl)) errors.sourceUrl = "Link ungültig.";
|
||||||
|
if (Object.keys(errors).length) return json({ errors }, { status: 422 });
|
||||||
|
|
||||||
|
// Build draft entry. User-submitted → draft + "eingereicht" tag for admin triage.
|
||||||
|
let descFull = description;
|
||||||
|
if (sourceUrl) descFull += `${descFull ? "\n\n" : ""}Quelle: ${sourceUrl}`;
|
||||||
|
|
||||||
|
const slug = `calendar-item-eingereicht-${Date.now().toString(36)}-${slugify(title) || "termin"}`;
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
_slug: slug,
|
||||||
|
_status: "draft",
|
||||||
|
title,
|
||||||
|
terminZeit: start.toISOString(),
|
||||||
|
tags: ["eingereicht"],
|
||||||
|
};
|
||||||
|
if (terminEnde) payload.terminEnde = new Date(terminEnde).toISOString();
|
||||||
|
if (locationText) payload.location = { text: locationText };
|
||||||
|
if (descFull) payload.description = descFull;
|
||||||
|
|
||||||
|
const cmsBase = (publicEnv.PUBLIC_CMS_URL || "http://localhost:3099").replace(/\/$/, "");
|
||||||
|
const cmsEnv = publicEnv.PUBLIC_CMS_ENV || "";
|
||||||
|
const url = cmsEnv
|
||||||
|
? `${cmsBase}/api/content/calendar_item?_environment=${encodeURIComponent(cmsEnv)}`
|
||||||
|
: `${cmsBase}/api/content/calendar_item`;
|
||||||
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${API_KEY}` },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[termin] CMS fetch failed:", e);
|
||||||
|
return json({ error: "Server nicht erreichbar." }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.ok) return json({ ok: true }, { status: 201 });
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
console.error("[termin] CMS error", res.status, text);
|
||||||
|
return json({ error: "Fehler beim Einreichen." }, { status: 502 });
|
||||||
|
};
|
||||||
|
|
||||||
|
function safeOrigin(u: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(u).origin;
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
let title = $state("");
|
||||||
|
let terminZeit = $state(""); // datetime-local
|
||||||
|
let terminEnde = $state("");
|
||||||
|
let locationText = $state("");
|
||||||
|
let description = $state("");
|
||||||
|
let sourceUrl = $state("");
|
||||||
|
let website = $state(""); // honeypot
|
||||||
|
|
||||||
|
let formState = $state<"idle" | "sending" | "sent" | "error">("idle");
|
||||||
|
let error = $state("");
|
||||||
|
let mountedAt = 0;
|
||||||
|
|
||||||
|
onMount(() => { mountedAt = Date.now(); });
|
||||||
|
|
||||||
|
function toIso(local: string): string {
|
||||||
|
if (!local) return "";
|
||||||
|
const d = new Date(local);
|
||||||
|
return Number.isNaN(d.getTime()) ? "" : d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (formState === "sending") return;
|
||||||
|
error = "";
|
||||||
|
if (title.trim().length < 3) { error = "Bitte einen Titel angeben."; return; }
|
||||||
|
if (!terminZeit) { error = "Bitte Datum und Uhrzeit angeben."; return; }
|
||||||
|
formState = "sending";
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/termin", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: title.trim(),
|
||||||
|
terminZeit: toIso(terminZeit),
|
||||||
|
terminEnde: toIso(terminEnde),
|
||||||
|
locationText: locationText.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
sourceUrl: sourceUrl.trim(),
|
||||||
|
website,
|
||||||
|
_client_age_ms: mountedAt ? Date.now() - mountedAt : 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
formState = "sent";
|
||||||
|
} else {
|
||||||
|
const d = (await res.json().catch(() => ({}))) as { error?: string; errors?: Record<string, string> };
|
||||||
|
error = d.error ?? (d.errors ? Object.values(d.errors)[0] : "") ?? "Einreichen fehlgeschlagen.";
|
||||||
|
formState = "error";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
error = "Server nicht erreichbar.";
|
||||||
|
formState = "error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Termin melden – Windwiderstand</title>
|
||||||
|
<meta name="description" content="Veranstaltung oder Termin zum Regionalplan / Windkraft einreichen. Nach kurzer Prüfung erscheint er im Kalender." />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="container-custom max-w-2xl py-10">
|
||||||
|
<h1 class="text-2xl font-bold text-wald-900">Termin melden</h1>
|
||||||
|
<p class="mt-2 text-sm text-stein-600">
|
||||||
|
Veranstaltung, Versammlung oder Aktion zum Thema einreichen. Wir prüfen die Angaben und
|
||||||
|
schalten den Termin anschließend im <a href="/kalender" class="text-wald-700 underline">Kalender</a> frei.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if formState === "sent"}
|
||||||
|
<div class="mt-6 rounded-lg border border-wald-300 bg-wald-50 p-4 text-sm text-wald-800">
|
||||||
|
<p class="font-semibold">Danke, eingereicht.</p>
|
||||||
|
<p class="mt-1 text-stein-600">Der Termin erscheint im Kalender, sobald wir ihn geprüft und freigegeben haben.</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-3 rounded-md border border-stein-300 bg-white px-3 py-1.5 text-xs font-medium text-stein-700 hover:bg-stein-50"
|
||||||
|
onclick={() => { title = ""; terminZeit = ""; terminEnde = ""; locationText = ""; description = ""; sourceUrl = ""; formState = "idle"; }}
|
||||||
|
>Weiteren Termin melden</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<form class="mt-6 space-y-4" onsubmit={(e) => { e.preventDefault(); submit(); }}>
|
||||||
|
<!-- Honeypot -->
|
||||||
|
<div class="absolute left-[-9999px] h-px w-px overflow-hidden opacity-0" aria-hidden="true">
|
||||||
|
<label>Website<input type="text" tabindex="-1" autocomplete="off" bind:value={website} /></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-stein-700" for="t-title">Titel <span class="text-fire-600">*</span></label>
|
||||||
|
<input id="t-title" type="text" required bind:value={title} placeholder="z. B. Einwohnerversammlung Schleusingen" class="w-full rounded-md border border-stein-300 px-3 py-2 text-sm focus:border-wald-500 focus:outline-none focus:ring-2 focus:ring-wald-200" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-stein-700" for="t-start">Beginn <span class="text-fire-600">*</span></label>
|
||||||
|
<input id="t-start" type="datetime-local" required bind:value={terminZeit} class="w-full rounded-md border border-stein-300 px-3 py-2 text-sm focus:border-wald-500 focus:outline-none focus:ring-2 focus:ring-wald-200" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-stein-700" for="t-end">Ende <span class="text-stein-400">(optional)</span></label>
|
||||||
|
<input id="t-end" type="datetime-local" bind:value={terminEnde} class="w-full rounded-md border border-stein-300 px-3 py-2 text-sm focus:border-wald-500 focus:outline-none focus:ring-2 focus:ring-wald-200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-stein-700" for="t-loc">Ort <span class="text-stein-400">(optional)</span></label>
|
||||||
|
<input id="t-loc" type="text" bind:value={locationText} placeholder="Mehrzweckhalle, 98553 …" class="w-full rounded-md border border-stein-300 px-3 py-2 text-sm focus:border-wald-500 focus:outline-none focus:ring-2 focus:ring-wald-200" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-stein-700" for="t-desc">Beschreibung <span class="text-stein-400">(optional)</span></label>
|
||||||
|
<textarea id="t-desc" rows="4" bind:value={description} placeholder="Worum geht es? Wer lädt ein?" class="w-full rounded-md border border-stein-300 px-3 py-2 text-sm focus:border-wald-500 focus:outline-none focus:ring-2 focus:ring-wald-200"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-stein-700" for="t-url">Quelle / Link <span class="text-stein-400">(optional)</span></label>
|
||||||
|
<input id="t-url" type="url" bind:value={sourceUrl} placeholder="https://…" class="w-full rounded-md border border-stein-300 px-3 py-2 text-sm focus:border-wald-500 focus:outline-none focus:ring-2 focus:ring-wald-200" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="text-sm text-fire-600">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="text-xs text-stein-400">Eingereichte Termine werden vor der Veröffentlichung geprüft.</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-lg bg-wald-700 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-wald-800 disabled:opacity-50"
|
||||||
|
disabled={formState === "sending"}
|
||||||
|
>{formState === "sending" ? "Wird gesendet …" : "Termin einreichen"}</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user