ui-nyla/src/layouts/MainLayout.tsx
ValueOn AG f35e22c7f4
Some checks failed
Deploy Nyla Frontend to Production / build-and-deploy (push) Failing after 2s
Sync: full codebase from GitHub frontend_nyla main
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 23:54:30 +02:00

203 lines
6 KiB
TypeScript

/**
* MainLayout
*
* Hauptlayout der Anwendung mit Sidebar und Content-Bereich.
* Enthält den FeatureProvider für das Multi-Tenant-System.
*/
import React, { useEffect, useRef, useState } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { FeatureProvider, useFeatureStore } from '../stores/featureStore';
import { MandateNavigation } from '../components/Navigation/MandateNavigation';
import { UserSection } from '../components/Navigation/UserSection';
import { RagRunningBadge } from '../components/RagRunningBadge/RagRunningBadge';
import { KEEP_ALIVE_ROUTES, hideFeatureOutlet } from '../config/keepAliveRoutes';
import type { KeepAliveEntry, KeepAliveScopedEntry, KeepAliveUnscopedEntry } from '../types/keepAlive.types';
import { isKeepAliveScoped } from '../types/keepAlive.types';
import styles from './MainLayout.module.css';
import { useLanguage } from '../providers/language/LanguageContext';
const keepAliveShellStyle = (isVisible: boolean, shellOverflowHidden: boolean): React.CSSProperties => ({
display: isVisible ? 'flex' : 'none',
flexDirection: 'column',
position: 'absolute',
top: 'var(--mobile-topbar-height, 0px)',
left: 0,
right: 0,
bottom: 0,
...(shellOverflowHidden ? { overflow: 'hidden' as const } : {}),
});
const RoutedKeepAliveUnscoped: React.FC<{ entry: KeepAliveUnscopedEntry; pathname: string }> = ({
entry,
pathname,
}) => {
const isVisible = entry.pathRegex.test(pathname);
return (
<div style={keepAliveShellStyle(isVisible, true)}>
{entry.render()}
</div>
);
};
const RoutedKeepAliveScoped: React.FC<{ entry: KeepAliveScopedEntry; pathname: string }> = ({
entry,
pathname,
}) => {
const isVisible = entry.pathRegex.test(pathname);
const {
scopeRegex,
requireMandateForMount = true,
shellOverflowHidden = true,
render,
} = entry;
const cachedMandateIdRef = useRef<string>('');
const cachedInstanceIdRef = useRef<string>('');
const match = pathname.match(scopeRegex);
if (match?.[1] && match?.[2]) {
cachedMandateIdRef.current = match[1];
cachedInstanceIdRef.current = match[2];
}
const mandateId = cachedMandateIdRef.current;
const instanceId = cachedInstanceIdRef.current;
const scopeReady = requireMandateForMount
? !!(mandateId && instanceId)
: !!instanceId;
if (!scopeReady) {
return null;
}
const scopeKey = `${mandateId}:${instanceId}`;
return (
<div style={keepAliveShellStyle(isVisible, shellOverflowHidden)}>
{render({ mandateId, instanceId, scopeKey })}
</div>
);
};
const RoutedKeepAliveSlot: React.FC<{ entry: KeepAliveEntry; pathname: string }> = ({
entry,
pathname,
}) => {
if (!isKeepAliveScoped(entry)) {
return <RoutedKeepAliveUnscoped entry={entry} pathname={pathname} />;
}
return <RoutedKeepAliveScoped entry={entry} pathname={pathname} />;
};
// =============================================================================
// INNER LAYOUT (mit Zugriff auf Store)
// =============================================================================
const MainLayoutInner: React.FC = () => {
const { t } = useLanguage();
const { loadFeatures, initialized, loading, error } = useFeatureStore();
const location = useLocation();
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const hideOutletShell = hideFeatureOutlet(location.pathname);
// Features laden beim Mount
useEffect(() => {
if (!initialized && !loading) {
loadFeatures();
}
}, [initialized, loading, loadFeatures]);
useEffect(() => {
setIsMobileSidebarOpen(false);
}, [location.pathname]);
useEffect(() => {
const handleResize = () => {
if (window.innerWidth > 1024) {
setIsMobileSidebarOpen(false);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return (
<div className={styles.mainLayout}>
{isMobileSidebarOpen && (
<button
className={styles.mobileBackdrop}
onClick={() => setIsMobileSidebarOpen(false)}
aria-label={t('Navigation schließen')}
/>
)}
{/* Sidebar */}
<aside className={`${styles.sidebar} ${isMobileSidebarOpen ? styles.sidebarOpen : ''}`}>
<div className={styles.logoContainer}>
<img src="/logos/poweron-logo.png" alt="PowerOn" className={styles.logoImage} />
</div>
<nav className={styles.navigation}>
{loading && <div className={styles.loadingNav}>{t('Lade Navigation…')}</div>}
{error && (
<div className={styles.errorNav}>
{t('Fehler')}: {error}
</div>
)}
{initialized && !loading && <MandateNavigation />}
</nav>
{/* User-Bereich am unteren Rand */}
<UserSection />
</aside>
{/* Content */}
<main className={styles.content}>
<div className={styles.mobileTopBar}>
<button
className={styles.mobileMenuButton}
onClick={() => setIsMobileSidebarOpen(true)}
aria-label={t('Navigation öffnen')}
>
</button>
<img src="/logos/poweron-logo.png" alt="PowerOn" className={styles.mobileLogo} />
</div>
{KEEP_ALIVE_ROUTES.map((routeEntry) => (
<RoutedKeepAliveSlot key={routeEntry.id} entry={routeEntry} pathname={location.pathname} />
))}
<div
className={styles.outletShell}
style={{ display: hideOutletShell ? 'none' : undefined }}
>
<Outlet />
</div>
</main>
<RagRunningBadge />
</div>
);
};
// =============================================================================
// MAIN LAYOUT (mit Provider)
// =============================================================================
export const MainLayout: React.FC = () => {
return (
<FeatureProvider>
<MainLayoutInner />
</FeatureProvider>
);
};
export default MainLayout;