diff --git a/frontend/editor/src/core/components/shared/AppSwitch.css b/frontend/editor/src/core/components/shared/AppSwitch.css new file mode 100644 index 0000000000..c40080b1e9 --- /dev/null +++ b/frontend/editor/src/core/components/shared/AppSwitch.css @@ -0,0 +1,27 @@ +/* Trigger: bare square icon button that blends into either sidebar's header. */ +.app-switch-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + border: none; + background: none; + cursor: pointer; + border-radius: var(--radius-sm); + color: var(--color-text-4); + transition: + background var(--motion-fast), + color var(--motion-fast); +} + +.app-switch-btn:hover { + background: var(--color-bg-hover); + color: var(--color-text-2); +} + +.app-switch-icon { + width: 1rem; + height: 1.0625rem; + display: block; +} diff --git a/frontend/editor/src/core/components/shared/AppSwitch.tsx b/frontend/editor/src/core/components/shared/AppSwitch.tsx new file mode 100644 index 0000000000..b713e75e7c --- /dev/null +++ b/frontend/editor/src/core/components/shared/AppSwitch.tsx @@ -0,0 +1,82 @@ +import { useTranslation } from "react-i18next"; +import { Button, Dropdown } from "@app/ui"; +import markLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg"; +import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg"; +import "@app/components/shared/AppSwitch.css"; + +export type AppSwitchTarget = "editor" | "processor"; + +function ChevronDownIcon() { + return ( + + + + ); +} + +interface AppSwitchProps { + /** The app this switcher is rendered in (shown as active in the menu). */ + current: AppSwitchTarget; + /** Resolved color scheme; picks the brand mark for the menu items. */ + theme: "light" | "dark"; + /** Invoked with the selected app; only called for apps other than `current`. */ + onSwitch: (app: AppSwitchTarget) => void; + className?: string; +} + +/** + * The editor ⇄ processor app switcher (chevron button → app menu). The editor + * and portal sidebars render this same element so the two apps present one + * identical switcher; each host supplies its own theme source and navigation. + */ +export function AppSwitch({ + current, + theme, + onSwitch, + className, +}: AppSwitchProps) { + const { t } = useTranslation(); + const mark = theme === "dark" ? markDark : markLight; + const apps: Array<{ id: AppSwitchTarget; label: string }> = [ + { + id: "processor", + label: t("portal.shell.sidebar.appProcessor", "Processor"), + }, + { id: "editor", label: t("portal.shell.sidebar.appEditor", "Editor") }, + ]; + return ( + + + + + + {apps.map((app) => ( + onSwitch(app.id)} + leading={} + > + {app.label} + + ))} + + + ); +} diff --git a/frontend/editor/src/core/components/shared/AppSwitcher.tsx b/frontend/editor/src/core/components/shared/AppSwitcher.tsx new file mode 100644 index 0000000000..298a46e1ac --- /dev/null +++ b/frontend/editor/src/core/components/shared/AppSwitcher.tsx @@ -0,0 +1,8 @@ +/** + * Core stub for the sidebar app switcher. Builds that bundle the admin portal + * (proprietary/saas) shadow this with a real switcher; core has no portal, so + * there is nothing to switch to. + */ +export function AppSwitcher() { + return null; +} diff --git a/frontend/editor/src/core/components/shared/FileSidebar.css b/frontend/editor/src/core/components/shared/FileSidebar.css index d06982179e..0b495d723b 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.css +++ b/frontend/editor/src/core/components/shared/FileSidebar.css @@ -102,6 +102,18 @@ flex-shrink: 0; } +/* App switcher (portal builds only) sits at the far end of the header row. + The content-fade animation makes this span a stacking context, which would + trap the menu's z-index below later sidebar rows — elevate the span so the + open menu paints above them. */ +.file-sidebar-app-switch { + margin-inline-start: auto; + display: flex; + align-items: center; + position: relative; + z-index: var(--z-dropdown); +} + /* ---- Search row ---- */ .file-sidebar-search-row { display: flex; diff --git a/frontend/editor/src/core/components/shared/FileSidebar.tsx b/frontend/editor/src/core/components/shared/FileSidebar.tsx index db3820350e..f6fb7f0ce9 100644 --- a/frontend/editor/src/core/components/shared/FileSidebar.tsx +++ b/frontend/editor/src/core/components/shared/FileSidebar.tsx @@ -29,6 +29,7 @@ import { import { accountService } from "@app/services/accountService"; import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons"; import { Wordmark } from "@app/components/shared/Wordmark"; +import { AppSwitcher } from "@app/components/shared/AppSwitcher"; import type { StirlingFileStub } from "@app/types/fileContext"; import MenuIcon from "@mui/icons-material/Menu"; import SearchIcon from "@mui/icons-material/Search"; @@ -679,6 +680,17 @@ const FileSidebar = forwardRef( className="file-sidebar-brand-text sidebar-content-fade" /> )} + {!collapsed && ( + // The header row itself toggles collapse; stop the switcher's + // clicks and key presses from reaching it. + e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + > + + + )} diff --git a/frontend/editor/src/core/routes/portalBasename.ts b/frontend/editor/src/core/routes/portalBasename.ts new file mode 100644 index 0000000000..70f05f6a27 --- /dev/null +++ b/frontend/editor/src/core/routes/portalBasename.ts @@ -0,0 +1,7 @@ +/** + * Base path where the admin portal route-set mounts inside the editor app + * (see adminRouteExtensions). Lives in core so any layer can reference the + * mount point without importing portal code — build flavors that ship no + * portal (core, desktop, prototypes) must never resolve @portal. + */ +export const PORTAL_BASENAME = "/portal"; diff --git a/frontend/editor/src/core/ui/Dropdown.css b/frontend/editor/src/core/ui/Dropdown.css index c96aec1672..736371e6ae 100644 --- a/frontend/editor/src/core/ui/Dropdown.css +++ b/frontend/editor/src/core/ui/Dropdown.css @@ -36,6 +36,12 @@ font-size: 0.8125rem; color: var(--color-text-2); border-radius: var(--radius-sm); + /* Explicit button reset: hosts without a global button reset (e.g. the + editor) would otherwise show the UA's buttonface background and border. */ + background: none; + border: none; + font-family: inherit; + cursor: pointer; transition: background var(--motion-fast), color var(--motion-fast); diff --git a/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx b/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx new file mode 100644 index 0000000000..9d7c2c095e --- /dev/null +++ b/frontend/editor/src/desktop/components/shared/AppSwitcher.tsx @@ -0,0 +1,9 @@ +/** + * Desktop inherits proprietary's layers but does not ship the portal (see + * desktop/routes/adminRouteExtensions), so shadow the switcher back to empty — + * otherwise the desktop bundle would reference @portal via the proprietary + * switcher's imports. + */ +export function AppSwitcher() { + return null; +} diff --git a/frontend/editor/src/portal/auth/editorUrl.ts b/frontend/editor/src/portal/auth/editorUrl.ts index 43eb466cb7..8f60292331 100644 --- a/frontend/editor/src/portal/auth/editorUrl.ts +++ b/frontend/editor/src/portal/auth/editorUrl.ts @@ -3,8 +3,15 @@ * bouncing non-admins out). * * Sourced from VITE_EDITOR_URL so it's configurable per deploy rather than - * hardcoded. The committed default is "/" (production serves the editor at the - * root on the same origin as the portal). For dev cross-app navigation to a - * separately-running editor, set VITE_EDITOR_URL in editor/.env.local. + * hardcoded, falling back to "/" (the editor serves the portal at /portal on + * the same origin, so the root is the editor). For dev cross-app navigation to + * a separately-running editor, set VITE_EDITOR_URL in editor/.env.local. */ -export const EDITOR_URL = import.meta.env.VITE_EDITOR_URL; +export const EDITOR_URL = import.meta.env.VITE_EDITOR_URL || "/"; + +/** + * When the editor is at the root of this origin, the portal and editor are + * route-sets of one SPA — switching apps can be a client-side navigation + * instead of a full page load. + */ +export const EDITOR_IS_SAME_APP = EDITOR_URL === "/"; diff --git a/frontend/editor/src/portal/components/Sidebar.css b/frontend/editor/src/portal/components/Sidebar.css index c12b6debb0..fcc8c7e6a8 100644 --- a/frontend/editor/src/portal/components/Sidebar.css +++ b/frontend/editor/src/portal/components/Sidebar.css @@ -40,35 +40,12 @@ white-space: nowrap; } -/* App switcher (down-arrow → Portal / Editor) */ +/* App switcher (down-arrow → Portal / Editor); button and menu styling live + with the shared AppSwitch element. */ .portal-sidebar__app-switch { margin-left: auto; display: flex; } -.portal-sidebar__app-switch-btn { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.25rem; - height: 1.25rem; - border: none; - background: none; - cursor: pointer; - border-radius: var(--radius-sm); - color: var(--color-text-4); - transition: - background var(--motion-fast), - color var(--motion-fast); -} -.portal-sidebar__app-switch-btn:hover { - background: var(--color-bg-hover); - color: var(--color-text-2); -} -.portal-sidebar__app-icon { - width: 1rem; - height: 1.0625rem; - display: block; -} /* Nav body */ .portal-sidebar__nav { diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index 486611248b..fed5d7f68e 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -1,5 +1,7 @@ -import { Button, Dropdown, NavItem } from "@app/ui"; +import { NavItem } from "@app/ui"; +import { AppSwitch } from "@app/components/shared/AppSwitch"; import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; import { useView, type ViewId } from "@portal/contexts/ViewContext"; import { useTier } from "@portal/contexts/TierContext"; import { useTheme } from "@portal/contexts/ThemeContext"; @@ -7,7 +9,7 @@ import { useUI } from "@portal/contexts/UIContext"; import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; import { useAsync } from "@portal/hooks/useAsync"; import { fetchHomeKpis, type KpiEntry } from "@portal/api/home"; -import { EDITOR_URL } from "@portal/auth/editorUrl"; +import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl"; import markLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg"; import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg"; import { @@ -22,7 +24,6 @@ import { UsageIcon, DocsIcon, SettingsIcon, - ChevronDownIcon, } from "@portal/components/icons"; import "@portal/components/Sidebar.css"; @@ -114,6 +115,15 @@ export function Sidebar() { const { theme } = useTheme(); const { openSettings } = useUI(); const { t } = useTranslation(); + const navigate = useNavigate(); + + // Editor and portal are one SPA when the editor serves this origin's root, so + // the switch stays client-side; an absolute EDITOR_URL (dev cross-app setup) + // needs a full page load. + const goToEditor = () => { + if (EDITOR_IS_SAME_APP) navigate("/"); + else window.location.href = EDITOR_URL; + }; // Procurement is no longer a nav tab — it lives on Home as the deal-status hero and expands into // a takeover modal (matching the marketing prototype). @@ -148,45 +158,12 @@ export function Sidebar() { - - - - - - - } - > - {t("portal.shell.sidebar.appProcessor")} - - { - window.location.href = EDITOR_URL; - }} - leading={ - - } - > - {t("portal.shell.sidebar.appEditor")} - - - +