All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 5m15s
267 lines
9.6 KiB
TypeScript
267 lines
9.6 KiB
TypeScript
"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>
|
||
)
|
||
}
|