499 lines
16 KiB
TypeScript
Executable File
499 lines
16 KiB
TypeScript
Executable File
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 { useTokenAccess } from '../tokenAccess';
|
|
import { syncAppConfigFromServer, useAppConfig } from '../appConfig';
|
|
import { ChatRuntimeBridgeV2 } from '../ChatRuntimeBridgeV2';
|
|
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 { 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';
|
|
|
|
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 === 'server-command')
|
|
) {
|
|
return {
|
|
topMenu: 'plans',
|
|
docsMenu: DOCS_DEFAULT_FOLDER,
|
|
apiMenu: 'components',
|
|
planMenu: first,
|
|
chatMenu: 'live',
|
|
playMenu: 'layout',
|
|
};
|
|
}
|
|
|
|
if (top === 'chat' && (first === 'live' || first === 'changes' || first === 'errors' || first === 'manage')) {
|
|
return {
|
|
topMenu: 'chat',
|
|
docsMenu: DOCS_DEFAULT_FOLDER,
|
|
apiMenu: 'components',
|
|
planMenu: 'all',
|
|
chatMenu: first,
|
|
playMenu: 'layout',
|
|
};
|
|
}
|
|
|
|
if (top === 'play' && first === 'layout') {
|
|
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: 'plans',
|
|
docsMenu: DOCS_DEFAULT_FOLDER,
|
|
apiMenu: 'components',
|
|
planMenu: 'all',
|
|
chatMenu: 'live',
|
|
playMenu: 'layout',
|
|
};
|
|
}
|
|
|
|
function isRestrictedTopMenu(topMenu: TopMenuKey, hasAccess: boolean) {
|
|
return !hasAccess && topMenu !== 'docs';
|
|
}
|
|
|
|
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') {
|
|
return planMenu === 'server-command' ? ['server-group'] : ['plan-group'];
|
|
}
|
|
|
|
if (chatMenu === 'errors') {
|
|
return ['app-log-group'];
|
|
}
|
|
|
|
return chatMenu === 'manage' ? ['chat-manage-group'] : ['codex-live-group'];
|
|
}
|
|
|
|
export function MainLayout() {
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const [searchParams] = useSearchParams();
|
|
const { 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 [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
const [contentExpanded, setContentExpanded] = useState(false);
|
|
const [isMobileViewport, setIsMobileViewport] = useState(false);
|
|
const [sidebarOpenKeys, setSidebarOpenKeys] = useState<string[]>(
|
|
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, setSavedLayouts, docFolders } = layoutData;
|
|
const { chatUnreadCount } = useUnreadCounts();
|
|
|
|
useEffect(() => {
|
|
void syncAppConfigFromServer();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const mediaQuery = window.matchMedia('(max-width: 768px)');
|
|
const updateViewport = () => {
|
|
setIsMobileViewport(mediaQuery.matches);
|
|
};
|
|
|
|
updateViewport();
|
|
mediaQuery.addEventListener('change', updateViewport);
|
|
|
|
return () => {
|
|
mediaQuery.removeEventListener('change', updateViewport);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isMobileViewport) {
|
|
setSidebarCollapsed(false);
|
|
return;
|
|
}
|
|
|
|
setSidebarCollapsed(routeState.topMenu !== 'docs');
|
|
}, [isMobileViewport, 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)) {
|
|
navigate(buildDocsPath(docFolders[0]), { replace: true });
|
|
}
|
|
}, [docFolders, navigate, routeState.docsMenu, routeState.topMenu]);
|
|
|
|
useEffect(() => {
|
|
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(routeState.playMenu);
|
|
|
|
if (savedLayoutId && !savedLayouts.some((record) => record.id === savedLayoutId)) {
|
|
navigate(buildPlayPath('layout'), { replace: true });
|
|
}
|
|
}, [navigate, routeState.playMenu, savedLayouts]);
|
|
|
|
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]);
|
|
|
|
useGesturePageState('anyway');
|
|
useGestureLayer({
|
|
id: 'main-layout',
|
|
enabled: !(isMobileViewport && routeState.topMenu === 'docs' && routeState.docsMenu === 'worklogs'),
|
|
gestures: [
|
|
{
|
|
id: 'mobile-top-right-pull-alert',
|
|
activeStates: ['anyway'],
|
|
mobileOnly: true,
|
|
trigger: 'pull-down-top-right',
|
|
onTrigger: () => {
|
|
openSearch();
|
|
},
|
|
},
|
|
{
|
|
id: 'mobile-middle-right-search-window',
|
|
activeStates: ['anyway'],
|
|
mobileOnly: true,
|
|
trigger: 'pull-left-middle-right',
|
|
onTrigger: () => {
|
|
openSearch('window');
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
useEffect(() => {
|
|
const handleWindowKeyDown = (event: KeyboardEvent) => {
|
|
if (event.repeat || isTypingTarget(event.target)) {
|
|
return;
|
|
}
|
|
|
|
if (matchesShortcut(event, appConfig.gestureShortcuts.openWindowSearch)) {
|
|
event.preventDefault();
|
|
openSearch('window');
|
|
return;
|
|
}
|
|
|
|
if (matchesShortcut(event, appConfig.gestureShortcuts.openSearch)) {
|
|
event.preventDefault();
|
|
openSearch();
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleWindowKeyDown);
|
|
|
|
return () => {
|
|
window.removeEventListener('keydown', handleWindowKeyDown);
|
|
};
|
|
}, [appConfig.gestureShortcuts.openSearch, appConfig.gestureShortcuts.openWindowSearch, openSearch]);
|
|
|
|
const selectedDocs = useMemo(
|
|
() => docsDocuments.filter((document) => document.folder === routeState.docsMenu),
|
|
[docsDocuments, routeState.docsMenu],
|
|
);
|
|
|
|
const searchOptions = useMemo(
|
|
() =>
|
|
buildSearchOptions({
|
|
componentSamples,
|
|
widgetSamples,
|
|
docFolders,
|
|
docsDocuments,
|
|
hasAccess,
|
|
navigateTo: (path) => {
|
|
navigate(path);
|
|
},
|
|
setFocusedComponentId,
|
|
requestPlanQuickFilter: (filter) => {
|
|
setActivePlanQuickFilter(filter);
|
|
setPlanQuickFilterRequestKey((previous) => previous + 1);
|
|
},
|
|
}),
|
|
[componentSamples, docFolders, docsDocuments, hasAccess, navigate, 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 showInlineMobileDocsSidebar = isMobileViewport && routeState.topMenu === 'docs';
|
|
const initialSelectedPlanId = Number(searchParams.get('planId'));
|
|
const initialSelectedWorkId = searchParams.get('workId');
|
|
|
|
return (
|
|
<MainLayoutContextProvider
|
|
value={{
|
|
topMenu: routeState.topMenu,
|
|
selectedDocsMenu: routeState.docsMenu,
|
|
selectedApiMenu: routeState.apiMenu,
|
|
selectedPlanMenu: routeState.planMenu,
|
|
selectedChatMenu: routeState.chatMenu,
|
|
selectedPlayMenu: routeState.playMenu,
|
|
activePlanQuickFilter,
|
|
planQuickFilterRequestKey,
|
|
initialSelectedPlanId: Number.isFinite(initialSelectedPlanId) ? initialSelectedPlanId : null,
|
|
initialSelectedWorkId,
|
|
selectedDocs,
|
|
docsDocuments,
|
|
componentSampleEntries,
|
|
widgetSampleEntries,
|
|
componentSamples,
|
|
widgetSamples,
|
|
savedLayouts,
|
|
setSavedLayouts,
|
|
searchOptions,
|
|
}}
|
|
>
|
|
<Layout className="app-shell app-shell--docs-api">
|
|
<ChatRuntimeBridgeV2 />
|
|
{contentExpanded ? null : (
|
|
<MainHeader
|
|
activeTopMenu={routeState.topMenu}
|
|
sidebarCollapsed={sidebarCollapsed}
|
|
contentExpanded={contentExpanded}
|
|
isMobileViewport={isMobileViewport}
|
|
onToggleSidebar={() => {
|
|
setSidebarCollapsed((previous) => !previous);
|
|
}}
|
|
onToggleContentExpanded={() => {
|
|
setContentExpanded((previous) => !previous);
|
|
}}
|
|
onChangeTopMenu={(menu) => {
|
|
navigate(resolveTopMenuPath(menu, currentDocsFolder));
|
|
setSidebarCollapsed(false);
|
|
}}
|
|
onOpenPlanQuickFilter={(filter) => {
|
|
const targetPlanMenu = resolvePlanQuickFilterMenu(filter);
|
|
setActivePlanQuickFilter(filter);
|
|
setPlanQuickFilterRequestKey((previous) => previous + 1);
|
|
navigate(buildPlansPath(targetPlanMenu));
|
|
setSidebarCollapsed(isMobileViewport);
|
|
scrollToElement(PLAN_MENU_ANCHOR_IDS[targetPlanMenu] ?? 'plan-menu-all');
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<Layout>
|
|
{contentExpanded || (isMobileViewport && sidebarCollapsed && !showInlineMobileDocsSidebar) ? null : (
|
|
<MainSidebar
|
|
activeTopMenu={routeState.topMenu}
|
|
hasAccess={hasAccess}
|
|
sidebarCollapsed={sidebarCollapsed}
|
|
isMobileViewport={isMobileViewport}
|
|
mobileInline={showInlineMobileDocsSidebar}
|
|
openKeys={sidebarOpenKeys}
|
|
apiMenuItems={apiMenuItems}
|
|
docsMenuItems={docsMenuItems}
|
|
planMenuItems={planMenuItems}
|
|
chatMenuItems={chatMenuItems}
|
|
playMenuItems={playMenuItems}
|
|
selectedApiMenu={routeState.apiMenu}
|
|
selectedDocsMenu={routeState.docsMenu}
|
|
selectedPlanMenu={routeState.planMenu}
|
|
selectedChatMenu={routeState.chatMenu}
|
|
selectedPlayMenu={routeState.playMenu}
|
|
onOpenKeysChange={setSidebarOpenKeys}
|
|
onSelectApiMenu={(key) => {
|
|
navigate(buildApisPath(key as ApiSectionKey));
|
|
if (isMobileViewport) {
|
|
setSidebarCollapsed(true);
|
|
}
|
|
}}
|
|
onSelectDocsMenu={(key) => {
|
|
navigate(buildDocsPath(key));
|
|
if (isMobileViewport && !showInlineMobileDocsSidebar) {
|
|
setSidebarCollapsed(true);
|
|
}
|
|
}}
|
|
onSelectPlanMenu={(key) => {
|
|
setActivePlanQuickFilter(key === 'release' ? 'release-pending-main' : null);
|
|
setPlanQuickFilterRequestKey((previous) => previous + 1);
|
|
navigate(buildPlansPath(key));
|
|
if (isMobileViewport) {
|
|
setSidebarCollapsed(true);
|
|
}
|
|
}}
|
|
onSelectChatMenu={(key) => {
|
|
navigate(buildChatPath(key));
|
|
if (isMobileViewport) {
|
|
setSidebarCollapsed(true);
|
|
}
|
|
}}
|
|
onSelectPlayMenu={(key) => {
|
|
const savedLayoutId = resolveSavedLayoutIdFromMenuKey(key);
|
|
navigate(savedLayoutId ? buildSavedLayoutPath(savedLayoutId) : buildPlayPath(key as 'layout'));
|
|
if (isMobileViewport) {
|
|
setSidebarCollapsed(true);
|
|
}
|
|
}}
|
|
introColor={sidebarIntro.color}
|
|
introTag={sidebarIntro.tag}
|
|
introDescription={sidebarIntro.description}
|
|
/>
|
|
)}
|
|
|
|
{isMobileViewport && !sidebarCollapsed && !showInlineMobileDocsSidebar ? null : (
|
|
<MainContent contentExpanded={contentExpanded} onToggleContentExpanded={() => setContentExpanded((previous) => !previous)}>
|
|
<Outlet />
|
|
</MainContent>
|
|
)}
|
|
</Layout>
|
|
</Layout>
|
|
</MainLayoutContextProvider>
|
|
);
|
|
}
|