feat: marketplace modules and isolated container management
This commit is contained in:
@@ -1,30 +1,40 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '../../features/auth/auth-context';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { ProfilePage } from '../../features/profile/profile-page';
|
||||
import { Icon, type IconName } from '../ui/icon';
|
||||
|
||||
/** Ein Navigationspunkt der Sidebar. */
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
icon: IconName;
|
||||
adminOnly?: boolean;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/', label: 'Dashboard', icon: '⌂' },
|
||||
{ to: '/profile', label: 'Mein Profil', icon: '👤' },
|
||||
{ to: '/admin/users', label: 'Benutzer', icon: '👥', adminOnly: true },
|
||||
{ to: '/admin/modules', label: 'Module', icon: '🧩', adminOnly: true },
|
||||
{ to: '/admin/system', label: 'Systemstatus', icon: '⚙', adminOnly: true },
|
||||
{ to: '/admin/audit', label: 'Audit-Log', icon: '📋', adminOnly: true },
|
||||
{ to: '/admin/settings', label: 'Einstellungen', icon: '🔧', adminOnly: true },
|
||||
{ to: '/', label: 'Dashboard', icon: 'dashboard' },
|
||||
{ to: '/admin/users', label: 'Benutzer', icon: 'users', adminOnly: true },
|
||||
{ to: '/admin/modules', label: 'Module', icon: 'modules', adminOnly: true },
|
||||
{ to: '/admin/system', label: 'Systemstatus', icon: 'system', adminOnly: true },
|
||||
{ to: '/admin/audit', label: 'Audit-Log', icon: 'audit', adminOnly: true },
|
||||
];
|
||||
|
||||
function initialDarkMode(): boolean {
|
||||
return document.documentElement.dataset.theme === 'dark';
|
||||
}
|
||||
|
||||
/** Responsive App-Shell: Sidebar (Desktop) / Overlay-Menü (Mobil). */
|
||||
export function AppLayout(): ReactNode {
|
||||
const { user, logout } = useAuth();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
const [darkMode, setDarkMode] = useState(initialDarkMode);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = darkMode ? 'dark' : 'light';
|
||||
localStorage.setItem('mpm-theme', darkMode ? 'dark' : 'light');
|
||||
}, [darkMode]);
|
||||
|
||||
const visibleItems = NAV_ITEMS.filter(
|
||||
(item) => !item.adminOnly || user?.role === 'ADMIN',
|
||||
@@ -70,22 +80,28 @@ export function AppLayout(): ReactNode {
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span aria-hidden="true">{item.icon}</span>
|
||||
<Icon name={item.icon} className="h-[18px] w-[18px]" />
|
||||
{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
|
||||
type="button"
|
||||
onClick={() => setProfileOpen(true)}
|
||||
className="group flex w-full min-w-0 items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-3 text-left transition-colors hover:border-brand-300 hover:bg-brand-50"
|
||||
aria-haspopup="dialog"
|
||||
aria-label={`Profileinstellungen für ${user?.displayName ?? 'Benutzer'} öffnen`}
|
||||
>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600">
|
||||
<Icon name="user" className="h-[18px] w-[18px]" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-slate-900 group-hover:text-brand-700">
|
||||
{user?.displayName}
|
||||
</span>
|
||||
<Icon name="arrow" className="h-4 w-4 shrink-0 text-slate-400 transition-transform group-hover:translate-x-0.5" />
|
||||
</button>
|
||||
<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"
|
||||
@@ -105,18 +121,31 @@ export function AppLayout(): ReactNode {
|
||||
aria-label="Menü öffnen"
|
||||
aria-expanded={mobileMenuOpen}
|
||||
>
|
||||
<span aria-hidden="true" className="text-xl">☰</span>
|
||||
<Icon name="menu" className="h-5 w-5" />
|
||||
</button>
|
||||
<span className="hidden text-sm text-slate-500 lg:block">
|
||||
Management-Plattform
|
||||
</span>
|
||||
<span className="hidden text-sm text-slate-500 lg:block">Management-Plattform</span>
|
||||
<span className="text-sm text-slate-500 lg:hidden">MPM</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={darkMode}
|
||||
aria-label="Darkmode"
|
||||
onClick={() => setDarkMode((current) => !current)}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg border border-slate-200 px-3 text-sm text-slate-600 transition-colors hover:bg-slate-100"
|
||||
>
|
||||
<Icon name={darkMode ? 'sun' : 'moon'} className="h-4 w-4" />
|
||||
<span>{darkMode ? 'Hell' : 'Dunkel'}</span>
|
||||
<span className={`relative h-5 w-9 rounded-full transition-colors ${darkMode ? 'bg-brand-600' : 'bg-slate-300'}`}>
|
||||
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform ${darkMode ? 'translate-x-4' : 'translate-x-0.5'}`} />
|
||||
</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-y-auto p-4 lg:p-8">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
{profileOpen && <ProfilePage onClose={() => setProfileOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export function RequireAuth({ children }: { children: ReactNode }): ReactNode {
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname + location.search }} />;
|
||||
}
|
||||
|
||||
return children;
|
||||
@@ -32,4 +32,4 @@ export function RequireAdmin({ children }: { children: ReactNode }): ReactNode {
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface CardProps {
|
||||
export function Card({ children, className = '' }: CardProps): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl border border-slate-200 bg-white shadow-sm ${className}`}
|
||||
className={`rounded-2xl border border-slate-200 bg-white shadow-sm ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
@@ -25,7 +25,7 @@ export interface CardHeaderProps {
|
||||
/** 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 className="flex items-start justify-between gap-4 border-b border-slate-200 px-5 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>}
|
||||
@@ -42,5 +42,5 @@ export interface CardBodyProps {
|
||||
|
||||
/** Karten-Inhalt. */
|
||||
export function CardBody({ children, className = '' }: CardBodyProps): ReactNode {
|
||||
return <div className={`px-6 py-4 ${className}`}>{children}</div>;
|
||||
}
|
||||
return <div className={`px-5 py-5 ${className}`}>{children}</div>;
|
||||
}
|
||||
|
||||
36
apps/platform-frontend/src/components/ui/icon.tsx
Normal file
36
apps/platform-frontend/src/components/ui/icon.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
export type IconName = 'dashboard' | 'users' | 'modules' | 'system' | 'audit' | 'moon' | 'sun' | 'user' | 'arrow' | 'close' | 'menu' | 'plus' | 'check';
|
||||
|
||||
const PATHS: Record<IconName, ReactNode> = {
|
||||
dashboard: <><rect x="3" y="3" width="7" height="7" rx="1.5" /><rect x="14" y="3" width="7" height="7" rx="1.5" /><rect x="3" y="14" width="7" height="7" rx="1.5" /><rect x="14" y="14" width="7" height="7" rx="1.5" /></>,
|
||||
users: <><path d="M16 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /><circle cx="10" cy="7" r="4" /><path d="M20 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" /></>,
|
||||
modules: <><rect x="3" y="3" width="8" height="8" rx="2" /><rect x="13" y="3" width="8" height="5" rx="2" /><rect x="13" y="10" width="8" height="11" rx="2" /><rect x="3" y="13" width="8" height="8" rx="2" /></>,
|
||||
system: <><path d="M12 8v4l2.5 2.5" /><circle cx="12" cy="12" r="9" /><path d="M12 3v2m9 7h-2M5 12H3m9 9v-2" /></>,
|
||||
audit: <><path d="M8 4h10a2 2 0 0 1 2 2v14H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2Z" /><path d="M8 2v4m0 4h8m-8 4h8m-8 4h5" /></>,
|
||||
moon: <path d="M20.9 13A9 9 0 0 1 11 3.1 9 9 0 1 0 20.9 13Z" />,
|
||||
sun: <><circle cx="12" cy="12" r="4" /><path d="M12 2v2m0 16v2M4.93 4.93l1.42 1.42m11.3 11.3 1.42 1.42M2 12h2m16 0h2M4.93 19.07l1.42-1.42m11.3-11.3 1.42-1.42" /></>,
|
||||
user: <><circle cx="12" cy="8" r="4" /><path d="M5 21a7 7 0 0 1 14 0" /></>,
|
||||
arrow: <><path d="M5 12h14m-6-6 6 6-6 6" /></>,
|
||||
close: <><path d="m18 6-12 12M6 6l12 12" /></>,
|
||||
menu: <><path d="M4 6h16M4 12h16M4 18h16" /></>,
|
||||
plus: <path d="M12 5v14M5 12h14" />,
|
||||
check: <path d="m5 12 4 4L19 6" />,
|
||||
};
|
||||
|
||||
export function Icon({ name, className = 'h-5 w-5' }: { name: IconName; className?: string }): ReactNode {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{PATHS[name]}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ export interface ModalProps {
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
panelClassName?: string;
|
||||
hideHeader?: boolean;
|
||||
}
|
||||
|
||||
/** Zugängliches Modal (Design-System): Fokus-Falle, ESC schließt. */
|
||||
@@ -17,6 +19,8 @@ export function Modal({
|
||||
onClose,
|
||||
children,
|
||||
footer,
|
||||
panelClassName = 'max-w-md',
|
||||
hideHeader = false,
|
||||
}: ModalProps): ReactNode {
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -45,16 +49,18 @@ export function Modal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className="w-full max-w-md rounded-xl bg-white shadow-xl"
|
||||
className={`w-full rounded-xl bg-white shadow-xl ${panelClassName}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="border-b border-slate-200 px-6 py-4">
|
||||
<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>
|
||||
{!hideHeader && (
|
||||
<div className="border-b border-slate-200 px-6 py-4">
|
||||
<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>
|
||||
)}
|
||||
<div className="px-6 py-4">{children}</div>
|
||||
{footer && <div className="border-t border-slate-200 px-6 py-4">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user