mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
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:
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user