diff --git a/src/app/globals.css b/src/app/globals.css
new file mode 100644
index 0000000..24f0a63
--- /dev/null
+++ b/src/app/globals.css
@@ -0,0 +1,90 @@
+@import 'tailwindcss';
+@import 'tw-animate-css';
+
+@custom-variant dark (&:is(.dark *));
+
+/*
+ * Farben als OKLCH, damit Hell und Dunkel dieselbe wahrgenommene Helligkeit
+ * halten. Die Bereichsfarben kommen aus der Datenbank und werden zur Laufzeit
+ * gesetzt, nicht hier.
+ */
+:root {
+ --radius: 0.625rem;
+
+ --hintergrund: oklch(0.99 0.002 250);
+ --vordergrund: oklch(0.21 0.02 260);
+ --karte: oklch(1 0 0);
+ --karte-vordergrund: oklch(0.21 0.02 260);
+ --gedaempft: oklch(0.96 0.004 250);
+ --gedaempft-vordergrund: oklch(0.5 0.015 260);
+ --rand: oklch(0.91 0.006 255);
+ --eingabe: oklch(0.91 0.006 255);
+ --ring: oklch(0.55 0.16 258);
+
+ --betont: oklch(0.52 0.17 258);
+ --betont-vordergrund: oklch(0.99 0.002 250);
+ --gefahr: oklch(0.55 0.21 27);
+ --gefahr-vordergrund: oklch(0.99 0.002 250);
+ --erfolg: oklch(0.6 0.13 155);
+ --warnung: oklch(0.7 0.15 75);
+}
+
+.dark {
+ --hintergrund: oklch(0.17 0.015 262);
+ --vordergrund: oklch(0.95 0.005 250);
+ --karte: oklch(0.21 0.018 262);
+ --karte-vordergrund: oklch(0.95 0.005 250);
+ --gedaempft: oklch(0.26 0.018 262);
+ --gedaempft-vordergrund: oklch(0.7 0.015 258);
+ --rand: oklch(0.31 0.018 262);
+ --eingabe: oklch(0.31 0.018 262);
+ --ring: oklch(0.62 0.15 258);
+
+ --betont: oklch(0.62 0.16 258);
+ --betont-vordergrund: oklch(0.17 0.015 262);
+ --gefahr: oklch(0.62 0.19 25);
+ --gefahr-vordergrund: oklch(0.98 0.002 250);
+ --erfolg: oklch(0.68 0.12 155);
+ --warnung: oklch(0.78 0.13 75);
+}
+
+@theme inline {
+ --color-hintergrund: var(--hintergrund);
+ --color-vordergrund: var(--vordergrund);
+ --color-karte: var(--karte);
+ --color-karte-vordergrund: var(--karte-vordergrund);
+ --color-gedaempft: var(--gedaempft);
+ --color-gedaempft-vordergrund: var(--gedaempft-vordergrund);
+ --color-rand: var(--rand);
+ --color-eingabe: var(--eingabe);
+ --color-ring: var(--ring);
+ --color-betont: var(--betont);
+ --color-betont-vordergrund: var(--betont-vordergrund);
+ --color-gefahr: var(--gefahr);
+ --color-gefahr-vordergrund: var(--gefahr-vordergrund);
+ --color-erfolg: var(--erfolg);
+ --color-warnung: var(--warnung);
+
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+}
+
+@layer base {
+ * {
+ @apply border-rand outline-ring/50;
+ }
+
+ body {
+ @apply bg-hintergrund text-vordergrund;
+ font-feature-settings:
+ 'liga' 1,
+ 'calt' 1;
+ }
+
+ /* Wer mit der Tastatur bedient, muss sehen, wo er ist. */
+ :focus-visible {
+ @apply outline-ring outline-2 outline-offset-2;
+ }
+}
diff --git a/src/app/keine-berechtigung/page.tsx b/src/app/keine-berechtigung/page.tsx
new file mode 100644
index 0000000..bba52ca
--- /dev/null
+++ b/src/app/keine-berechtigung/page.tsx
@@ -0,0 +1,22 @@
+import type { Metadata } from 'next'
+import Link from 'next/link'
+import { ShieldAlert } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+
+export const metadata: Metadata = { title: 'Keine Berechtigung' }
+
+export default function KeineBerechtigungSeite() {
+ return (
+
+
+ Dazu fehlt dir die Berechtigung
+
+ Diese Seite ist der Verwaltung vorbehalten. Wenn du meinst, dass das ein Versehen
+ ist, wende dich an die Verwaltung dieser Instanz.
+
+
+
+ )
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
new file mode 100644
index 0000000..29b3fac
--- /dev/null
+++ b/src/app/layout.tsx
@@ -0,0 +1,36 @@
+import type { Metadata, Viewport } from 'next'
+import { Toaster } from 'sonner'
+import { FarbschemaAnbieter } from '@/components/farbschema-anbieter'
+import './globals.css'
+
+export const metadata: Metadata = {
+ title: {
+ default: 'Planer',
+ template: '%s – Planer',
+ },
+ description: 'Aufgaben, Ideen, Projekte und Zeitblöcke an einem Ort.',
+ applicationName: 'Planer',
+ robots: { index: false, follow: false },
+}
+
+export const viewport: Viewport = {
+ width: 'device-width',
+ initialScale: 1,
+ themeColor: [
+ { media: '(prefers-color-scheme: light)', color: '#fbfbfd' },
+ { media: '(prefers-color-scheme: dark)', color: '#1a1c22' },
+ ],
+}
+
+export default function WurzelLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+ {children}
+
+
+
+
+ )
+}
diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx
new file mode 100644
index 0000000..0641832
--- /dev/null
+++ b/src/app/not-found.tsx
@@ -0,0 +1,20 @@
+import type { Metadata } from 'next'
+import Link from 'next/link'
+import { Button } from '@/components/ui/button'
+
+export const metadata: Metadata = { title: 'Nicht gefunden' }
+
+export default function NichtGefunden() {
+ return (
+
+ Fehler 404
+ Diese Seite gibt es nicht
+
+ Möglicherweise ist sie in dieser Instanz auch gar nicht vorgesehen.
+
+
+
+ )
+}
diff --git a/src/components/farbschema-anbieter.tsx b/src/components/farbschema-anbieter.tsx
new file mode 100644
index 0000000..afdbc20
--- /dev/null
+++ b/src/components/farbschema-anbieter.tsx
@@ -0,0 +1,20 @@
+'use client'
+
+import { ThemeProvider } from 'next-themes'
+
+/**
+ * Hell, Dunkel oder nach Systemeinstellung. Die Wahl liegt im Browser des
+ * Benutzers, nicht in der Datenbank.
+ */
+export function FarbschemaAnbieter({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/components/kopfleiste.tsx b/src/components/kopfleiste.tsx
new file mode 100644
index 0000000..c863e1a
--- /dev/null
+++ b/src/components/kopfleiste.tsx
@@ -0,0 +1,109 @@
+'use client'
+
+import Link from 'next/link'
+import { usePathname } from 'next/navigation'
+import { useTheme } from 'next-themes'
+import { LogOut, Moon, Shield, Sun } from 'lucide-react'
+import { signOut } from 'next-auth/react'
+import type { Rolle } from '@/generated/prisma/enums'
+import { ROLLEN_NAME, istAdmin } from '@/lib/rollen'
+import { cn } from '@/lib/utils'
+import { Button } from '@/components/ui/button'
+
+type Eintrag = { pfad: string; name: string; nurAdmin?: boolean }
+
+const EINTRAEGE: Eintrag[] = [
+ { pfad: '/', name: 'Start' },
+ { pfad: '/bereiche', name: 'Bereiche' },
+ { pfad: '/einstellungen', name: 'Einstellungen' },
+ { pfad: '/verwaltung/protokoll', name: 'Protokoll', nurAdmin: true },
+]
+
+export function Kopfleiste({
+ name,
+ email,
+ rolle,
+ instanzName,
+}: {
+ name: string
+ email: string
+ rolle: Rolle
+ instanzName: string
+}) {
+ const pfad = usePathname()
+ const sichtbar = EINTRAEGE.filter((e) => !e.nurAdmin || istAdmin(rolle))
+
+ return (
+
+ )
+}
+
+function FarbschemaKnopf() {
+ const { resolvedTheme, setTheme } = useTheme()
+
+ // Welches Symbol gilt, entscheidet die dark-Klasse und damit CSS. next-themes
+ // setzt sie schon vor der Hydratation, deshalb braucht es hier weder einen
+ // Effekt noch einen Zustand – und es blitzt nichts auf.
+ return (
+
+ )
+}
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
new file mode 100644
index 0000000..557e2c3
--- /dev/null
+++ b/src/components/ui/button.tsx
@@ -0,0 +1,47 @@
+import { Slot } from '@radix-ui/react-slot'
+import { cva, type VariantProps } from 'class-variance-authority'
+import { cn } from '@/lib/utils'
+
+const knopfStile = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0",
+ {
+ variants: {
+ art: {
+ betont: 'bg-betont text-betont-vordergrund hover:bg-betont/90',
+ umriss: 'border border-rand bg-karte hover:bg-gedaempft',
+ leise: 'hover:bg-gedaempft',
+ gefahr: 'bg-gefahr text-gefahr-vordergrund hover:bg-gefahr/90',
+ verweis: 'text-betont underline-offset-4 hover:underline',
+ },
+ groesse: {
+ normal: 'h-9 px-4 py-2',
+ klein: 'h-8 rounded-md px-3 text-xs',
+ gross: 'h-11 rounded-md px-6',
+ quadrat: 'size-9',
+ },
+ },
+ defaultVariants: {
+ art: 'betont',
+ groesse: 'normal',
+ },
+ },
+)
+
+export type KnopfEigenschaften = React.ComponentProps<'button'> &
+ VariantProps & {
+ /** Rendert stattdessen das Kindelement, z. B. einen Link. */
+ alsKind?: boolean
+ }
+
+function Button({
+ className,
+ art,
+ groesse,
+ alsKind = false,
+ ...rest
+}: KnopfEigenschaften) {
+ const Element = alsKind ? Slot : 'button'
+ return
+}
+
+export { Button, knopfStile }
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
new file mode 100644
index 0000000..eb75234
--- /dev/null
+++ b/src/components/ui/card.tsx
@@ -0,0 +1,40 @@
+import { cn } from '@/lib/utils'
+
+function Card({ className, ...rest }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function CardKopf({ className, ...rest }: React.ComponentProps<'div'>) {
+ return
+}
+
+function CardTitel({ className, ...rest }: React.ComponentProps<'h2'>) {
+ return (
+
+ )
+}
+
+function CardBeschreibung({ className, ...rest }: React.ComponentProps<'p'>) {
+ return
+}
+
+function CardInhalt({ className, ...rest }: React.ComponentProps<'div'>) {
+ return
+}
+
+function CardFuss({ className, ...rest }: React.ComponentProps<'div'>) {
+ return
+}
+
+export { Card, CardKopf, CardTitel, CardBeschreibung, CardInhalt, CardFuss }
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
new file mode 100644
index 0000000..450e8bc
--- /dev/null
+++ b/src/components/ui/input.tsx
@@ -0,0 +1,20 @@
+import { cn } from '@/lib/utils'
+
+function Input({ className, type, ...rest }: React.ComponentProps<'input'>) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx
new file mode 100644
index 0000000..b8a8beb
--- /dev/null
+++ b/src/components/ui/label.tsx
@@ -0,0 +1,19 @@
+'use client'
+
+import * as LabelPrimitive from '@radix-ui/react-label'
+import { cn } from '@/lib/utils'
+
+function Label({ className, ...rest }: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx
new file mode 100644
index 0000000..6110590
--- /dev/null
+++ b/src/components/ui/separator.tsx
@@ -0,0 +1,26 @@
+'use client'
+
+import * as SeparatorPrimitive from '@radix-ui/react-separator'
+import { cn } from '@/lib/utils'
+
+function Separator({
+ className,
+ orientation = 'horizontal',
+ decorative = true,
+ ...rest
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/src/lib/rollen.test.ts b/src/lib/rollen.test.ts
new file mode 100644
index 0000000..07c6ae5
--- /dev/null
+++ b/src/lib/rollen.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest'
+import { ROLLEN_NAME, istAdmin, rolleReichtAus } from './rollen'
+
+describe('rolleReichtAus', () => {
+ it('laesst jede Rolle das, was sie selbst ist', () => {
+ expect(rolleReichtAus('BENUTZER', 'BENUTZER')).toBe(true)
+ expect(rolleReichtAus('ADMIN', 'ADMIN')).toBe(true)
+ })
+
+ it('laesst die Verwaltung auch alles Niedrigere', () => {
+ expect(rolleReichtAus('ADMIN', 'BENUTZER')).toBe(true)
+ })
+
+ it('laesst einen Benutzer nicht in die Verwaltung', () => {
+ expect(rolleReichtAus('BENUTZER', 'ADMIN')).toBe(false)
+ })
+})
+
+describe('istAdmin', () => {
+ it('erkennt nur die Verwaltung', () => {
+ expect(istAdmin('ADMIN')).toBe(true)
+ expect(istAdmin('BENUTZER')).toBe(false)
+ })
+})
+
+describe('ROLLEN_NAME', () => {
+ it('benennt jede Rolle deutsch', () => {
+ expect(ROLLEN_NAME.BENUTZER).toBe('Benutzer')
+ expect(ROLLEN_NAME.ADMIN).toBe('Verwaltung')
+ })
+})
diff --git a/src/lib/rollen.ts b/src/lib/rollen.ts
new file mode 100644
index 0000000..3cfa889
--- /dev/null
+++ b/src/lib/rollen.ts
@@ -0,0 +1,25 @@
+import type { Rolle } from '@/generated/prisma/enums'
+
+/**
+ * Rangfolge der Rollen. Reine Logik, damit Seite, Server Action und
+ * Oberflaeche dieselbe Antwort geben.
+ */
+
+const RANG: Record = {
+ BENUTZER: 1,
+ ADMIN: 2,
+}
+
+export const ROLLEN_NAME: Record = {
+ BENUTZER: 'Benutzer',
+ ADMIN: 'Verwaltung',
+}
+
+/** Reicht die vorhandene Rolle fuer das Verlangte? */
+export function rolleReichtAus(vorhanden: Rolle, verlangt: Rolle): boolean {
+ return RANG[vorhanden] >= RANG[verlangt]
+}
+
+export function istAdmin(rolle: Rolle): boolean {
+ return rolleReichtAus(rolle, 'ADMIN')
+}