feat: Phase 1 – Plattform-Grundgerüst (Docker, NestJS, React, Auth)
This commit is contained in:
118
apps/platform-frontend/src/components/layout/app-layout.tsx
Normal file
118
apps/platform-frontend/src/components/layout/app-layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
apps/platform-frontend/src/components/route-guards.tsx
Normal file
35
apps/platform-frontend/src/components/route-guards.tsx
Normal 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;
|
||||
}
|
||||
29
apps/platform-frontend/src/components/ui/badge.tsx
Normal file
29
apps/platform-frontend/src/components/ui/badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
apps/platform-frontend/src/components/ui/button.tsx
Normal file
58
apps/platform-frontend/src/components/ui/button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
apps/platform-frontend/src/components/ui/card.tsx
Normal file
46
apps/platform-frontend/src/components/ui/card.tsx
Normal 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>;
|
||||
}
|
||||
51
apps/platform-frontend/src/components/ui/input.tsx
Normal file
51
apps/platform-frontend/src/components/ui/input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
apps/platform-frontend/src/components/ui/states.tsx
Normal file
57
apps/platform-frontend/src/components/ui/states.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user