feat: Oberflaechenbausteine, Anwendungsrahmen und Konventionen

Basisbausteine im shadcn/ui-Stil, aber mit drei bewussten Abweichungen:

- Bedienelemente sind 44px hoch statt 36px. Ich bediene die App oft am
  Handy im Heim oder auf dem Amt; kleiner ist dort nicht treffsicher.
- Auswahlfelder benutzen das native <select>. Android und iOS oeffnen
  dafuer ihre eigene Liste - schneller als jeder Nachbau, und mit
  Screenreader und Tastatur funktioniert es ohne Zutun.
- Eingabefelder haben mindestens 16px Schrift, sonst zoomt iOS-Safari
  beim Hineintippen das Formular heran.

<Feld> verdrahtet Beschriftung, Erklaerung und Fehler ueber
aria-describedby. Der Erklaerungstext ist kein Beiwerk: die Anwendung
benutzt Begriffe aus dem Betreuungsrecht, die niemand nachschlagen soll.

<Rechtshinweis> ist der stehende Vermerk, dass hier keine Rechtsberatung
stattfindet. Er steht an jeder Stelle mit Paragraphenbezug.

Der Anwendungsrahmen fuehrt drei Bedingungen zusammen, ohne die niemand
an Fachdaten kommt: angemeldet, zweiter Faktor eingerichtet, mindestens
eine betreute Person zugeordnet. Die Navigation zeigt Zaehler fuer
ueberfaellige Fristen und neuen Posteingang - die Frage "wo muss ich
hin?" wird schon im Menue beantwortet.

Das Farbschema wird per Inline-Skript mit CSP-Nonce gesetzt, bevor die
Seite gezeichnet wird - sonst blitzt beim Laden das helle Thema auf.

docs/KONVENTIONEN.md haelt fest, was in allen weiteren Etappen gilt:
Verzeichnisaufbau, Cent-Betraege, Umgang mit verschluesselten Feldern,
Zugriffspruefung, Audit-Pflicht, Ampelfarben, Commit-Trailer.

Etappe: 1
Phase: Fundament
This commit is contained in:
Florian Wilde
2026-08-22 01:54:56 +02:00
parent bd1c7b801f
commit dcd3a8ce71
30 changed files with 2151 additions and 12 deletions
+128
View File
@@ -0,0 +1,128 @@
'use client'
import * as React from 'react'
import Link from 'next/link'
import { signOut } from 'next-auth/react'
import { LogOut, Monitor, Moon, Settings, ShieldCheck, Sun, User as UserIcon } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { initialen } from '@/lib/utils'
type Thema = 'hell' | 'dunkel' | 'system'
/**
* Benutzermenue mit Themenwahl und Abmelden.
*
* Das Thema wird im localStorage gemerkt und sofort auf <html> gesetzt -
* dieselbe Logik wie im Startskript im Layout, damit es beim naechsten
* Laden nicht flackert.
*/
export function BenutzerMenue({
name,
email,
rolle,
}: {
name: string
email: string
rolle: string
}) {
const [thema, setThema] = React.useState<Thema>('system')
React.useEffect(() => {
const gespeichert = (localStorage.getItem('bc-thema') as Thema | null) ?? 'system'
setThema(gespeichert)
}, [])
function themaSetzen(neu: Thema) {
setThema(neu)
try {
localStorage.setItem('bc-thema', neu)
} catch {
/* Speicher gesperrt - dann gilt die Wahl nur fuer diese Sitzung. */
}
const dunkel =
neu === 'dunkel' ||
(neu === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
document.documentElement.classList.toggle('dark', dunkel)
document.documentElement.style.colorScheme = dunkel ? 'dark' : 'light'
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="w-full justify-start gap-3 px-2">
<span
aria-hidden="true"
className="grid size-8 shrink-0 place-items-center rounded-full bg-secondary text-xs font-semibold text-secondary-foreground"
>
{initialen(name)}
</span>
<span className="min-w-0 flex-1 text-left lg:block">
<span className="block truncate text-sm font-medium">{name}</span>
<span className="block truncate text-xs text-muted-foreground">{rolle}</span>
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel>
<div className="truncate font-medium">{name}</div>
<div className="truncate text-xs font-normal text-muted-foreground">{email}</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/konto">
<UserIcon className="size-4" />
Mein Konto
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/konto/zwei-faktor">
<ShieldCheck className="size-4" />
Zweiter Faktor
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/einstellungen">
<Settings className="size-4" />
Einstellungen
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
Darstellung
</DropdownMenuLabel>
<DropdownMenuItem onSelect={() => themaSetzen('hell')}>
<Sun className="size-4" />
Hell
{thema === 'hell' && <span className="ml-auto text-xs">✓</span>}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => themaSetzen('dunkel')}>
<Moon className="size-4" />
Dunkel
{thema === 'dunkel' && <span className="ml-auto text-xs">✓</span>}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => themaSetzen('system')}>
<Monitor className="size-4" />
Wie das System
{thema === 'system' && <span className="ml-auto text-xs">✓</span>}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => void signOut({ callbackUrl: '/anmelden' })}>
<LogOut className="size-4" />
Abmelden
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}