syncova-policies/components/site-pages-form.tsx
Weapie 5aa8880190 Gebaerdensprach-Video ermoeglichen und Erklaerung anpassen
Ein DGS-Video liess sich bisher gar nicht einbinden: Der Markdown-
Prozessor laeuft mit Sanitizing, und remark reicht rohes HTML dann von
vornherein nicht durch - <video> kam als leeres <p> heraus. Eine
erweiterte Freigabeliste allein half deshalb nicht.

Statt rohes HTML fuer alle Textfelder zu oeffnen - auch fuer die 121
Erklaerungen - bekommt die Gebaerdensprach-Seite zwei eigene Felder fuer
Video- und Untertiteladresse. Ausgegeben wird ein natives <video
controls> mit <track kind="captions"> und Bildunterschrift.

Adressen werden zweimal geprueft, beim Speichern und vor der Ausgabe:
zugelassen sind nur der eigene Server und https. javascript:, data:,
protokollrelative und einfache http-Adressen werden abgewiesen. iframe
bleibt bewusst gesperrt - eine YouTube-Einbindung wuerde Besucherdaten
an Dritte uebertragen.

Erklaerung zur Barrierefreiheit entsprechend gekuerzt: Die beiden
Luecken nach § 4 BITV 2.0 entfallen, es bleibt die ungepruefte
Druckansicht.

Geprueft: Video wird mit Untertitelspur ausgeliefert und ist per Tastatur
erreichbar, axe-core meldet auf beiden neuen Seiten in hell und dunkel
keine Verstoesse, Adresspruefung weist alle acht Testfaelle korrekt zu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:18:09 +02:00

