feat: Phase 1 – Plattform-Grundgerüst (Docker, NestJS, React, Auth)

This commit is contained in:
MPM Dev
2026-10-06 14:22:11 +02:00
commit a7e1c421f2
85 changed files with 17701 additions and 0 deletions

View File

@@ -0,0 +1,118 @@
import { type ReactNode, useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import { useAuth } from '../../features/auth/auth-context';
import { Badge } from '../ui/badge';
/** Ein Navigationspunkt der Sidebar. */
interface NavItem {
to: string;
label: string;
icon: string;
adminOnly?: boolean;
}
const NAV_ITEMS: NavItem[] = [
{ to: '/', label: 'Dashboard', icon: '⌂' },
{ to: '/admin/users', label: 'Benutzer', icon: '👥', adminOnly: true },
{ to: '/admin/system', label: 'Systemstatus', icon: '⚙', adminOnly: true },
];
/** Responsive App-Shell: Sidebar (Desktop) / Overlay-Menü (Mobil). */
export function AppLayout(): ReactNode {
const { user, logout } = useAuth();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const visibleItems = NAV_ITEMS.filter(
(item) => !item.adminOnly || user?.role === 'ADMIN',
);
return (
<div className="flex h-full">
{/* Mobile: Overlay-Hintergrund */}
{mobileMenuOpen && (
<div
className="fixed inset-0 z-30 bg-slate-900/50 lg:hidden"
onClick={() => setMobileMenuOpen(false)}
aria-hidden="true"
/>
)}
{/* Sidebar */}
<aside
className={`fixed inset-y-0 left-0 z-40 flex w-64 flex-col border-r border-slate-200 bg-white transition-transform lg:static lg:translate-x-0
${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full'}`}
aria-label="Hauptnavigation"
>
<div className="flex h-16 items-center gap-2 border-b border-slate-200 px-6">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-600 text-sm font-bold text-white">
M
</span>
<span className="text-base font-semibold text-slate-900">MPM</span>
</div>
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-4">
{visibleItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
onClick={() => setMobileMenuOpen(false)}
className={({ isActive }) =>
`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors
${
isActive
? 'bg-brand-50 text-brand-700'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
}`
}
>
<span aria-hidden="true">{item.icon}</span>
{item.label}
</NavLink>
))}
</nav>
<div className="border-t border-slate-200 p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-slate-900">
{user?.displayName}
</p>
<p className="truncate text-xs text-slate-500">@{user?.username}</p>
</div>
{user?.role === 'ADMIN' && <Badge variant="info">Admin</Badge>}
</div>
<button
onClick={() => void logout()}
className="mt-3 w-full rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900"
>
Abmelden
</button>
</div>
</aside>
{/* Hauptbereich */}
<div className="flex min-w-0 flex-1 flex-col">
{/* Topbar (mobil: Menü-Button) */}
<header className="flex h-16 items-center justify-between border-b border-slate-200 bg-white px-4 lg:px-6">
<button
className="rounded-lg p-2 text-slate-600 hover:bg-slate-100 lg:hidden"
onClick={() => setMobileMenuOpen((open) => !open)}
aria-label="Menü öffnen"
aria-expanded={mobileMenuOpen}
>
<span aria-hidden="true" className="text-xl">☰</span>
</button>
<span className="hidden text-sm text-slate-500 lg:block">
Management-Plattform
</span>
<span className="text-sm text-slate-500 lg:hidden">MPM</span>
</header>
<main className="flex-1 overflow-y-auto p-4 lg:p-8">
<Outlet />
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { type ReactNode } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../features/auth/auth-context';
import { Spinner } from './ui/states';
/** Schützt Routen: nur für authentifizierte Benutzer. */
export function RequireAuth({ children }: { children: ReactNode }): ReactNode {
const { status } = useAuth();
const location = useLocation();
if (status === 'loading') {
return <Spinner label="Sitzung wird geprüft…" className="min-h-[50vh]" />;
}
if (status === 'unauthenticated') {
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
}
return children;
}
/** Schützt Admin-Routen: nur für Benutzer mit Rolle ADMIN. */
export function RequireAdmin({ children }: { children: ReactNode }): ReactNode {
const { user, status } = useAuth();
if (status === 'loading') {
return <Spinner label="Sitzung wird geprüft…" className="min-h-[50vh]" />;
}
if (!user || user.role !== 'ADMIN') {
return <Navigate to="/" replace />;
}
return children;
}

View File

@@ -0,0 +1,29 @@
import { type ReactNode } from 'react';
/** Farbschemata für Badges (Statusanzeigen). */
export type BadgeVariant = 'success' | 'warning' | 'danger' | 'neutral' | 'info';
const VARIANT_CLASSES: Record<BadgeVariant, string> = {
success: 'bg-emerald-50 text-emerald-700 ring-emerald-600/20',
warning: 'bg-amber-50 text-amber-700 ring-amber-600/20',
danger: 'bg-red-50 text-red-700 ring-red-600/20',
neutral: 'bg-slate-100 text-slate-600 ring-slate-500/20',
info: 'bg-brand-50 text-brand-700 ring-brand-600/20',
};
export interface BadgeProps {
variant?: BadgeVariant;
children: ReactNode;
}
/** Kleines Status-Label (Design-System). */
export function Badge({ variant = 'neutral', children }: BadgeProps): ReactNode {
return (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset
${VARIANT_CLASSES[variant]}`}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,58 @@
import { type ButtonHTMLAttributes, type ReactNode } from 'react';
/** Varianten des Buttons (Design-System). */
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
export type ButtonSize = 'sm' | 'md' | 'lg';
const VARIANT_CLASSES: Record<ButtonVariant, string> = {
primary:
'bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800 disabled:bg-slate-300',
secondary:
'bg-white text-slate-700 border border-slate-300 hover:bg-slate-50 active:bg-slate-100 disabled:text-slate-400',
danger:
'bg-red-600 text-white hover:bg-red-700 active:bg-red-800 disabled:bg-slate-300',
ghost:
'bg-transparent text-slate-600 hover:bg-slate-100 active:bg-slate-200 disabled:text-slate-400',
};
const SIZE_CLASSES: Record<ButtonSize, string> = {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-11 px-5 text-base',
};
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
children: ReactNode;
}
/** Standard-Button des Design-Systems. */
export function Button({
variant = 'primary',
size = 'md',
loading = false,
className = '',
disabled,
children,
...rest
}: ButtonProps): ReactNode {
return (
<button
className={`inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-colors
focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed
${VARIANT_CLASSES[variant]} ${SIZE_CLASSES[size]} ${className}`}
disabled={disabled || loading}
{...rest}
>
{loading && (
<span
aria-hidden="true"
className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
/>
)}
{children}
</button>
);
}

View File

@@ -0,0 +1,46 @@
import { type ReactNode } from 'react';
export interface CardProps {
children: ReactNode;
className?: string;
}
/** Karten-Container des Design-Systems. */
export function Card({ children, className = '' }: CardProps): ReactNode {
return (
<div
className={`rounded-xl border border-slate-200 bg-white shadow-sm ${className}`}
>
{children}
</div>
);
}
export interface CardHeaderProps {
title: string;
description?: string;
children?: ReactNode;
}
/** Karten-Kopf mit Titel, Beschreibung und optionalen Aktionen. */
export function CardHeader({ title, description, children }: CardHeaderProps): ReactNode {
return (
<div className="flex items-start justify-between gap-4 border-b border-slate-200 px-6 py-4">
<div>
<h2 className="text-base font-semibold text-slate-900">{title}</h2>
{description && <p className="mt-0.5 text-sm text-slate-500">{description}</p>}
</div>
{children}
</div>
);
}
export interface CardBodyProps {
children: ReactNode;
className?: string;
}
/** Karten-Inhalt. */
export function CardBody({ children, className = '' }: CardBodyProps): ReactNode {
return <div className={`px-6 py-4 ${className}`}>{children}</div>;
}

View File

@@ -0,0 +1,51 @@
import { type InputHTMLAttributes, type ReactNode } from 'react';
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
hint?: string;
}
/** Text-Input mit Label, Fehler- und Hinweistext (Design-System). */
export function Input({
label,
error,
hint,
className = '',
id,
...rest
}: InputProps): ReactNode {
const inputId = id ?? `input-${label.toLowerCase().replace(/\s+/g, '-')}`;
const describedBy = error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined;
return (
<div className="flex flex-col gap-1.5">
<label htmlFor={inputId} className="text-sm font-medium text-slate-700">
{label}
</label>
<input
id={inputId}
className={`h-10 rounded-lg border px-3 text-sm transition-colors
${
error
? 'border-red-400 focus:border-red-500'
: 'border-slate-300 focus:border-brand-500'
}
${className}`}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
{...rest}
/>
{hint && !error && (
<p id={`${inputId}-hint`} className="text-xs text-slate-500">
{hint}
</p>
)}
{error && (
<p id={`${inputId}-error`} role="alert" className="text-xs text-red-600">
{error}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { type ReactNode } from 'react';
export interface SpinnerProps {
label?: string;
className?: string;
}
/** Ladeindikator (Design-System). */
export function Spinner({ label = 'Wird geladen…', className = '' }: SpinnerProps): ReactNode {
return (
<div className={`flex flex-col items-center justify-center gap-3 py-12 ${className}`} role="status">
<span
aria-hidden="true"
className="h-8 w-8 animate-spin rounded-full border-4 border-brand-200 border-t-brand-600"
/>
<span className="text-sm text-slate-500">{label}</span>
</div>
);
}
export interface EmptyStateProps {
title: string;
description?: string;
icon?: ReactNode;
}
/** Anzeige für leere Zustände (Design-System). */
export function EmptyState({ title, description, icon }: EmptyStateProps): ReactNode {
return (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
{icon && <div className="text-slate-300">{icon}</div>}
<h3 className="text-sm font-semibold text-slate-900">{title}</h3>
{description && <p className="max-w-sm text-sm text-slate-500">{description}</p>}
</div>
);
}
export interface ErrorStateProps {
title?: string;
message?: string;
}
/** Anzeige für Fehlerzustände (Design-System). */
export function ErrorState({
title = 'Ein Fehler ist aufgetreten',
message = 'Bitte versuchen Sie es später erneut.',
}: ErrorStateProps): ReactNode {
return (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center" role="alert">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-50 text-red-600">
!
</div>
<h3 className="text-sm font-semibold text-slate-900">{title}</h3>
<p className="max-w-sm text-sm text-slate-500">{message}</p>
</div>
);
}