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>
173 lines
7.0 KiB
TypeScript
173 lines
7.0 KiB
TypeScript
"use client"
|
|
|
|
import { useMemo, useState } from "react"
|
|
import Link from "next/link"
|
|
import { ChevronRight, FileText, Search, X } from "lucide-react"
|
|
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Separator } from "@/components/ui/separator"
|
|
import { Skeleton } from "@/components/ui/skeleton"
|
|
import { PrintPolicy } from "@/components/print-policy"
|
|
import { usePublicPrivacyPolicies } from "@/hooks/use-privacy-policies"
|
|
import { formatDate } from "@/lib/format"
|
|
import { PrivacyPolicy } from "@/types/privacy-policy"
|
|
|
|
export function PolicyList({ initial }: { initial: PrivacyPolicy[] }) {
|
|
const { policies, isLoading, error } = usePublicPrivacyPolicies(initial)
|
|
const [searchTerm, setSearchTerm] = useState("")
|
|
|
|
const filteredPolicies = useMemo(() => {
|
|
const term = searchTerm.trim().toLowerCase()
|
|
if (!term) return policies
|
|
|
|
return policies.filter((policy) =>
|
|
[policy.title, policy.responsible, policy.processingDescription].some((field) =>
|
|
field.toLowerCase().includes(term)
|
|
)
|
|
)
|
|
}, [policies, searchTerm])
|
|
|
|
return (
|
|
<main id="inhalt" tabIndex={-1} className="mx-auto w-full max-w-5xl flex-1 px-4 py-6 sm:px-6 sm:py-8">
|
|
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
|
<div className="space-y-1.5">
|
|
<h1 className="text-2xl font-semibold tracking-tight">
|
|
Datenschutzerklärungen
|
|
</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Transparente Informationen zu unserer Datenverarbeitung nach DSGVO
|
|
</p>
|
|
</div>
|
|
|
|
<div className="w-full sm:w-72">
|
|
<label htmlFor="suche" className="sr-only">
|
|
Datenschutzerklärungen durchsuchen
|
|
</label>
|
|
<div className="relative">
|
|
<Search
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
|
|
/>
|
|
<Input
|
|
id="suche"
|
|
type="search"
|
|
placeholder="Suchen…"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="px-9"
|
|
/>
|
|
{searchTerm && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={() => setSearchTerm("")}
|
|
className="absolute top-1/2 right-0.5 -translate-y-1/2 text-muted-foreground"
|
|
>
|
|
<X aria-hidden="true" />
|
|
<span className="sr-only">Suche zurücksetzen</span>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{error ? (
|
|
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
|
Fehler beim Laden der Daten: {error.message}
|
|
</div>
|
|
) : isLoading ? (
|
|
<div className="divide-y rounded-lg border">
|
|
{Array.from({ length: 4 }).map((_, index) => (
|
|
<div key={index} className="flex items-center gap-4 p-4">
|
|
<Skeleton className="size-9 rounded-lg" />
|
|
<div className="flex-1 space-y-2">
|
|
<Skeleton className="h-4 w-1/3" />
|
|
<Skeleton className="h-3.5 w-1/2" />
|
|
</div>
|
|
<Skeleton className="h-4 w-20" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : filteredPolicies.length === 0 ? (
|
|
<div className="flex flex-col items-center gap-3 rounded-lg border border-dashed px-6 py-16 text-center">
|
|
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
|
<FileText className="size-6 text-muted-foreground" />
|
|
</div>
|
|
<p className="font-medium">
|
|
{searchTerm
|
|
? "Keine Treffer"
|
|
: "Keine Datenschutzerklärungen verfügbar"}
|
|
</p>
|
|
<p className="max-w-sm text-sm text-muted-foreground">
|
|
{searchTerm
|
|
? "Versuchen Sie einen anderen Suchbegriff."
|
|
: "Derzeit sind keine öffentlichen Datenschutzerklärungen hinterlegt."}
|
|
</p>
|
|
{searchTerm && (
|
|
<Button variant="outline" onClick={() => setSearchTerm("")}>
|
|
Suche zurücksetzen
|
|
</Button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<ul className="divide-y overflow-hidden rounded-lg border bg-card">
|
|
{filteredPolicies.map((policy) => (
|
|
<li
|
|
key={policy.id}
|
|
className="group flex flex-wrap items-center gap-x-3 gap-y-1 px-3 transition-colors hover:bg-muted/40 sm:flex-nowrap"
|
|
>
|
|
{/* Echter Link: teilbar, in neuem Tab zu öffnen, ohne JavaScript
|
|
nutzbar. Der Pfeil gehört mit hinein, statt daneben ein
|
|
zweiter Link auf dasselbe Ziel zu stehen. */}
|
|
<Link
|
|
href={`/erklaerung/${policy.slug}`}
|
|
className="flex min-w-0 flex-1 items-center gap-3 rounded-md py-4 outline-none focus-visible:ring-[3px] focus-visible:ring-ring"
|
|
>
|
|
<span
|
|
aria-hidden="true"
|
|
className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-brand/10 text-brand"
|
|
>
|
|
<FileText className="size-4.5" />
|
|
</span>
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block font-medium">{policy.title}</span>
|
|
<span className="mt-0.5 block truncate text-sm text-muted-foreground">
|
|
{policy.responsible.replace(/[*_#`]/g, "").split("\n")[0]}
|
|
</span>
|
|
</span>
|
|
<ChevronRight
|
|
aria-hidden="true"
|
|
className="hidden size-4 shrink-0 text-muted-foreground group-hover:text-foreground sm:block"
|
|
/>
|
|
</Link>
|
|
|
|
<div className="flex shrink-0 items-center gap-2 pb-3 sm:pb-0">
|
|
<span className="text-sm text-muted-foreground">
|
|
<span className="sr-only">Zuletzt geändert: </span>
|
|
{formatDate(policy.updatedAt)}
|
|
</span>
|
|
<Separator orientation="vertical" className="h-5" />
|
|
<PrintPolicy policy={policy} size="icon-sm" variant="ghost" />
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{!isLoading && !error && filteredPolicies.length > 0 && (
|
|
<div className="mt-4 flex items-center justify-between gap-3 text-sm text-muted-foreground">
|
|
{/* Beim Filtern hört eine Vorlesesoftware sonst nicht, was passiert */}
|
|
<p role="status" aria-live="polite">
|
|
{filteredPolicies.length}{" "}
|
|
{filteredPolicies.length === 1 ? "Erklärung" : "Erklärungen"}
|
|
{searchTerm && ` von ${policies.length}`}
|
|
</p>
|
|
<Badge variant="outline">DSGVO-konform</Badge>
|
|
</div>
|
|
)}
|
|
</main>
|
|
)
|
|
}
|