Some checks failed
Deploy Nyla Frontend to Production / build-and-deploy (push) Failing after 2s
Co-authored-by: Cursor <cursoragent@cursor.com>
203 lines
6 KiB
TypeScript
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;
|