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 ( +
+
+ + Planer + + + + +
+ + + {name} + + +
+
+
+ ) +} + +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') +}