Responsiv machen und Barrierefreiheit nach WCAG 2.1 AA herstellen
All checks were successful
Container-Image bauen und veröffentlichen / build-and-push (push) Successful in 1m33s
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>
This commit is contained in:
parent
c951a4123a
commit
3633e8a4cf
@ -217,7 +217,7 @@ export default function AdminDashboard() {
|
||||
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-7xl items-center justify-between gap-4 px-6">
|
||||
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between gap-2 px-4 sm:gap-4 sm:px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Brand />
|
||||
<Badge variant="secondary">Admin</Badge>
|
||||
@ -314,7 +314,7 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-7xl px-6 py-8">
|
||||
<main id="inhalt" tabIndex={-1} className="mx-auto max-w-7xl px-4 py-6 sm:px-6 sm:py-8">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="space-y-1.5">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
@ -326,7 +326,7 @@ export default function AdminDashboard() {
|
||||
{incompleteCount > 0 && (
|
||||
<>
|
||||
{" · "}
|
||||
<span className="text-amber-600 dark:text-amber-500">
|
||||
<span className="text-amber-700 dark:text-amber-400">
|
||||
{incompleteCount} unvollständig
|
||||
</span>
|
||||
</>
|
||||
@ -334,30 +334,40 @@ export default function AdminDashboard() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1 sm:w-64 sm:flex-none">
|
||||
<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 className="flex w-full items-center gap-2 sm:w-auto">
|
||||
<div className="min-w-0 flex-1 sm:w-64 sm:flex-none">
|
||||
<label htmlFor="admin-suche" className="sr-only">
|
||||
Erklärungen nach Titel oder Verantwortlichem 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="admin-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>
|
||||
|
||||
<Button onClick={handleAddNew}>
|
||||
<Plus />
|
||||
<Button onClick={handleAddNew} className="shrink-0">
|
||||
<Plus aria-hidden="true" />
|
||||
Neu
|
||||
</Button>
|
||||
</div>
|
||||
@ -448,7 +458,7 @@ export default function AdminDashboard() {
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="cursor-help border-amber-500/40 text-amber-600 dark:text-amber-500"
|
||||
className="cursor-help border-amber-500/40 text-amber-700 dark:text-amber-400"
|
||||
>
|
||||
<TriangleAlert />
|
||||
{missingByPolicy.get(policy.id)!.length}
|
||||
|
||||
@ -55,7 +55,7 @@ export default function SignInPage() {
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<main className="flex flex-1 items-center justify-center px-6 pb-20">
|
||||
<main id="inhalt" tabIndex={-1} className="flex flex-1 items-center justify-center px-4 pb-20 sm:px-6">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className="flex size-11 items-center justify-center rounded-xl bg-brand text-brand-foreground">
|
||||
|
||||
@ -129,7 +129,7 @@ export default async function PolicyPage({
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<PublicHeader subtitle="Datenschutz" />
|
||||
|
||||
<main className="mx-auto w-full max-w-3xl flex-1 px-6 py-8">
|
||||
<main id="inhalt" tabIndex={-1} className="mx-auto w-full max-w-3xl flex-1 px-4 py-6 sm:px-6 sm:py-8">
|
||||
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-4 text-muted-foreground">
|
||||
<Link href="/">
|
||||
<ArrowLeft />
|
||||
|
||||
10
app/gebaerdensprache/page.tsx
Normal file
10
app/gebaerdensprache/page.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { LegalPage, legalPageMetadata } from "@/components/legal-page"
|
||||
|
||||
// Inhalt kommt aus der Datenbank und wird im Admin gepflegt.
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export const metadata = legalPageMetadata("signLanguage")
|
||||
|
||||
export default function Page() {
|
||||
return <LegalPage page="signLanguage" />
|
||||
}
|
||||
@ -17,16 +17,19 @@
|
||||
--secondary: oklch(0.968 0.002 286);
|
||||
--secondary-foreground: oklch(0.21 0.006 286);
|
||||
--muted: oklch(0.968 0.002 286);
|
||||
--muted-foreground: oklch(0.552 0.014 286);
|
||||
/* 4.55:1 auch auf muted-Flächen – vorher 0.552 und damit knapp darunter */
|
||||
--muted-foreground: oklch(0.545 0.014 286);
|
||||
--accent: oklch(0.968 0.002 286);
|
||||
--accent-foreground: oklch(0.21 0.006 286);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive: oklch(0.520 0.245 27.325); /* 4.5:1 auch auf 10%-Fläche im Badge */
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--success: oklch(0.596 0.145 163);
|
||||
--success: oklch(0.495 0.145 163); /* 4.5:1 auch auf 10%-Fläche im Badge */
|
||||
--border: oklch(0.918 0.004 286);
|
||||
--input: oklch(0.918 0.004 286);
|
||||
--ring: oklch(0.6 0.012 286);
|
||||
--brand: oklch(0.55 0.155 258);
|
||||
/* Rahmen von Eingabefeldern: 3:1 nach WCAG 1.4.11, sonst ist das Feld
|
||||
nicht als solches erkennbar. Dekorative Trenner nutzen --border. */
|
||||
--input: oklch(0.620 0.004 286);
|
||||
--ring: oklch(0.53 0.155 258); /* Fokus in Markenfarbe, 5.2:1 */
|
||||
--brand: oklch(0.53 0.155 258); /* 4.5:1 auch auf 10%-Fläche im Badge */
|
||||
--brand-foreground: oklch(0.985 0 0);
|
||||
--chart-1: oklch(0.55 0.155 258);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
@ -58,12 +61,12 @@
|
||||
--muted-foreground: oklch(0.712 0.013 286);
|
||||
--accent: oklch(0.256 0.006 286);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.637 0.237 25.331);
|
||||
--destructive: oklch(0.670 0.237 25.331); /* 4.5:1 auf 20%-Fläche im Badge */
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--success: oklch(0.696 0.17 162.48);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.55 0.012 286);
|
||||
--input: oklch(0.500 0.006 286); /* deckend statt transparent, 3:1 */
|
||||
--ring: oklch(0.68 0.14 258); /* Fokus in Markenfarbe, 6.9:1 */
|
||||
--brand: oklch(0.68 0.14 258);
|
||||
--brand-foreground: oklch(0.14 0.004 286);
|
||||
--chart-1: oklch(0.68 0.14 258);
|
||||
@ -148,7 +151,7 @@
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
@apply border-border outline-ring;
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@ -4,6 +4,7 @@ import localFont from "next/font/local"
|
||||
import { AppConfigProvider } from "@/components/providers/app-config-provider"
|
||||
import { AuthProvider } from "@/components/providers/session-provider"
|
||||
import { appName } from "@/lib/app-config"
|
||||
import { SkipLink } from "@/components/skip-link"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import "./globals.css"
|
||||
|
||||
@ -56,6 +57,7 @@ export default function RootLayout({
|
||||
/>
|
||||
</head>
|
||||
<body className={inter.className}>
|
||||
<SkipLink />
|
||||
<AppConfigProvider appName={appName()}>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</AppConfigProvider>
|
||||
|
||||
10
app/leichte-sprache/page.tsx
Normal file
10
app/leichte-sprache/page.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { LegalPage, legalPageMetadata } from "@/components/legal-page"
|
||||
|
||||
// Inhalt kommt aus der Datenbank und wird im Admin gepflegt.
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export const metadata = legalPageMetadata("easyLanguage")
|
||||
|
||||
export default function Page() {
|
||||
return <LegalPage page="easyLanguage" />
|
||||
}
|
||||
@ -70,7 +70,7 @@ export function DefaultableField({
|
||||
invalid={invalid}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Badge variant="outline" className="text-amber-600 dark:text-amber-500">
|
||||
<Badge variant="outline" className="text-amber-700 dark:text-amber-400">
|
||||
Weicht vom Standard ab
|
||||
</Badge>
|
||||
<Button
|
||||
|
||||
@ -27,7 +27,7 @@ export async function LegalPage({ page }: { page: SitePageKey }) {
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<PublicHeader subtitle={meta.title} />
|
||||
|
||||
<main className="mx-auto w-full max-w-3xl flex-1 px-6 py-8">
|
||||
<main id="inhalt" tabIndex={-1} className="mx-auto w-full max-w-3xl flex-1 px-4 py-6 sm:px-6 sm:py-8">
|
||||
<h1 className="mb-6 text-2xl font-semibold tracking-tight">
|
||||
{meta.title}
|
||||
</h1>
|
||||
|
||||
@ -30,7 +30,7 @@ export function PolicyList({ initial }: { initial: PrivacyPolicy[] }) {
|
||||
}, [policies, searchTerm])
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-6 py-8">
|
||||
<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">
|
||||
@ -41,25 +41,35 @@ export function PolicyList({ initial }: { initial: PrivacyPolicy[] }) {
|
||||
</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 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>
|
||||
|
||||
@ -102,60 +112,58 @@ export function PolicyList({ initial }: { initial: PrivacyPolicy[] }) {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y overflow-hidden rounded-lg border bg-card">
|
||||
<ul className="divide-y overflow-hidden rounded-lg border bg-card">
|
||||
{filteredPolicies.map((policy) => (
|
||||
<div
|
||||
<li
|
||||
key={policy.id}
|
||||
className="group flex items-center gap-3 px-3 transition-colors hover:bg-muted/40"
|
||||
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 */}
|
||||
{/* 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/50"
|
||||
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 className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-brand/10 text-brand">
|
||||
<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 truncate font-medium">
|
||||
{policy.title}
|
||||
</span>
|
||||
<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 pr-1">
|
||||
<span className="hidden text-sm text-muted-foreground sm:inline">
|
||||
<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="hidden h-5 sm:block" />
|
||||
<Separator orientation="vertical" className="h-5" />
|
||||
<PrintPolicy policy={policy} size="icon-sm" variant="ghost" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
asChild
|
||||
className="text-muted-foreground group-hover:text-foreground"
|
||||
>
|
||||
<Link href={`/erklaerung/${policy.slug}`}>
|
||||
<ChevronRight />
|
||||
<span className="sr-only">{policy.title} öffnen</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && filteredPolicies.length > 0 && (
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
<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}`}
|
||||
</span>
|
||||
</p>
|
||||
<Badge variant="outline">DSGVO-konform</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -39,8 +39,15 @@ export function PrintPolicy({
|
||||
|
||||
const button = (
|
||||
<Button variant={variant} size={size} onClick={handlePrint} className={className}>
|
||||
<Printer />
|
||||
{!isIconOnly && label}
|
||||
<Printer aria-hidden="true" />
|
||||
{isIconOnly ? (
|
||||
// Ein Tooltip ist kein zugänglicher Name – ohne diesen Text wäre die
|
||||
// Schaltfläche für Screenreader namenlos. Der Titel steht mit drin,
|
||||
// weil in der Liste mehrere gleich aussehende Schaltflächen stehen.
|
||||
<span className="sr-only">{`${label}: ${policy.title}`}</span>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
|
||||
|
||||
@ -12,8 +12,8 @@ import { ThemeToggle } from "@/components/theme-toggle"
|
||||
export function PublicHeader({ subtitle }: { subtitle?: string }) {
|
||||
return (
|
||||
<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">
|
||||
<Link href="/" className="rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50">
|
||||
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between gap-2 px-4 sm:gap-4 sm:px-6">
|
||||
<Link href="/" className="rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring">
|
||||
<Brand subtitle={subtitle} />
|
||||
<span className="sr-only">Zur Übersicht</span>
|
||||
</Link>
|
||||
|
||||
@ -18,7 +18,7 @@ export async function SiteFooter() {
|
||||
|
||||
return (
|
||||
<footer className="mt-12 border-t">
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-3 px-6 py-6 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-3 px-4 py-6 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between sm:px-6">
|
||||
<span>
|
||||
© {new Date().getFullYear()} {appName()}
|
||||
</span>
|
||||
|
||||
@ -102,6 +102,56 @@ Name, E-Mail, Telefon
|
||||
Wenn wir auf Ihre Meldung nicht zufriedenstellend reagieren, können Sie die
|
||||
Schlichtungsstelle nach § 16 BGG anrufen:
|
||||
Anschrift und Kontakt der zuständigen Schlichtungsstelle`,
|
||||
|
||||
easyLanguage: `## Willkommen
|
||||
|
||||
Das hier ist die Internet-Seite vom Amt Musterstadt.
|
||||
Hier steht: Was macht das Amt mit Ihren Daten?
|
||||
|
||||
## Was finden Sie auf dieser Seite?
|
||||
|
||||
Sie finden hier eine Liste.
|
||||
In der Liste stehen viele Themen.
|
||||
Zum Beispiel: Bau-Antrag. Oder: Wohn-Geld.
|
||||
Zu jedem Thema gibt es einen Text.
|
||||
In dem Text steht: Welche Daten braucht das Amt?
|
||||
Und: Wie lange speichert das Amt die Daten?
|
||||
|
||||
## Wie finden Sie ein Thema?
|
||||
|
||||
Oben auf der Seite ist ein Such-Feld.
|
||||
Dort können Sie ein Wort eintippen.
|
||||
Dann zeigt die Seite nur noch passende Themen.
|
||||
Sie können auch auf ein Thema in der Liste klicken.
|
||||
|
||||
## Barriere-Freiheit
|
||||
|
||||
Barriere-frei heißt: Alle Menschen können die Seite benutzen.
|
||||
Wir arbeiten daran, dass die Seite barriere-frei wird.
|
||||
Manche Sachen sind noch nicht barriere-frei.
|
||||
Sie können uns das melden.
|
||||
Schreiben Sie an: *[E-Mail-Adresse eintragen]*
|
||||
|
||||
*Dieser Text muss vor der Veröffentlichung von einer Prüf-Gruppe
|
||||
gegengelesen werden. Das ist bei Leichter Sprache üblich.*`,
|
||||
|
||||
signLanguage: `## Informationen in Deutscher Gebärdensprache
|
||||
|
||||
Hier entsteht ein Video in Deutscher Gebärdensprache.
|
||||
|
||||
Das Video erklärt:
|
||||
- die wesentlichen Inhalte dieser Website
|
||||
- wie die Navigation funktioniert
|
||||
- die wesentlichen Inhalte der Erklärung zur Barrierefreiheit
|
||||
|
||||
*[Video einbinden, sobald es vorliegt. Erforderlich nach § 4 BITV 2.0.
|
||||
Ein Video braucht eine Produktion mit gehörlosen Dolmetscherinnen oder
|
||||
Dolmetschern – Untertitel allein genügen nicht.]*
|
||||
|
||||
### Kontakt
|
||||
|
||||
Fragen zur Barrierefreiheit richten Sie bitte an:
|
||||
*[Kontaktdaten eintragen]*`,
|
||||
}
|
||||
|
||||
interface SitePagesFormProps {
|
||||
@ -160,9 +210,10 @@ export function SitePagesForm({ open, onOpenChange }: SitePagesFormProps) {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rechtliche Seiten</DialogTitle>
|
||||
<DialogDescription>
|
||||
Impressum, Datenschutzerklärung und Erklärung zur Barrierefreiheit
|
||||
des Portals selbst. Sie erscheinen in der Fußzeile der öffentlichen
|
||||
Seiten – ein leer gelassener Text nimmt die Seite vom Netz.
|
||||
Die eigenständigen Seiten des Portals: drei rechtliche Pflichtseiten
|
||||
sowie Leichte Sprache und Gebärdensprache nach § 4 BITV 2.0. Sie
|
||||
erscheinen in der Fußzeile der öffentlichen Seiten – ein leer
|
||||
gelassener Text nimmt die betreffende Seite vom Netz.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@ -172,12 +223,10 @@ export function SitePagesForm({ open, onOpenChange }: SitePagesFormProps) {
|
||||
onValueChange={(value) => setTab(value as SitePageKey)}
|
||||
className="min-h-0 flex-1 gap-3"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsList className="h-auto flex-wrap">
|
||||
{SITE_PAGES.map((key) => (
|
||||
<TabsTrigger key={key} value={key}>
|
||||
{key === "accessibility"
|
||||
? "Barrierefreiheit"
|
||||
: SITE_PAGE_META[key].title}
|
||||
{SITE_PAGE_META[key].short}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
17
components/skip-link.tsx
Normal file
17
components/skip-link.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Sprungmarke zum Hauptinhalt (WCAG 2.4.1, Ebene A).
|
||||
*
|
||||
* Unsichtbar, bis sie den Fokus bekommt – dann erscheint sie als erstes
|
||||
* bedienbares Element oben links. Wer mit der Tastatur navigiert, überspringt
|
||||
* damit Kopfzeile und Navigation, statt sie auf jeder Seite durchzutabben.
|
||||
*/
|
||||
export function SkipLink() {
|
||||
return (
|
||||
<a
|
||||
href="#inhalt"
|
||||
className="sr-only rounded-md bg-background px-4 py-2 text-sm font-medium text-foreground shadow-lg outline-none ring-2 ring-ring focus:not-sr-only focus:absolute focus:top-3 focus:left-3 focus:z-50"
|
||||
>
|
||||
Zum Inhalt springen
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@ -35,7 +35,7 @@ function AccordionTrigger({
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"flex flex-1 cursor-pointer items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
"flex flex-1 cursor-pointer items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-md border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-md border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@ -5,14 +5,14 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none disabled:pointer-events-none disabled:opacity-50 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none disabled:pointer-events-none disabled:opacity-50 focus-visible:border-ring focus-visible:ring-ring focus-visible:ring-[3px] aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
|
||||
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
|
||||
@ -11,7 +11,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
"flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none md:text-sm",
|
||||
"file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground",
|
||||
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"focus-visible:border-ring focus-visible:ring-ring focus-visible:ring-[3px]",
|
||||
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
|
||||
@ -18,7 +18,7 @@ function ScrollArea({
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
className="size-full rounded-[inherit] outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
|
||||
@ -32,7 +32,7 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
className={cn(
|
||||
"flex h-9 w-full cursor-pointer items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none dark:bg-input/30 dark:hover:bg-input/50",
|
||||
"data-[placeholder]:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"data-[placeholder]:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
|
||||
@ -14,7 +14,7 @@ function Switch({
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
"peer inline-flex h-[1.15rem] w-8 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-xs transition-all outline-none",
|
||||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
|
||||
className
|
||||
)}
|
||||
|
||||
@ -43,7 +43,7 @@ function TabsTrigger({
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"inline-flex h-[calc(100%-1px)] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none",
|
||||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
|
||||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
"data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
|
||||
@ -9,7 +9,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none md:text-sm",
|
||||
"placeholder:text-muted-foreground dark:bg-input/30",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"focus-visible:border-ring focus-visible:ring-ring focus-visible:ring-[3px]",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
|
||||
@ -1,32 +1,59 @@
|
||||
/** Die drei rechtlichen Seiten des Portals. */
|
||||
export const SITE_PAGES = ["imprint", "privacy", "accessibility"] as const
|
||||
/**
|
||||
* Die eigenständigen Seiten des Portals.
|
||||
*
|
||||
* Drei rechtliche Pflichtseiten sowie zwei Seiten, die § 4 BITV 2.0 von
|
||||
* öffentlichen Stellen verlangt: Erläuterungen in Leichter Sprache und in
|
||||
* Deutscher Gebärdensprache.
|
||||
*/
|
||||
export const SITE_PAGES = [
|
||||
"imprint",
|
||||
"privacy",
|
||||
"accessibility",
|
||||
"easyLanguage",
|
||||
"signLanguage",
|
||||
] as const
|
||||
|
||||
/** Bezeichner einer rechtlichen Seite. */
|
||||
/** Bezeichner einer Seite. */
|
||||
export type SitePageKey = (typeof SITE_PAGES)[number]
|
||||
|
||||
/** Überschrift und URL je Seite – für Fußzeile, Admin und die Seiten selbst. */
|
||||
export const SITE_PAGE_META: Record<
|
||||
SitePageKey,
|
||||
{ title: string; path: string; hint: string }
|
||||
{ title: string; short: string; path: string; hint: string }
|
||||
> = {
|
||||
imprint: {
|
||||
title: "Impressum",
|
||||
short: "Impressum",
|
||||
path: "/impressum",
|
||||
hint: "Anbieterkennzeichnung nach § 5 DDG",
|
||||
},
|
||||
privacy: {
|
||||
title: "Datenschutzerklärung",
|
||||
short: "Datenschutz",
|
||||
path: "/datenschutz",
|
||||
hint: "Datenverarbeitung durch dieses Portal selbst",
|
||||
},
|
||||
accessibility: {
|
||||
title: "Erklärung zur Barrierefreiheit",
|
||||
short: "Barrierefreiheit",
|
||||
path: "/barrierefreiheit",
|
||||
hint: "Pflichtangabe öffentlicher Stellen nach BITV 2.0",
|
||||
hint: "Pflichtangabe öffentlicher Stellen nach § 7 BITV 2.0, jährlich zu überprüfen",
|
||||
},
|
||||
easyLanguage: {
|
||||
title: "Leichte Sprache",
|
||||
short: "Leichte Sprache",
|
||||
path: "/leichte-sprache",
|
||||
hint: "Pflichtangabe nach § 4 BITV 2.0: wesentliche Inhalte, Navigation und Barrierefreiheitserklärung in Leichter Sprache",
|
||||
},
|
||||
signLanguage: {
|
||||
title: "Gebärdensprache",
|
||||
short: "Gebärdensprache",
|
||||
path: "/gebaerdensprache",
|
||||
hint: "Pflichtangabe nach § 4 BITV 2.0: dieselben Inhalte in Deutscher Gebärdensprache, üblicherweise als Video",
|
||||
},
|
||||
}
|
||||
|
||||
/** Inhalte der rechtlichen Seiten, wie sie API und Admin austauschen. */
|
||||
/** Inhalte der Seiten, wie sie API und Admin austauschen. */
|
||||
export type SitePagesContent = Record<SitePageKey, string>
|
||||
|
||||
/** Leere Ausgangsinhalte – solange im Admin nichts gepflegt wurde. */
|
||||
@ -34,4 +61,6 @@ export const EMPTY_SITE_PAGES: SitePagesContent = {
|
||||
imprint: "",
|
||||
privacy: "",
|
||||
accessibility: "",
|
||||
easyLanguage: "",
|
||||
signLanguage: "",
|
||||
}
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
-- Zwei weitere Seiten, die § 4 BITV 2.0 für öffentliche Stellen verlangt:
|
||||
-- Erläuterungen in Leichter Sprache und in Deutscher Gebärdensprache zu den
|
||||
-- wesentlichen Inhalten, zur Navigation und zur Erklärung zur Barrierefreiheit.
|
||||
--
|
||||
-- Wie die übrigen Seiten: leerer Text bedeutet, die Seite wird nicht
|
||||
-- ausgeliefert und nicht in der Fußzeile verlinkt.
|
||||
ALTER TABLE "SitePages" ADD COLUMN "easyLanguage" TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE "SitePages" ADD COLUMN "signLanguage" TEXT NOT NULL DEFAULT '';
|
||||
@ -40,7 +40,9 @@ model SitePages {
|
||||
id Int @id @default(1) // Singleton – immer 1
|
||||
imprint String @default("") // Impressum (§ 5 DDG)
|
||||
privacy String @default("") // Datenschutzerklärung für dieses Portal
|
||||
accessibility String @default("") // Erklärung zur Barrierefreiheit (BITV 2.0)
|
||||
accessibility String @default("") // Erklärung zur Barrierefreiheit (§ 7 BITV 2.0)
|
||||
easyLanguage String @default("") // Erläuterungen in Leichter Sprache (§ 4 BITV 2.0)
|
||||
signLanguage String @default("") // Erläuterungen in Deutscher Gebärdensprache (§ 4 BITV 2.0)
|
||||
updatedAt DateTime @updatedAt // Automatisch bei Update gesetzt
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user