Extend the window.gitmost native-host bridge with three methods that work
when no page is open, registered globally at the app-shell level (not in
page-editor.tsx) so the react-router navigate fn and the api-client are
available:
- listSpaces(): reuse getSpaces() -> [{id, name}], flags truncation.
- listPages({spaceId, parentPageId?}): reuse getSidebarPages()
-> [{id, title, hasChildren}], first page only (truncated flag).
- createPageWithRecording({spaceId, parentPageId?, title?, base64,
filename, mimeType}): validate/decode the audio first (so a bad payload
leaves no junk page), resolve the space slug via getSpaceById (no-space
probe), createPage(), navigate via the router (no reload), wait for the
new page's editor to be mounted+editable+Yjs-connected, then run the same
uploadAudioAction path as insertRecording. Resolve-only error contract:
no-space | create-failed | editor-timeout | insert-failed.
DRY: extract the base64 decode/validate + audio-insert pipeline from
page-editor.tsx into features/editor/gitmost/gitmost-recording.ts; the
existing insertRecording now delegates to it (behavior unchanged).
Mount GitmostGlobalBridge once in GlobalAppShell. Before navigating, reset
the shared yjsConnectionStatusAtom so the readiness gate waits for the NEW
page's provider to connect instead of a stale "connected" from a previously
open page.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
168 lines
5.4 KiB
TypeScript
168 lines
5.4 KiB
TypeScript
import { AppShell, Container } from "@mantine/core";
|
|
import React, { useEffect, useRef, useState } from "react";
|
|
import { useLocation } from "react-router-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
|
import { useAtom } from "jotai";
|
|
import {
|
|
asideStateAtom,
|
|
desktopSidebarAtom,
|
|
mobileSidebarAtom,
|
|
sidebarWidthAtom,
|
|
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
|
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
|
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
|
import Aside from "@/components/layouts/global/aside.tsx";
|
|
import AiChatWindow from "@/features/ai-chat/components/ai-chat-window.tsx";
|
|
import GitmostGlobalBridge from "@/features/editor/gitmost/gitmost-global-bridge.tsx";
|
|
import classes from "./app-shell.module.css";
|
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
|
import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
|
|
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
|
|
import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx";
|
|
|
|
export default function GlobalAppShell({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [mobileOpened] = useAtom(mobileSidebarAtom);
|
|
const toggleMobile = useToggleSidebar(mobileSidebarAtom);
|
|
const [desktopOpened] = useAtom(desktopSidebarAtom);
|
|
const [{ isAsideOpen, tab: asideTab }] = useAtom(asideStateAtom);
|
|
const [sidebarWidth, setSidebarWidth] = useAtom(sidebarWidthAtom);
|
|
const [isResizing, setIsResizing] = useState(false);
|
|
const sidebarRef = useRef(null);
|
|
|
|
const startResizing = React.useCallback((mouseDownEvent) => {
|
|
mouseDownEvent.preventDefault();
|
|
setIsResizing(true);
|
|
}, []);
|
|
|
|
const stopResizing = React.useCallback(() => {
|
|
setIsResizing(false);
|
|
}, []);
|
|
|
|
const resize = React.useCallback(
|
|
(mouseMoveEvent) => {
|
|
if (isResizing) {
|
|
const newWidth =
|
|
mouseMoveEvent.clientX -
|
|
sidebarRef.current.getBoundingClientRect().left;
|
|
if (newWidth < 220) {
|
|
setSidebarWidth(220);
|
|
return;
|
|
}
|
|
if (newWidth > 600) {
|
|
setSidebarWidth(600);
|
|
return;
|
|
}
|
|
setSidebarWidth(newWidth);
|
|
}
|
|
},
|
|
[isResizing],
|
|
);
|
|
|
|
useEffect(() => {
|
|
//https://codesandbox.io/p/sandbox/kz9de
|
|
window.addEventListener("mousemove", resize);
|
|
window.addEventListener("mouseup", stopResizing);
|
|
return () => {
|
|
window.removeEventListener("mousemove", resize);
|
|
window.removeEventListener("mouseup", stopResizing);
|
|
};
|
|
}, [resize, stopResizing]);
|
|
|
|
const location = useLocation();
|
|
const isSettingsRoute = location.pathname.startsWith("/settings");
|
|
const isSpaceRoute = location.pathname.startsWith("/s/");
|
|
const isPageRoute = location.pathname.includes("/p/");
|
|
const showGlobalSidebar = !isSpaceRoute && !isSettingsRoute;
|
|
|
|
return (
|
|
<>
|
|
<SkipToMain />
|
|
<AppShell
|
|
header={{ height: 45 }}
|
|
navbar={{
|
|
width: isSpaceRoute ? sidebarWidth : 300,
|
|
breakpoint: "sm",
|
|
collapsed: {
|
|
mobile: !mobileOpened,
|
|
desktop: !desktopOpened,
|
|
},
|
|
}}
|
|
aside={
|
|
isPageRoute && {
|
|
width: 420,
|
|
breakpoint: "sm",
|
|
collapsed: { mobile: !isAsideOpen, desktop: !isAsideOpen },
|
|
}
|
|
}
|
|
padding={{ base: "xs", sm: "md" }}
|
|
>
|
|
<AppShell.Header px="md" className={classes.header}>
|
|
<AppHeader />
|
|
</AppShell.Header>
|
|
<AppShell.Navbar
|
|
className={classes.navbar}
|
|
withBorder={false}
|
|
ref={sidebarRef}
|
|
aria-label={
|
|
isSpaceRoute
|
|
? t("Space navigation")
|
|
: isSettingsRoute
|
|
? t("Settings navigation")
|
|
: t("Main navigation")
|
|
}
|
|
>
|
|
{isSpaceRoute && (
|
|
<div className={classes.resizeHandle} onMouseDown={startResizing} />
|
|
)}
|
|
{isSpaceRoute && <SpaceSidebar />}
|
|
{isSettingsRoute && <SettingsSidebar />}
|
|
{showGlobalSidebar && <GlobalSidebar />}
|
|
</AppShell.Navbar>
|
|
<AppShell.Main id={MAIN_CONTENT_ID} tabIndex={-1}>
|
|
{isSettingsRoute ? (
|
|
<Container size={900} pb={80}>
|
|
{children}
|
|
</Container>
|
|
) : (
|
|
children
|
|
)}
|
|
</AppShell.Main>
|
|
|
|
{isPageRoute && (
|
|
<AppShell.Aside
|
|
id={ASIDE_PANEL_ID}
|
|
tabIndex={-1}
|
|
className={classes.aside}
|
|
p="sm"
|
|
withBorder={false}
|
|
aria-label={
|
|
asideTab === "comments"
|
|
? t("Comments")
|
|
: asideTab === "toc"
|
|
? t("Table of contents")
|
|
: asideTab === "details"
|
|
? t("Details")
|
|
: undefined
|
|
}
|
|
>
|
|
<Aside />
|
|
</AppShell.Aside>
|
|
)}
|
|
</AppShell>
|
|
{/* Floating AI chat window. Mounted once globally; it is position: fixed
|
|
and self-hides when closed, so its place in the tree is not critical. */}
|
|
<AiChatWindow />
|
|
{/* Global gitmost native bridge: registers listSpaces / listPages /
|
|
createPageWithRecording on window.gitmost so the native host can
|
|
create a page with a recording even when no page editor is open. */}
|
|
<GitmostGlobalBridge />
|
|
</>
|
|
);
|
|
}
|