All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 1m33s
Rechtsrahmen: § 13 Abs. 3 LBGG SH i. V. m. §§ 3 und 4 BITV 2.0. Eine Erklaerung "nicht barrierefrei" ersetzt die Herstellungspflicht nicht, sie dokumentiert den Stand nur voruebergehend. Farben, alle Werte gegen die WCAG-Formel nachgerechnet: - Sekundaertext 4.40:1 auf muted-Flaechen -> 4.55:1 - Rahmen von Eingabefeldern 1.24:1 -> 3:1 (WCAG 1.4.11); dekorative Trenner bleiben unveraendert - Badge-Farben scheiterten auf der eigenen 10-Prozent-Flaeche (brand 4.20, success 4.18, destructive 3.88) -> jeweils ueber 4.5 - Warnfarbe im Admin 3.11:1 -> amber-700 mit 4.90:1 Fokus war faktisch unsichtbar: Der Ring wurde durchgaengig mit halber Deckkraft gezeichnet (ring-ring/50), effektiv rund 1.5:1. Jetzt volle Deckkraft in Markenfarbe, 12 Dateien betroffen. Weiter: Sprungmarke zum Inhalt auf allen Seiten, Suchfelder beschriftet, Druck-Schaltflaechen hatten als reine Icon-Buttons gar keinen zugaenglichen Namen, Liste als echte Liste ausgezeichnet, doppelter Link je Zeile entfernt, Trefferzahl als Live-Region. Responsiv: Aussenabstaende und Kopfzeilen ab 320 Pixel, Listenzeilen brechen um. Neu nach § 4 BITV 2.0: Seiten fuer Leichte Sprache und Gebaerdensprache, im Admin pflegbar. Leer gelassen und damit noch nicht online - die Inhalte kommen vom Amt. In der Erklaerung als bekannte Luecke benannt. Geprueft mit axe-core (WCAG 2.1 A und AA) ueber alle oeffentlichen Seiten und den Adminbereich, hell und dunkel, dazu Tastaturbedienung, 200 Prozent Vergroesserung und 320 Pixel Breite: keine Verstoesse, kein waagerechter Ueberlauf. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
237 lines
8.0 KiB
TypeScript
237 lines
8.0 KiB
TypeScript
"use client"
|
||
|
||
import { Printer } from "lucide-react"
|
||
|
||
import { Button } from "@/components/ui/button"
|
||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||
import { useAppName } from "@/components/providers/app-config-provider"
|
||
import { PrivacyPolicy } from "@/types/privacy-policy"
|
||
import { escapeHtml, markdownToHtmlSync } from "@/lib/markdown"
|
||
|
||
interface PrintPolicyProps {
|
||
policy: PrivacyPolicy
|
||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
|
||
size?: "default" | "sm" | "lg" | "icon" | "icon-sm"
|
||
label?: string
|
||
className?: string
|
||
}
|
||
|
||
const iconSizes = ["icon", "icon-sm"]
|
||
|
||
export function PrintPolicy({
|
||
policy,
|
||
variant = "outline",
|
||
size = "sm",
|
||
label = "Drucken",
|
||
className,
|
||
}: PrintPolicyProps) {
|
||
const appName = useAppName()
|
||
|
||
const handlePrint = () => {
|
||
const printWindow = window.open("", "_blank")
|
||
if (!printWindow) return
|
||
|
||
printWindow.document.write(generatePrintContent(policy, appName))
|
||
printWindow.document.close()
|
||
}
|
||
|
||
const isIconOnly = iconSizes.includes(size)
|
||
|
||
const button = (
|
||
<Button variant={variant} size={size} onClick={handlePrint} className={className}>
|
||
<Printer aria-hidden="true" />
|
||
{isIconOnly ? (
|
||
// Ein Tooltip ist kein zugänglicher Name – ohne diesen Text wäre die
|
||
// Schaltfläche für Screenreader namenlos. Der Titel steht mit drin,
|
||
// weil in der Liste mehrere gleich aussehende Schaltflächen stehen.
|
||
<span className="sr-only">{`${label}: ${policy.title}`}</span>
|
||
) : (
|
||
label
|
||
)}
|
||
</Button>
|
||
)
|
||
|
||
if (!isIconOnly) return button
|
||
|
||
return (
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||
<TooltipContent>Als PDF drucken</TooltipContent>
|
||
</Tooltip>
|
||
)
|
||
}
|
||
|
||
function generatePrintContent(policy: PrivacyPolicy, appName: string): string {
|
||
const formatDate = (dateString: string) =>
|
||
new Date(dateString).toLocaleDateString("de-DE", {
|
||
year: "numeric",
|
||
month: "long",
|
||
day: "numeric",
|
||
})
|
||
|
||
const cleanMarkdown = (content: string) => markdownToHtmlSync(content)
|
||
|
||
// Art. 21 Abs. 4 verlangt den Hinweis auf das Widerspruchsrecht „von anderen
|
||
// Informationen getrennt“ – in der Druckfassung als eigener, gerahmter Block.
|
||
const objectionNotice = (content?: string | null) =>
|
||
content
|
||
? `<section class="objection">
|
||
<h2 class="objection-title">Ihr Widerspruchsrecht nach Art. 21 DSGVO</h2>
|
||
<div class="section-content">${cleanMarkdown(content)}</div>
|
||
</section>`
|
||
: ""
|
||
|
||
const section = (title: string, content?: string | null) =>
|
||
content
|
||
? `<section class="section">
|
||
<h2 class="section-title">${title}</h2>
|
||
<div class="section-content">${cleanMarkdown(content)}</div>
|
||
</section>`
|
||
: ""
|
||
|
||
return `
|
||
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>${escapeHtml(policy.title)}</title>
|
||
<style>
|
||
@page { margin: 2cm 1.8cm; size: A4; }
|
||
* { box-sizing: border-box; }
|
||
body {
|
||
font-family: -apple-system, "Segoe UI", Arial, sans-serif;
|
||
font-size: 10pt;
|
||
line-height: 1.5;
|
||
color: #18181b;
|
||
margin: 0;
|
||
}
|
||
.header {
|
||
border-bottom: 1px solid #d4d4d8;
|
||
padding-bottom: 10px;
|
||
margin-bottom: 18px;
|
||
page-break-after: avoid;
|
||
}
|
||
.eyebrow {
|
||
font-size: 7.5pt;
|
||
letter-spacing: .08em;
|
||
text-transform: uppercase;
|
||
color: #71717a;
|
||
margin: 0 0 4px 0;
|
||
}
|
||
.header h1 {
|
||
margin: 0 0 4px 0;
|
||
font-size: 17pt;
|
||
font-weight: 600;
|
||
letter-spacing: -0.01em;
|
||
line-height: 1.2;
|
||
}
|
||
.meta-info { font-size: 8pt; color: #71717a; margin: 0; }
|
||
.section { margin-bottom: 14px; page-break-inside: avoid; orphans: 3; widows: 3; }
|
||
.objection {
|
||
margin: 16px 0;
|
||
padding: 10px 12px;
|
||
border: 1.5pt solid #52525b;
|
||
border-radius: 3px;
|
||
page-break-inside: avoid;
|
||
}
|
||
.objection-title {
|
||
font-size: 10pt;
|
||
font-weight: 700;
|
||
margin: 0 0 5px 0;
|
||
page-break-after: avoid;
|
||
}
|
||
.section-title {
|
||
font-size: 9pt;
|
||
font-weight: 600;
|
||
letter-spacing: .04em;
|
||
text-transform: uppercase;
|
||
color: #52525b;
|
||
margin: 0 0 5px 0;
|
||
padding-bottom: 3px;
|
||
border-bottom: 1px solid #e4e4e7;
|
||
page-break-after: avoid;
|
||
}
|
||
.section-content { font-size: 9.5pt; }
|
||
.section-content > :first-child { margin-top: 0; }
|
||
.section-content > :last-child { margin-bottom: 0; }
|
||
.section-content p { margin: 0 0 5px 0; }
|
||
.section-content h1, .section-content h2, .section-content h3 {
|
||
font-size: 9.5pt; font-weight: 600; margin: 8px 0 3px 0;
|
||
}
|
||
.section-content strong { font-weight: 600; }
|
||
.section-content code {
|
||
background: #f4f4f5; padding: 1px 3px; border-radius: 2px;
|
||
font-family: ui-monospace, "Courier New", monospace; font-size: 8.5pt;
|
||
}
|
||
.section-content ul, .section-content ol { margin: 4px 0; padding-left: 16px; }
|
||
.section-content li { margin: 2px 0; }
|
||
.section-content a { color: #2563eb; text-decoration: none; }
|
||
.footer {
|
||
border-top: 1px solid #e4e4e7;
|
||
margin-top: 24px;
|
||
padding-top: 8px;
|
||
font-size: 7.5pt;
|
||
color: #a1a1aa;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
page-break-inside: avoid;
|
||
}
|
||
@media print {
|
||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header class="header">
|
||
<p class="eyebrow">${escapeHtml(appName)} · Datenschutzerklärung</p>
|
||
<h1>${escapeHtml(policy.title)}</h1>
|
||
<p class="meta-info">
|
||
Erstellt am ${formatDate(policy.createdAt)}${
|
||
policy.updatedAt !== policy.createdAt
|
||
? ` · Aktualisiert am ${formatDate(policy.updatedAt)}`
|
||
: ""
|
||
}
|
||
</p>
|
||
</header>
|
||
|
||
${section("Verantwortlicher im Sinne der DSGVO", policy.responsible)}
|
||
${section("Kontaktdaten des Datenschutzbeauftragten", policy.contactDPO)}
|
||
${section("Bezeichnung der Verarbeitungstätigkeit", policy.processingDescription)}
|
||
${section("Zweck der Datenerhebung", policy.processingPurposes)}
|
||
${section("Rechtsgrundlage der Datenerhebung", policy.legalBasis)}
|
||
${section("Berechtigte Interessen", policy.legitimateInterests)}
|
||
${section("Kategorien der verarbeiteten Daten", policy.dataCategories)}
|
||
${section("Geplante Empfänger der Daten", policy.recipients)}
|
||
${section("Übermittlung in Drittländer", policy.thirdCountryTransfer)}
|
||
${section("Speicherdauer der Daten", policy.storageDuration)}
|
||
${section("Herkunft der Daten", policy.dataSource)}
|
||
${section("Automatisierte Entscheidungsfindung", policy.automatedDecision)}
|
||
${section("Betroffenen-Rechte", policy.dataSubjectsRights)}
|
||
${section("Beschwerderecht bei der Aufsichtsbehörde", policy.complaintRight)}
|
||
${section("Widerrufsrecht bei Einwilligung", policy.withdrawalRight)}
|
||
${objectionNotice(policy.objectionRight)}
|
||
${section("Ihre Pflicht zur Bereitstellung der Daten", policy.provisionObligation)}
|
||
${section("Folgen, wenn Sie die Daten nicht angeben", policy.provisionConsequences)}
|
||
|
||
<footer class="footer">
|
||
<span>${escapeHtml(policy.title)}</span>
|
||
<span>Gedruckt am ${new Date().toLocaleDateString("de-DE", {
|
||
year: "numeric",
|
||
month: "long",
|
||
day: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
})}</span>
|
||
</footer>
|
||
|
||
<script>
|
||
window.onload = function () {
|
||
setTimeout(function () { window.print(); }, 250);
|
||
};
|
||
</script>
|
||
</body>
|
||
</html>
|
||
`
|
||
}
|