341 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { useEffect, useState } from "react"
import { AlertCircle, ExternalLink } from "lucide-react"
import Link from "next/link"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { MarkdownInput } from "@/components/markdown-input"
import { updateSitePages, useSitePages } from "@/hooks/use-site-pages"
import { ApiError } from "@/lib/api"
import {
EMPTY_SITE_PAGES,
SITE_PAGES,
SITE_PAGE_META,
SitePageKey,
SitePagesContent,
} from "@/lib/site-pages"
/**
* Vorlagen als Platzhalter. Sie werden nicht gespeichert, sondern zeigen nur,
* welche Angaben die jeweilige Seite enthalten muss.
*/
const placeholders: Record<SitePageKey, string> = {
imprint: `## Angaben gemäß § 5 DDG
**Amt Musterstadt**
Musterstraße 1
12345 Musterstadt
**Vertreten durch:** Amtsvorsteher/in Vorname Nachname
**Kontakt**
Telefon: 01234 5678-0
E-Mail: info@amt-musterstadt.de
**Zuständige Aufsichtsbehörde**
Name und Anschrift der Rechtsaufsicht
**Verantwortlich für den Inhalt:** Vorname Nachname, Anschrift`,
privacy: `## Datenschutzerklärung für dieses Portal
Diese Erklärung betrifft die Verarbeitung Ihrer Daten beim **Besuch dieses
Portals**. Die Merkblätter zu den einzelnen Verwaltungsverfahren finden Sie in
der Übersicht.
### Verantwortlicher
Name, Anschrift, Kontakt
### Datenschutzbeauftragte/r
Name, Anschrift, E-Mail
### Server-Protokolldaten
Beim Aufruf werden IP-Adresse, Zeitpunkt, aufgerufene Seite, übertragene
Datenmenge und Browserkennung protokolliert.
*Zweck:* technischer Betrieb und Abwehr von Angriffen
*Rechtsgrundlage:* Art. 6 Abs. 1 lit. e DSGVO i. V. m. Landesrecht
*Speicherdauer:* z. B. 7 Tage
### Cookies
Ohne Anmeldung setzt das Portal keine Cookies. Im Admin-Bereich wird ein
technisch notwendiges Sitzungs-Cookie verwendet.
### Ihre Rechte
Auskunft (Art. 15), Berichtigung (Art. 16), Löschung (Art. 17), Einschränkung
(Art. 18), Widerspruch (Art. 21) sowie Beschwerde bei der Aufsichtsbehörde
(Art. 77 DSGVO).`,
accessibility: `## Erklärung zur Barrierefreiheit
Diese Erklärung gilt für die Website **datenschutz.beispiel.de**.
### Stand der Vereinbarkeit mit den Anforderungen
Diese Website ist mit der BITV 2.0 *[vollständig / teilweise / nicht]*
vereinbar.
### Nicht barrierefreie Inhalte
Hier die bekannten Einschränkungen und deren Begründung auflisten.
### Erstellung dieser Erklärung
Diese Erklärung wurde am TT.MM.JJJJ erstellt.
Grundlage war eine *[Selbstbewertung / Prüfung durch Dritte]*.
Letzte Überprüfung: TT.MM.JJJJ
### Barrieren melden – Kontakt
Sie können uns Mängel bei der Barrierefreiheit mitteilen und Informationen in
barrierefreier Form anfordern:
Name, E-Mail, Telefon
### Schlichtungsverfahren
Wenn wir auf Ihre Meldung nicht zufriedenstellend reagieren, können Sie die
Schlichtungsstelle nach § 16 BGG anrufen:
Anschrift und Kontakt der zuständigen Schlichtungsstelle`,
easyLanguage: `## Willkommen
Das hier ist die Internet-Seite vom Amt Musterstadt.
Hier steht: Was macht das Amt mit Ihren Daten?
## Was finden Sie auf dieser Seite?
Sie finden hier eine Liste.
In der Liste stehen viele Themen.
Zum Beispiel: Bau-Antrag. Oder: Wohn-Geld.
Zu jedem Thema gibt es einen Text.
In dem Text steht: Welche Daten braucht das Amt?
Und: Wie lange speichert das Amt die Daten?
## Wie finden Sie ein Thema?
Oben auf der Seite ist ein Such-Feld.
Dort können Sie ein Wort eintippen.
Dann zeigt die Seite nur noch passende Themen.
Sie können auch auf ein Thema in der Liste klicken.
## Barriere-Freiheit
Barriere-frei heißt: Alle Menschen können die Seite benutzen.
Wir arbeiten daran, dass die Seite barriere-frei wird.
Manche Sachen sind noch nicht barriere-frei.
Sie können uns das melden.
Schreiben Sie an: *[E-Mail-Adresse eintragen]*
*Dieser Text muss vor der Veröffentlichung von einer Prüf-Gruppe
gegengelesen werden. Das ist bei Leichter Sprache üblich.*`,
signLanguage: `## Informationen in Deutscher Gebärdensprache
Das Video oben erklärt:
- die wesentlichen Inhalte dieses Portals
- wie die Navigation funktioniert
- die wesentlichen Inhalte der Erklärung zur Barrierefreiheit
Die Adresse des Videos tragen Sie oben in das Feld ein; es erscheint dann
über diesem Text.
### Kontakt
Fragen zur Barrierefreiheit richten Sie bitte an:
*[Kontaktdaten eintragen]*`,
}
interface SitePagesFormProps {
open: boolean
onOpenChange: (open: boolean) => void
}
/**
* Pflegt Impressum, Datenschutzerklärung und Erklärung zur Barrierefreiheit.
* Ein leer gelassener Text nimmt die betreffende Seite vom Netz.
*/
export function SitePagesForm({ open, onOpenChange }: SitePagesFormProps) {
const { pages, isLoading } = useSitePages()
const [formData, setFormData] = useState<SitePagesContent>(EMPTY_SITE_PAGES)
const [errors, setErrors] = useState<string[]>([])
const [isSubmitting, setIsSubmitting] = useState(false)
const [tab, setTab] = useState<SitePageKey>("imprint")
// Beim Öffnen den gespeicherten Stand laden
useEffect(() => {
if (!open || isLoading) return
setErrors([])
setTab("imprint")
setFormData({ ...pages })
// `pages` bewusst nicht als Abhängigkeit: sonst würden Eingaben beim
// Hintergrund-Refresh von SWR überschrieben.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, isLoading])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setErrors([])
setIsSubmitting(true)
try {
await updateSitePages(formData)
toast.success("Seiteninhalte gespeichert")
onOpenChange(false)
} catch (error) {
const details =
error instanceof ApiError && error.details.length > 0
? error.details
: [error instanceof Error ? error.message : "Fehler beim Speichern"]
setErrors(details)
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[88vh] sm:max-w-4xl">
<DialogHeader>
<DialogTitle>Rechtliche Seiten</DialogTitle>
<DialogDescription>
Die eigenständigen Seiten des Portals: drei rechtliche Pflichtseiten
sowie Leichte Sprache und Gebärdensprache nach § 4 BITV 2.0. Sie
erscheinen in der Fußzeile der öffentlichen Seiten – ein leer
gelassener Text nimmt die betreffende Seite vom Netz.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col gap-4">
<Tabs
value={tab}
onValueChange={(value) => setTab(value as SitePageKey)}
className="min-h-0 flex-1 gap-3"
>
<TabsList className="h-auto flex-wrap">
{SITE_PAGES.map((key) => (
<TabsTrigger key={key} value={key}>
{SITE_PAGE_META[key].short}
</TabsTrigger>
))}
</TabsList>
{errors.length > 0 && (
<div className="flex gap-2.5 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<ul className="space-y-1">
{errors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</div>
)}
<DialogBody className="pt-1">
{SITE_PAGES.map((key) => (
<TabsContent key={key} value={key} className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
<span>{SITE_PAGE_META[key].hint}</span>
{formData[key].trim() && (
<Button variant="ghost" size="sm" asChild>
<Link href={SITE_PAGE_META[key].path} target="_blank">
<ExternalLink />
{SITE_PAGE_META[key].path}
</Link>
</Button>
)}
</div>
{/* Das Video steht in eigenen Feldern, nicht im Markdown:
Der Markdown-Prozessor filtert HTML weg. */}
{key === "signLanguage" && (
<div className="grid gap-3 rounded-lg border bg-muted/30 p-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="signLanguageVideo" className="gap-1">
Adresse des Videos
</Label>
<Input
id="signLanguageVideo"
value={formData.signLanguageVideo}
onChange={(e) =>
setFormData((prev) => ({
...prev,
signLanguageVideo: e.target.value,
}))
}
placeholder="/videos/gebaerdensprache.mp4"
/>
<p className="text-xs text-muted-foreground">
Eigener Server (beginnt mit /) oder https. Kein
YouTube oder Vimeo – das überträgt Besucherdaten an
Dritte.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="signLanguageCaptions" className="gap-1">
Untertiteldatei (optional)
</Label>
<Input
id="signLanguageCaptions"
value={formData.signLanguageCaptions}
onChange={(e) =>
setFormData((prev) => ({
...prev,
signLanguageCaptions: e.target.value,
}))
}
placeholder="/videos/gebaerdensprache.vtt"
/>
<p className="text-xs text-muted-foreground">
WebVTT-Datei. Nötig, sobald das Video gesprochenen Ton
enthält (WCAG 1.2.2).
</p>
</div>
</div>
)}
<MarkdownInput
id={`site-page-${key}`}
label={SITE_PAGE_META[key].title}
value={formData[key]}
onChange={(value) =>
setFormData((prev) => ({ ...prev, [key]: value }))
}
placeholder={placeholders[key]}
rows={20}
/>
</TabsContent>
))}
</DialogBody>
</Tabs>
<DialogFooter className="border-t pt-4">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Abbrechen
</Button>
<Button type="submit" disabled={isSubmitting || isLoading}>
{isSubmitting ? "Speichern…" : "Seiteninhalte speichern"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}