Portal/editor switcher (#6907)

Adds the portal's top-left app switcher to the editor sidebar, so you
can jump between the two apps from either side.

- Both sidebars render the same shared `AppSwitch` component (sui
dropdown).
- Switching is client-side (no page reload); portal→editor no longer
breaks when `VITE_EDITOR_URL` is unset.
- Editor side is admin-gated (`portalAccess`) and only exists in flavors
that ship the portal — core/desktop stub it out, same seam pattern as
the portal routes.
- Fixes en route: dropdown menu stacking in the editor sidebar, sui
dropdown item button reset, stale `dist-portal` ESLint ignore.
This commit is contained in:
Reece Browne
2026-07-08 12:17:00 +00:00
committed by GitHub
parent 8df49ac053
commit c8f238ae60
14 changed files with 228 additions and 73 deletions
@@ -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;
}
@@ -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 (
<svg
xmlns="http://www.w3.org/2000/svg"
width={14}
height={14}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
);
}
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 (
<Dropdown.Root align="end" className={className}>
<Dropdown.Trigger>
<Button
variant="tertiary"
className="app-switch-btn"
aria-label={t("portal.shell.sidebar.switchApp", "Switch app")}
>
<ChevronDownIcon />
</Button>
</Dropdown.Trigger>
<Dropdown.Menu width="11rem">
{apps.map((app) => (
<Dropdown.Item
key={app.id}
active={current === app.id}
onSelect={app.id === current ? undefined : () => onSwitch(app.id)}
leading={<img className="app-switch-icon" src={mark} alt="" />}
>
{app.label}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown.Root>
);
}
@@ -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;
}
@@ -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;
@@ -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<HTMLDivElement, FileSidebarProps>(
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.
<span
className="file-sidebar-app-switch sidebar-content-fade"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<AppSwitcher />
</span>
)}
</div>
</Tooltip>
@@ -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";
+6
View File
@@ -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);
@@ -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;
}
+11 -4
View File
@@ -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 === "/";
@@ -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 {
@@ -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() {
</span>
</span>
<Dropdown.Root align="end" className="portal-sidebar__app-switch">
<Dropdown.Trigger>
<Button
variant="tertiary"
className="portal-sidebar__app-switch-btn"
aria-label={t("portal.shell.sidebar.switchApp")}
>
<ChevronDownIcon size={14} />
</Button>
</Dropdown.Trigger>
<Dropdown.Menu width="11rem">
<Dropdown.Item
active
leading={
<img
className="portal-sidebar__app-icon"
src={theme === "dark" ? markDark : markLight}
alt=""
/>
}
>
{t("portal.shell.sidebar.appProcessor")}
</Dropdown.Item>
<Dropdown.Item
onSelect={() => {
window.location.href = EDITOR_URL;
}}
leading={
<img
className="portal-sidebar__app-icon"
src={theme === "dark" ? markDark : markLight}
alt=""
/>
}
>
{t("portal.shell.sidebar.appEditor")}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Root>
<AppSwitch
className="portal-sidebar__app-switch"
current="processor"
theme={theme}
onSwitch={goToEditor}
/>
</div>
<nav className="portal-sidebar__nav">
@@ -1,5 +1,6 @@
import { useCallback, useMemo } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
export type ViewId =
| "home"
@@ -55,9 +56,10 @@ export const VIEW_PATHS: Record<ViewId, string> = {
* The portal is mounted as a route-set under this base path inside the editor
* app (see the admin-route seam). VIEW_PATHS stay expressed as logical portal
* paths; this facade adds/strips the base so components keep navigating by
* ViewId without knowing where the portal is mounted.
* ViewId without knowing where the portal is mounted. The constant lives in
* core so portal-free build flavors can reference the mount point too.
*/
export const PORTAL_BASENAME = "/portal";
export { PORTAL_BASENAME };
/** Logical view path -> full app path (e.g. "/users" -> "/portal/users"). */
export function toPortalPath(viewPath: string): string {
@@ -0,0 +1,28 @@
import { useNavigate } from "react-router-dom";
import { useMantineColorScheme } from "@mantine/core";
import { useAuth } from "@app/auth/context";
import { AppSwitch } from "@app/components/shared/AppSwitch";
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
/**
* Sidebar app switcher between the editor and the admin portal. Both are
* route-sets of one SPA (the portal mounts at PORTAL_BASENAME), so switching
* is a client-side navigation. Hidden for users without portal access — they
* have nowhere to switch to. Renders the same AppSwitch element as the
* portal's sidebar.
*/
export function AppSwitcher() {
const { portalAccess } = useAuth();
const navigate = useNavigate();
const { colorScheme } = useMantineColorScheme();
if (!portalAccess) return null;
return (
<AppSwitch
current="editor"
theme={colorScheme === "dark" ? "dark" : "light"}
onSwitch={() => navigate(PORTAL_BASENAME)}
/>
);
}
+1
View File
@@ -72,6 +72,7 @@ export default defineConfig(
// Everything that contains 3rd party code that we don't want to lint
ignores: [
"dist",
"dist-portal",
"node_modules",
"playwright-report",
"storybook-static",