syncova-policies/app/page.tsx
Weapie 328f042307 Syncova Policies: Portal für DSGVO-Datenschutzerklärungen
Next.js 15 App Router mit öffentlicher Übersicht und geschütztem
Admin-Bereich zur Pflege von Datenschutzerklärungen nach Art. 12ff. DSGVO.

- Oberfläche auf Basis von shadcn/ui, Inter lokal eingebunden
- Prisma/SQLite, Auth.js mit Credentials-Provider und Rollen
- Massenimport der Merkblätter des Amtes Leezen aus PDF
- Docker-Image (standalone) und Gitea-Workflow zur Veröffentlichung

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 16:23:51 +02:00

203 lines
8.1 KiB
TypeScript

"use client"
import { useMemo, useState } from "react"
import { ChevronRight, FileText, Search, Settings2, 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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { Brand } from "@/components/brand"
import { PolicyDetail } from "@/components/policy-detail"
import { PrintPolicy } from "@/components/print-policy"
import { ThemeToggle } from "@/components/theme-toggle"
import { usePublicPrivacyPolicies } from "@/hooks/use-privacy-policies"
import { PrivacyPolicy } from "@/types/privacy-policy"
import { formatDate } from "@/lib/format"
export default function PublicDashboard() {
const { policies, isLoading, error } = usePublicPrivacyPolicies()
const [selectedPolicy, setSelectedPolicy] = useState<PrivacyPolicy | null>(null)
const [isDetailOpen, setIsDetailOpen] = useState(false)
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])
const handleViewDetails = (policy: PrivacyPolicy) => {
setSelectedPolicy(policy)
setIsDetailOpen(true)
}
return (
<div className="min-h-screen bg-background">
<header className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between gap-4 px-6">
<Brand subtitle="Datenschutz" />
<div className="flex items-center gap-1">
<ThemeToggle />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => window.open("/admin", "_blank")}
>
<Settings2 />
<span className="sr-only">Admin-Bereich</span>
</Button>
</TooltipTrigger>
<TooltipContent>Admin-Bereich</TooltipContent>
</Tooltip>
</div>
</div>
</header>
<main className="mx-auto max-w-5xl px-6 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="relative w-full sm:w-72">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
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 />
<span className="sr-only">Suche zurücksetzen</span>
</Button>
)}
</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>
) : (
<div className="divide-y overflow-hidden rounded-lg border bg-card">
{filteredPolicies.map((policy) => (
<div
key={policy.id}
className="group flex items-center gap-3 px-3 transition-colors hover:bg-muted/40"
>
<button
type="button"
onClick={() => handleViewDetails(policy)}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 rounded-md py-4 text-left outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<span 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 truncate 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>
</button>
<div className="flex shrink-0 items-center gap-2 pr-1">
<span className="hidden text-sm text-muted-foreground sm:inline">
{formatDate(policy.updatedAt)}
</span>
<Separator orientation="vertical" className="hidden h-5 sm:block" />
<PrintPolicy policy={policy} size="icon-sm" variant="ghost" />
<Button
variant="ghost"
size="icon-sm"
onClick={() => handleViewDetails(policy)}
className="text-muted-foreground group-hover:text-foreground"
>
<ChevronRight />
<span className="sr-only">Details anzeigen</span>
</Button>
</div>
</div>
))}
</div>
)}
{!isLoading && !error && filteredPolicies.length > 0 && (
<div className="mt-4 flex items-center justify-between text-sm text-muted-foreground">
<span>
{filteredPolicies.length}{" "}
{filteredPolicies.length === 1 ? "Erklärung" : "Erklärungen"}
{searchTerm && ` von ${policies.length}`}
</span>
<Badge variant="outline">DSGVO-konform</Badge>
</div>
)}
</main>
<PolicyDetail
policy={selectedPolicy}
open={isDetailOpen}
onOpenChange={setIsDetailOpen}
/>
</div>
)
}