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>
203 lines
8.1 KiB
TypeScript
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>
|
|
)
|
|
}
|