syncova-policies/components/policy-list.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

167 lines
6.9 KiB
TypeScript

"use client"
import { useMemo, useRef, useState } from "react"
import Link from "next/link"
import { ChevronRight, FileText, Search, X } from "lucide-react"
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 searchRef = useRef<HTMLInputElement>(null)
const resetSearch = () => { setSearchTerm(""); searchRef.current?.focus() }
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"
ref={searchRef}
type="search"
placeholder="Suchen…"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="px-9"
/>
{searchTerm && (
<Button
variant="ghost"
size="icon-sm"
onClick={resetSearch}
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={resetSearch}>
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>
)}
<p role="status" aria-live="polite" aria-atomic="true" className="mt-4 text-sm text-muted-foreground">
{error ? "Fehler beim Laden der Datenschutzerklärungen." : isLoading ? "Datenschutzerklärungen werden geladen…" : filteredPolicies.length + (filteredPolicies.length === 1 ? " Erklärung" : " Erklärungen") + (searchTerm ? " von " + policies.length : "")}
</p>
</main>
)
}