syncova-policies/components/policy-list.tsx
Weapie 3633e8a4cf
All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 1m33s
Responsiv machen und Barrierefreiheit nach WCAG 2.1 AA herstellen
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>
2026-09-08 15:44:30 +02:00

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>
)
}