import { Layout } from 'antd'; import { useEffect, useMemo, useState } from 'react'; import { Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { useGestureLayer, useGesturePageState, useSearchLayer } from '../../../layer'; import { useAppStore } from '../../../store'; import { ChatRuntimeBridgeV2 } from '../ChatRuntimeBridgeV2'; import { syncAppConfigFromServer, useAppConfig } from '../appConfig'; import { useTokenAccess } from '../tokenAccess'; import { useUnreadCounts } from '../chatV2/hooks/useUnreadCounts'; import { matchesShortcut, isTypingTarget, scrollToElement } from '../mainView/utils'; import { MainContent } from '../MainContent'; import { MainHeader } from '../MainHeader'; import { MainSidebar } from '../MainSidebar'; import { appendPreviewRuntimeSearch, isPreviewRuntime, readPreviewRuntimeDeviceModeFromUrl } from '../previewRuntime'; import { buildSearchOptions } from './buildSearchOptions'; import { MainLayoutContextProvider } from './MainLayoutContext'; import { useMainLayoutData } from './useMainLayoutData'; import '../MainLayout.css'; import { buildApiMenuItems, buildApisPath, buildChatMenuItems, buildChatPath, buildDocsMenuItems, buildDocsPath, buildPlanMenuItems, buildPlansPath, buildPlayMenuItems, buildPlayPath, buildSavedLayoutPath, DOCS_DEFAULT_FOLDER, PLAN_MENU_ANCHOR_IDS, renderSidebarIntro, resolveCurrentPageDescriptor, resolvePlanQuickFilterMenu, resolvePlayOpenKeys, resolveSavedLayoutIdFromMenuKey, resolveTopMenuPath, type ApiSectionKey, type ChatSectionKey, type PlanSectionKey, type PlaySidebarKey, type TopMenuKey, } from '../routes'; const E_READER_IMMERSIVE_BODY_CLASS = 'play-app-e-reader-immersive'; function parseRoute(pathname: string): { topMenu: TopMenuKey; docsMenu: string; apiMenu: ApiSectionKey; planMenu: PlanSectionKey; chatMenu: ChatSectionKey; playMenu: PlaySidebarKey; } { const segments = pathname.split('/').filter(Boolean); const [top, first, second] = segments; if (top === 'docs') { return { topMenu: 'docs', docsMenu: first || DOCS_DEFAULT_FOLDER, apiMenu: 'components', planMenu: 'all', chatMenu: 'live', playMenu: 'layout', }; } if (top === 'apis' && (first === 'components' || first === 'widgets')) { return { topMenu: 'apis', docsMenu: DOCS_DEFAULT_FOLDER, apiMenu: first, planMenu: 'all', chatMenu: 'live', playMenu: 'layout', }; } if ( top === 'plans' && (first === 'all' || first === 'in-progress' || first === 'done' || first === 'error' || first === 'release' || first === 'release-review' || first === 'board' || first === 'charts' || first === 'schedule' || first === 'history' || first === 'automation-type' || first === 'automation-context' || first === 'token-setting' || first === 'shared-resource' || first === 'server-command') ) { return { topMenu: 'plans', docsMenu: DOCS_DEFAULT_FOLDER, apiMenu: 'components', planMenu: first, chatMenu: 'live', playMenu: 'layout', }; } if ( top === 'chat' && (first === 'live' || first === 'system' || first === 'changes' || first === 'resources' || first === 'errors' || first === 'manage' || first === 'manage-defaults' || first === 'manage-share') ) { return { topMenu: 'chat', docsMenu: DOCS_DEFAULT_FOLDER, apiMenu: 'components', planMenu: 'all', chatMenu: first, playMenu: 'layout', }; } if (top === 'play' && (first === 'layout' || first === 'draw' || first === 'apps' || first === 'test' || first === 'cbt')) { return { topMenu: 'play', docsMenu: DOCS_DEFAULT_FOLDER, apiMenu: 'components', planMenu: 'all', chatMenu: 'live', playMenu: first, }; } if (top === 'play' && first === 'layout-record' && second) { return { topMenu: 'play', docsMenu: DOCS_DEFAULT_FOLDER, apiMenu: 'components', planMenu: 'all', chatMenu: 'live', playMenu: `layout-record:${second}`, }; } return { topMenu: 'chat', docsMenu: DOCS_DEFAULT_FOLDER, apiMenu: 'components', planMenu: 'all', chatMenu: 'live', playMenu: 'layout', }; } function isRestrictedTopMenu(topMenu: TopMenuKey, hasAccess: boolean) { return !hasAccess && topMenu !== 'docs'; } function getIsMobileViewport() { if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { return false; } if (readPreviewRuntimeDeviceModeFromUrl() === 'mobile') { return true; } return window.matchMedia('(max-width: 768px)').matches; } function getIsSidebarOverlayViewport(topMenu: TopMenuKey) { if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { return false; } if (readPreviewRuntimeDeviceModeFromUrl() === 'mobile') { return true; } return window.matchMedia(topMenu === 'chat' ? '(max-width: 1180px)' : '(max-width: 768px)').matches; } function resolveSidebarCollapsedForViewport(isSidebarOverlayViewport: boolean) { if (!isSidebarOverlayViewport) { return false; } return true; } function resolveSidebarOpenKeys( topMenu: TopMenuKey, hasAccess: boolean, planMenu: PlanSectionKey, chatMenu: ChatSectionKey, ) { if (!hasAccess) { return ['docs-group']; } if (topMenu === 'docs') { return ['docs-group']; } if (topMenu === 'apis') { return ['api-group']; } if (topMenu === 'play') { return resolvePlayOpenKeys(); } if (topMenu === 'plans') { if (planMenu === 'server-command') { return ['server-group']; } if (planMenu === 'token-setting' || planMenu === 'shared-resource') { return ['token-management-group']; } return ['plan-group']; } if (chatMenu === 'errors') { return ['app-log-group']; } return chatMenu === 'manage' || chatMenu === 'manage-defaults' ? ['chat-manage-group'] : ['chat-group']; } export function MainLayout() { const previewRuntime = isPreviewRuntime(); const location = useLocation(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); const { currentPage, focusedComponentId, setCurrentPage, setFocusedComponentId } = useAppStore(); const { hasAccess } = useTokenAccess(); const appConfig = useAppConfig(); const { openSearch, setOptions: setSearchOptions } = useSearchLayer(); const layoutData = useMainLayoutData(); const routeState = useMemo(() => parseRoute(location.pathname), [location.pathname]); const [isMobileViewport, setIsMobileViewport] = useState(() => getIsMobileViewport()); const [isEReaderImmersiveActive, setIsEReaderImmersiveActive] = useState(() => typeof document !== 'undefined' ? document.body.classList.contains(E_READER_IMMERSIVE_BODY_CLASS) : false, ); const [sidebarCollapsed, setSidebarCollapsed] = useState(() => resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport(routeState.topMenu)), ); const [isSidebarOverlayViewport, setIsSidebarOverlayViewport] = useState(() => getIsSidebarOverlayViewport(routeState.topMenu), ); const [contentExpanded, setContentExpanded] = useState(false); const [sidebarOpenKeys, setSidebarOpenKeys] = useState( resolveSidebarOpenKeys(routeState.topMenu, hasAccess, routeState.planMenu, routeState.chatMenu), ); const [activePlanQuickFilter, setActivePlanQuickFilter] = useState< 'working' | 'release-pending-main' | 'automation-failed' | null >(routeState.planMenu === 'release' ? 'release-pending-main' : null); const [planQuickFilterRequestKey, setPlanQuickFilterRequestKey] = useState(routeState.planMenu === 'release' ? 1 : 0); const { componentSampleEntries, widgetSampleEntries, componentSamples, widgetSamples, docsDocuments, savedLayouts, savedLayoutsReady, setSavedLayouts, docFolders } = layoutData; const { chatUnreadCount } = useUnreadCounts(); const navigateWithinApp = (path: string, options?: { replace?: boolean; resetSearch?: boolean }) => { const baseSearch = options?.resetSearch ? '' : location.search; const nextPath = previewRuntime ? appendPreviewRuntimeSearch(path, baseSearch) : path; navigate(nextPath, options?.replace == null ? undefined : { replace: options.replace }); }; useEffect(() => { void syncAppConfigFromServer(); }, []); useEffect(() => { if (typeof document === 'undefined') { return undefined; } const body = document.body; const syncEReaderImmersiveState = () => { setIsEReaderImmersiveActive(body.classList.contains(E_READER_IMMERSIVE_BODY_CLASS)); }; syncEReaderImmersiveState(); const observer = new MutationObserver(syncEReaderImmersiveState); observer.observe(body, { attributes: true, attributeFilter: ['class'] }); return () => { observer.disconnect(); }; }, []); useEffect(() => { const mediaQuery = window.matchMedia('(max-width: 768px)'); const updateViewport = () => { setIsMobileViewport(getIsMobileViewport()); }; updateViewport(); mediaQuery.addEventListener('change', updateViewport); return () => { mediaQuery.removeEventListener('change', updateViewport); }; }, []); useEffect(() => { const mediaQuery = window.matchMedia(routeState.topMenu === 'chat' ? '(max-width: 1180px)' : '(max-width: 768px)'); const updateViewport = () => { setIsSidebarOverlayViewport(getIsSidebarOverlayViewport(routeState.topMenu)); }; updateViewport(); mediaQuery.addEventListener('change', updateViewport); return () => { mediaQuery.removeEventListener('change', updateViewport); }; }, [routeState.topMenu]); useEffect(() => { setSidebarCollapsed(resolveSidebarCollapsedForViewport(isSidebarOverlayViewport)); }, [isSidebarOverlayViewport, routeState.topMenu]); useEffect(() => { setSidebarOpenKeys(resolveSidebarOpenKeys(routeState.topMenu, hasAccess, routeState.planMenu, routeState.chatMenu)); }, [hasAccess, routeState.chatMenu, routeState.planMenu, routeState.topMenu]); useEffect(() => { if (docFolders.length > 0 && routeState.topMenu === 'docs' && !docFolders.includes(routeState.docsMenu)) { navigateWithinApp(buildDocsPath(docFolders[0]), { replace: true }); } }, [docFolders, navigate, location.search, previewRuntime, routeState.docsMenu, routeState.topMenu]); useEffect(() => { const savedLayoutId = resolveSavedLayoutIdFromMenuKey(routeState.playMenu); if (savedLayoutId && savedLayoutsReady && !savedLayouts.some((record) => record.id === savedLayoutId)) { navigateWithinApp(buildPlayPath('layout'), { replace: true }); } }, [location.search, navigate, previewRuntime, routeState.playMenu, savedLayouts, savedLayoutsReady]); useEffect(() => { if (!isRestrictedTopMenu(routeState.topMenu, hasAccess)) { return; } navigate(`${buildDocsPath(DOCS_DEFAULT_FOLDER)}${location.search}`, { replace: true }); }, [hasAccess, location.search, navigate, routeState.topMenu]); useEffect(() => { if (routeState.topMenu !== 'plans') { setActivePlanQuickFilter(null); return; } if (routeState.planMenu === 'release') { setActivePlanQuickFilter('release-pending-main'); return; } setActivePlanQuickFilter((current) => (current === 'working' || current === 'automation-failed' ? current : null)); }, [routeState.planMenu, routeState.topMenu]); const gestureLayer = useMemo( () => ({ id: 'main-layout', enabled: !isEReaderImmersiveActive && !(isMobileViewport && routeState.topMenu === 'docs' && routeState.docsMenu === DOCS_DEFAULT_FOLDER), gestures: [ { id: 'mobile-top-right-pull-alert', activeStates: ['anyway'], mobileOnly: true, trigger: 'pull-down-top-right' as const, onTrigger: () => { openSearch(); }, }, ], }), [isEReaderImmersiveActive, isMobileViewport, openSearch, routeState.docsMenu, routeState.topMenu], ); useGesturePageState('anyway'); useGestureLayer(gestureLayer); useEffect(() => { const handleWindowKeyDown = (event: KeyboardEvent) => { if (event.repeat || isTypingTarget(event.target) || isEReaderImmersiveActive) { return; } if (matchesShortcut(event, appConfig.gestureShortcuts.openSearch)) { event.preventDefault(); openSearch(); } }; window.addEventListener('keydown', handleWindowKeyDown); return () => { window.removeEventListener('keydown', handleWindowKeyDown); }; }, [ appConfig.gestureShortcuts.openSearch, isEReaderImmersiveActive, openSearch, ]); const selectedDocs = useMemo( () => docsDocuments.filter((document) => document.folder === routeState.docsMenu), [docsDocuments, routeState.docsMenu], ); const searchOptions = useMemo( () => buildSearchOptions({ componentSamples, widgetSamples, docFolders, docsDocuments, savedLayouts, hasAccess, navigateTo: (path) => { navigateWithinApp(path); }, setFocusedComponentId, requestPlanQuickFilter: (filter) => { setActivePlanQuickFilter(filter); setPlanQuickFilterRequestKey((previous) => previous + 1); }, }), [ componentSamples, docFolders, docsDocuments, hasAccess, location.search, navigate, previewRuntime, savedLayouts, setFocusedComponentId, widgetSamples, ], ); useEffect(() => { setSearchOptions(searchOptions); }, [searchOptions, setSearchOptions]); useEffect(() => { setCurrentPage( resolveCurrentPageDescriptor({ topMenu: routeState.topMenu, docsMenu: routeState.docsMenu, apiMenu: routeState.apiMenu, planMenu: routeState.planMenu, chatMenu: routeState.chatMenu, playMenu: routeState.playMenu, savedLayouts, }), ); }, [routeState, savedLayouts, setCurrentPage]); const currentDocsFolder = docFolders.includes(routeState.docsMenu) ? routeState.docsMenu : docFolders[0] ?? DOCS_DEFAULT_FOLDER; const sidebarIntro = renderSidebarIntro(routeState.topMenu); const apiMenuItems = useMemo(() => buildApiMenuItems(), []); const docsMenuItems = useMemo(() => buildDocsMenuItems(docFolders), [docFolders]); const planMenuItems = useMemo(() => buildPlanMenuItems(hasAccess), [hasAccess]); const chatMenuItems = useMemo(() => buildChatMenuItems(hasAccess, chatUnreadCount), [chatUnreadCount, hasAccess]); const playMenuItems = useMemo(() => buildPlayMenuItems(savedLayouts), [savedLayouts]); const activePlayAppId = routeState.topMenu === 'play' && routeState.playMenu === 'apps' ? searchParams.get('app')?.trim() ?? '' : ''; const isPlayAppFullscreen = activePlayAppId.length > 0; const initialSelectedPlanId = Number(searchParams.get('planId')); const initialSelectedWorkId = searchParams.get('workId'); return ( {routeState.topMenu === 'chat' || isPlayAppFullscreen ? null : } {contentExpanded || isPlayAppFullscreen ? null : ( { setSidebarCollapsed((previous) => !previous); }} onToggleContentExpanded={() => { setContentExpanded((previous) => !previous); }} onChangeTopMenu={(menu) => { navigateWithinApp(resolveTopMenuPath(menu, currentDocsFolder)); setSidebarCollapsed(resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport(menu))); }} onOpenSearch={() => { openSearch(); }} onOpenPlanQuickFilter={(filter) => { const targetPlanMenu = resolvePlanQuickFilterMenu(filter); setActivePlanQuickFilter(filter); setPlanQuickFilterRequestKey((previous) => previous + 1); navigateWithinApp(buildPlansPath(targetPlanMenu)); setSidebarCollapsed(resolveSidebarCollapsedForViewport(getIsSidebarOverlayViewport('plans'))); scrollToElement(PLAN_MENU_ANCHOR_IDS[targetPlanMenu] ?? 'plan-menu-all'); }} /> )} {contentExpanded || isPlayAppFullscreen || (isSidebarOverlayViewport && sidebarCollapsed) ? null : ( { navigateWithinApp(buildApisPath(key as ApiSectionKey)); if (isSidebarOverlayViewport) { setSidebarCollapsed(true); } }} onSelectDocsMenu={(key) => { navigateWithinApp(buildDocsPath(key)); if (isSidebarOverlayViewport) { setSidebarCollapsed(true); } }} onSelectPlanMenu={(key) => { setActivePlanQuickFilter(key === 'release' ? 'release-pending-main' : null); setPlanQuickFilterRequestKey((previous) => previous + 1); navigateWithinApp(buildPlansPath(key)); if (isSidebarOverlayViewport) { setSidebarCollapsed(true); } }} onSelectChatMenu={(key) => { navigateWithinApp(buildChatPath(key), { resetSearch: true }); if (isSidebarOverlayViewport) { setSidebarCollapsed(true); } }} onSelectPlayMenu={(key) => { const savedLayoutId = resolveSavedLayoutIdFromMenuKey(key); navigateWithinApp(savedLayoutId ? buildSavedLayoutPath(savedLayoutId) : buildPlayPath(key)); if (isSidebarOverlayViewport) { setSidebarCollapsed(true); } }} introColor={sidebarIntro.color} introTag={sidebarIntro.tag} introDescription={sidebarIntro.description} /> )} setContentExpanded((previous) => !previous)} > ); }