Files
makelore/src/components/layout/MainLayout.tsx
inman 01bee3188b
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
feat: integrate learning module
2026-08-16 20:52:16 +08:00

136 lines
5.9 KiB
TypeScript

/**
* Main Layout Component
* TitleBar at top, then sidebar + content below.
*/
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useCallback, useEffect, useRef, useState } from 'react';
import { LockKeyhole, Settings2 } from 'lucide-react';
import { Sidebar } from './Sidebar';
import { TitleBar } from './TitleBar';
import { Button } from '@/components/ui/button';
import { getAiModuleForPath } from '@/lib/ai-modules';
import { useOpencodeStore } from '@/stores/opencode';
import { useProjectConfigStore } from '@/stores/project-config';
import { useSettingsStore } from '@/stores/settings';
import { cn } from '@/lib/utils';
import type { SidebarPeekSource } from './sidebar-peek';
const SIDEBAR_PEEK_CLOSE_DELAY_MS = 180;
export function MainLayout() {
const activeProject = useOpencodeStore((state) => state.activeProject);
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
const config = useProjectConfigStore((state) => activeProject ? state.configsByProjectId[activeProject.id] : undefined);
const load = useProjectConfigStore((state) => state.load);
const location = useLocation();
const navigate = useNavigate();
const [sidebarPeekOpen, setSidebarPeekOpen] = useState(false);
const sidebarPeekCloseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const sidebarPeekSourcesRef = useRef<Record<SidebarPeekSource, boolean>>({
toggle: false,
focus: false,
sidebar: false,
titlebar: false,
});
const activeModule = getAiModuleForPath(location.pathname);
const isProgrammingModule = activeModule === 'programming';
const isPaintingModule = activeModule === 'painting';
const isLearningPlayer = location.pathname.startsWith('/learning/course/');
const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/');
const isChatWorkspace = location.pathname === '/opencode-chat';
const isInitializationSafeRoute = location.pathname === '/project-config' || !isProgrammingModule;
const handleSidebarPeekChange = useCallback((open: boolean, source: SidebarPeekSource) => {
if (!sidebarCollapsed) return;
const sources = sidebarPeekSourcesRef.current;
if (sources[source] === open) return;
if (sidebarPeekCloseTimerRef.current) {
clearTimeout(sidebarPeekCloseTimerRef.current);
sidebarPeekCloseTimerRef.current = null;
}
sources[source] = open;
if (open) {
setSidebarPeekOpen(true);
return;
}
if (Object.values(sources).some(Boolean)) return;
sidebarPeekCloseTimerRef.current = setTimeout(() => {
if (Object.values(sidebarPeekSourcesRef.current).some(Boolean)) return;
setSidebarPeekOpen(false);
sidebarPeekCloseTimerRef.current = null;
}, SIDEBAR_PEEK_CLOSE_DELAY_MS);
}, [sidebarCollapsed]);
useEffect(() => {
sidebarPeekSourcesRef.current = { toggle: false, focus: false, sidebar: false, titlebar: false };
// This transient state mirrors the external persisted sidebar preference;
// reset it when that preference changes so a stale preview cannot reopen.
// eslint-disable-next-line react-hooks/set-state-in-effect
setSidebarPeekOpen(false);
if (sidebarPeekCloseTimerRef.current) {
clearTimeout(sidebarPeekCloseTimerRef.current);
sidebarPeekCloseTimerRef.current = null;
}
}, [sidebarCollapsed]);
useEffect(() => () => {
if (sidebarPeekCloseTimerRef.current) clearTimeout(sidebarPeekCloseTimerRef.current);
}, []);
useEffect(() => {
if (activeProject && isProgrammingModule) void load(activeProject.id).catch(() => undefined);
}, [activeProject, isProgrammingModule, load]);
const initializationBlocked = Boolean(activeProject && (!config || !config.initialized) && !isInitializationSafeRoute);
return (
<div data-testid="main-layout" className="relative flex h-[100dvh] flex-col overflow-hidden bg-transparent font-sans">
{/* Title bar: drag region on macOS, icon + controls on Windows */}
<TitleBar
integrated={!isLearningPlayer}
workspaceLayout={isChatWorkspace}
overlay={isPaintingModule && !isPromptMuseum}
pageTitle={isPromptMuseum ? '获取灵感' : undefined}
sidebarPeekOpen={sidebarPeekOpen}
onSidebarPeekChange={handleSidebarPeekChange}
/>
{/* Below the title bar: sidebar + content */}
<div className="relative flex min-h-0 flex-1 overflow-hidden">
{!isLearningPlayer ? (
<Sidebar
workspaceLayout={isChatWorkspace}
sidebarPeekOpen={sidebarPeekOpen}
onSidebarPeekChange={handleSidebarPeekChange}
/>
) : null}
<main
data-testid="main-content"
className={cn(
'relative min-h-0 min-w-0 flex-1 overflow-auto bg-background',
isPaintingModule || isLearningPlayer ? 'basis-0 h-full overflow-hidden p-0' : 'p-5 sm:p-6',
isChatWorkspace && !isPaintingModule && 'basis-0 overflow-hidden p-0',
)}
>
<Outlet />
{initializationBlocked ? (
<div data-testid="project-initialization-gate" className="glass-surface absolute inset-0 z-[100] flex items-center justify-center bg-background/90 p-6">
<div className="surface-card max-w-md rounded-2xl border border-border/80 bg-background p-8 text-center shadow-float">
<LockKeyhole className="mx-auto h-10 w-10" />
<h1 className="mt-4 text-2xl font-semibold"></h1>
<p className="mt-3 text-sm font-medium text-muted-foreground"> Agent </p>
<Button onClick={() => navigate('/project-config')} className="mt-6 border border-brand/20 bg-brand font-semibold text-primary-foreground"><Settings2 className="mr-2 h-4 w-4" /></Button>
</div>
</div>
) : null}
</main>
</div>
</div>
);
}