syncova-policies/components/site-pages-form.tsx
Weapie 265ca8ac38
All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 5m15s
Improve accessibility and access controls; verify production and database recovery
2026-09-14 11:44:41 +02:00

267 lines
9.6 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 { FormErrors } from "@/components/form-errors"
import { sitePageTemplates } from "@/lib/site-page-templates"
import { useEffect, useState } from "react"
import { 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).`,
...sitePageTemplates,
}
interface SitePagesFormProps {
open: boolean
onOpenChange: (open: boolean) => void
}
/**
* Pflegt Impressum, Datenschutzerklärung und Erklärung zur Barrierefreiheit.
* Veröffentlichte Seiten dürfen nicht versehentlich geleert werden.
*/
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-[calc(100dvh-2rem)] 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 aria-label="Bearbeitungsformular" 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>
<FormErrors errors={errors} />
<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">
Videodatei auf dem eigenen Server (beginnt mit /) oder
Player-Adresse eines freigegebenen Anbieters. Derzeit
freigegeben: alangu.de. Andere Videoportale sind
bewusst gesperrt – sie übertragen 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, für eigene Videodateien
(WCAG 1.2.2). Bei eingebetteten Playern müssen die Untertitel dort eingerichtet und geprüft werden.
</p>
</div>
</div>
)}
{key in sitePageTemplates && !formData[key].trim() && <Button type="button" variant="outline" onClick={() => setFormData(prev => ({ ...prev, [key]: sitePageTemplates[key as keyof typeof sitePageTemplates] }))}>Vorlage einfügen</Button>}
<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>
)
}