mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Quick access bar and old school sidebars (#7695)
This commit is contained in:
@@ -4141,7 +4141,6 @@ mobileShort = "Mobile"
|
|||||||
mobileUpload = "Mobile Upload"
|
mobileUpload = "Mobile Upload"
|
||||||
mobileUploadNotAvailable = "Mobile upload not enabled"
|
mobileUploadNotAvailable = "Mobile upload not enabled"
|
||||||
moreOptions = "More options"
|
moreOptions = "More options"
|
||||||
myFiles = "My Files"
|
|
||||||
nextFile = "Next file"
|
nextFile = "Next file"
|
||||||
noFiles = "No files available"
|
noFiles = "No files available"
|
||||||
noFilesFound = "No files found matching your search"
|
noFilesFound = "No files found matching your search"
|
||||||
@@ -4242,9 +4241,9 @@ duplicateFailed = "Could not duplicate file"
|
|||||||
expand = "Expand sidebar"
|
expand = "Expand sidebar"
|
||||||
googleDrive = "Google Drive"
|
googleDrive = "Google Drive"
|
||||||
googleDriveDisabled = "Google Drive is not configured"
|
googleDriveDisabled = "Google Drive is not configured"
|
||||||
leaveMyFiles = "Leave My Files"
|
leaveMyFiles = "Leave File library"
|
||||||
library = "PDF Library"
|
library = "PDF Library"
|
||||||
myFiles = "My Files"
|
myFiles = "File library"
|
||||||
noFiles = "No files yet"
|
noFiles = "No files yet"
|
||||||
openFileManager = "Browse all files & folders"
|
openFileManager = "Browse all files & folders"
|
||||||
openFromComputer = "Open from computer"
|
openFromComputer = "Open from computer"
|
||||||
@@ -4287,7 +4286,7 @@ addToWorkspaceCount = "Add {{count}} to workspace"
|
|||||||
allFiles = "All files"
|
allFiles = "All files"
|
||||||
back = "Back"
|
back = "Back"
|
||||||
backToFolder = "Back to {{folder}}"
|
backToFolder = "Back to {{folder}}"
|
||||||
backToMyFiles = "Back to My Files"
|
backToMyFiles = "Back to File library"
|
||||||
breadcrumbs = "Folder path"
|
breadcrumbs = "Folder path"
|
||||||
bulkActions = "Actions"
|
bulkActions = "Actions"
|
||||||
cancel = "Cancel"
|
cancel = "Cancel"
|
||||||
@@ -4339,7 +4338,6 @@ localFoldersUnavailable = "Folders are cloud-only - save a file to the cloud to
|
|||||||
moveSkippedRemote_one = "{{count}} file couldn't be moved on the server (no permission or already deleted)."
|
moveSkippedRemote_one = "{{count}} file couldn't be moved on the server (no permission or already deleted)."
|
||||||
moveSkippedRemote_other = "{{count}} files couldn't be moved on the server (no permission or already deleted)."
|
moveSkippedRemote_other = "{{count}} files couldn't be moved on the server (no permission or already deleted)."
|
||||||
moveTo = "Move to…"
|
moveTo = "Move to…"
|
||||||
myFiles = "My Files"
|
|
||||||
newFolder = "New folder"
|
newFolder = "New folder"
|
||||||
newFolderStorageDisabled = "Server folder storage isn't enabled. Ask your admin to turn it on."
|
newFolderStorageDisabled = "Server folder storage isn't enabled. Ask your admin to turn it on."
|
||||||
newFolderTabUnavailable = "Switch to All or Cloud to create folders."
|
newFolderTabUnavailable = "Switch to All or Cloud to create folders."
|
||||||
@@ -9173,7 +9171,6 @@ appEditor = "Editor"
|
|||||||
appProcessor = "Processor"
|
appProcessor = "Processor"
|
||||||
linkAccount = "Link Stirling account"
|
linkAccount = "Link Stirling account"
|
||||||
primaryNav = "Primary navigation"
|
primaryNav = "Primary navigation"
|
||||||
switchApp = "Switch app"
|
|
||||||
|
|
||||||
[portal.shell.topbar]
|
[portal.shell.topbar]
|
||||||
closeNav = "Close navigation"
|
closeNav = "Close navigation"
|
||||||
@@ -9652,6 +9649,16 @@ automate = "Automate"
|
|||||||
config = "Config"
|
config = "Config"
|
||||||
files = "Files"
|
files = "Files"
|
||||||
|
|
||||||
|
[quickNav]
|
||||||
|
editor = "Editor"
|
||||||
|
home = "Stirling"
|
||||||
|
invite = "Invite"
|
||||||
|
landmark = "Quick navigation"
|
||||||
|
noProcessorAccess = "Ask an admin for processor access"
|
||||||
|
notifications = "Notifications"
|
||||||
|
processor = "Processor"
|
||||||
|
reader = "Reader"
|
||||||
|
|
||||||
[read]
|
[read]
|
||||||
tags = "view,open,display,read,viewer,PDF viewer,PDF reader,open PDF,view PDF,display PDF,preview,browse"
|
tags = "view,open,display,read,viewer,PDF viewer,PDF reader,open PDF,view PDF,display PDF,preview,browse"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Suspense, lazy } from "react";
|
import { Suspense, lazy } from "react";
|
||||||
import { Routes, Route } from "react-router-dom";
|
import { Routes, Route } from "react-router-dom";
|
||||||
import { AppProviders } from "@app/components/AppProviders";
|
import { AppProviders } from "@app/components/AppProviders";
|
||||||
|
import { AppFrame } from "@app/components/layout/AppFrame";
|
||||||
import { AppLayout } from "@app/components/AppLayout";
|
import { AppLayout } from "@app/components/AppLayout";
|
||||||
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
|
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
|
||||||
import { ThemeProvider } from "@app/components/shared/ThemeProvider";
|
import { ThemeProvider } from "@app/components/shared/ThemeProvider";
|
||||||
@@ -53,18 +54,21 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* All other routes need AppProviders for backend integration */}
|
{/* The app, under a shared frame so the rail renders once outside it. */}
|
||||||
<Route
|
<Route element={<AppFrame />}>
|
||||||
path="*"
|
{/* All other routes need AppProviders for backend integration */}
|
||||||
element={
|
<Route
|
||||||
<AppProviders>
|
path="*"
|
||||||
<AppLayout>
|
element={
|
||||||
<HomePage />
|
<AppProviders>
|
||||||
<Onboarding />
|
<AppLayout>
|
||||||
</AppLayout>
|
<HomePage />
|
||||||
</AppProviders>
|
<Onboarding />
|
||||||
}
|
</AppLayout>
|
||||||
/>
|
</AppProviders>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
|||||||
mb="xs"
|
mb="xs"
|
||||||
style={{ paddingLeft: "1rem" }}
|
style={{ paddingLeft: "1rem" }}
|
||||||
>
|
>
|
||||||
{t("fileManager.myFiles", "My Files")}
|
{t("fileSidebar.myFiles", "File library")}
|
||||||
</Text>
|
</Text>
|
||||||
{buttons}
|
{buttons}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export function FolderTreePanel({ active }: FolderTreePanelProps) {
|
|||||||
<div className="folder-tree-panel-inner">
|
<div className="folder-tree-panel-inner">
|
||||||
<div className="folder-tree-panel-header">
|
<div className="folder-tree-panel-header">
|
||||||
<span className="folder-tree-panel-title">
|
<span className="folder-tree-panel-title">
|
||||||
{t("filesPage.myFiles", "My Files")}
|
{t("fileSidebar.myFiles", "File library")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Stores the route the user came from when they open files into the
|
* Stores the route the user came from when they open files into the
|
||||||
* workbench from My Files. Lets the workbench show a "Back to My Files"
|
* workbench from the file library. Lets the workbench show a "Back to File library"
|
||||||
* affordance and return to the exact folder they were browsing.
|
* affordance and return to the exact folder they were browsing.
|
||||||
*
|
*
|
||||||
* Persisted in sessionStorage so a hard reload keeps the return path
|
* Persisted in sessionStorage so a hard reload keeps the return path
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/* ========== APP FRAME ========== */
|
||||||
|
/* The rail's column, then whichever app is mounted, so a switch changes only the app. */
|
||||||
|
.app-frame {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
height: 100dvh; /* track mobile browser chrome */
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--c-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* min-width: 0 so the app shrinks instead of forcing the frame past the window. */
|
||||||
|
.app-frame__content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The rail hides itself below the mobile breakpoint - see QuickNavRailContainer.css. */
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
|
||||||
|
import { QuickNavHostProvider } from "@app/contexts/QuickNavHostContext";
|
||||||
|
import { QuickNavRailHost } from "@app/components/shared/quickNav/QuickNavRailHost";
|
||||||
|
import "@app/components/layout/AppFrame.css";
|
||||||
|
|
||||||
|
/** The rail renders once outside both apps; Suspense sits inside it, not above. */
|
||||||
|
export function AppFrame() {
|
||||||
|
return (
|
||||||
|
<QuickNavHostProvider>
|
||||||
|
<div className="app-frame">
|
||||||
|
<QuickNavRailHost />
|
||||||
|
<div className="app-frame__content">
|
||||||
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</QuickNavHostProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { useSuppressQuickNavRail } from "@app/contexts/QuickNavHostContext";
|
||||||
|
|
||||||
|
/** Pages that aren't the app: inside the frame for its providers, but with no rail. */
|
||||||
|
export function NoAppChrome() {
|
||||||
|
useSuppressQuickNavRail();
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
@@ -12,10 +12,8 @@
|
|||||||
.workbenchBarReopenTab {
|
.workbenchBarReopenTab {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
/* Right-align with the retract handle inside the bar: the bar's right
|
/* Aligns with the retract handle: 8px bar padding plus its own 6px inset. */
|
||||||
margin (--nav-gutter) + 1px border + 8px bar padding + the handle's own
|
right: 14px;
|
||||||
6px inset. */
|
|
||||||
right: calc(var(--nav-gutter) + 15px);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, Suspense, lazy } from "react";
|
import { useState, useEffect, useRef, Suspense, lazy } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||||
import { Box, Loader, Center, Stack, Text } from "@mantine/core";
|
import { Box, Loader, Center, Stack, Text } from "@mantine/core";
|
||||||
@@ -15,6 +15,7 @@ import { VIEWER_SUPPORTED_EXTENSIONS } from "@app/utils/fileUtils";
|
|||||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||||
import { useSigningOverlay } from "@app/contexts/SigningOverlayContext";
|
import { useSigningOverlay } from "@app/contexts/SigningOverlayContext";
|
||||||
import { useCookieConsent } from "@app/hooks/useCookieConsent";
|
import { useCookieConsent } from "@app/hooks/useCookieConsent";
|
||||||
|
import { useIsPhone } from "@app/hooks/useIsMobile";
|
||||||
import styles from "@app/components/layout/Workbench.module.css";
|
import styles from "@app/components/layout/Workbench.module.css";
|
||||||
|
|
||||||
import WorkbenchBar from "@app/components/shared/WorkbenchBar";
|
import WorkbenchBar from "@app/components/shared/WorkbenchBar";
|
||||||
@@ -58,10 +59,13 @@ export default function Workbench() {
|
|||||||
setPageEditorFunctions,
|
setPageEditorFunctions,
|
||||||
setSidebarsVisible,
|
setSidebarsVisible,
|
||||||
customWorkbenchViews,
|
customWorkbenchViews,
|
||||||
|
readerMode,
|
||||||
} = useToolWorkflow();
|
} = useToolWorkflow();
|
||||||
|
|
||||||
const { handleToolSelect } = useToolWorkflow();
|
const { handleToolSelect } = useToolWorkflow();
|
||||||
const { overlay: signingOverlay } = useSigningOverlay();
|
const { overlay: signingOverlay } = useSigningOverlay();
|
||||||
|
// Below this width the rail, and the bell it carries, is gone.
|
||||||
|
const isPhone = useIsPhone();
|
||||||
|
|
||||||
// Get navigation state - this is the source of truth
|
// Get navigation state - this is the source of truth
|
||||||
const { selectedTool: selectedToolId } = useNavigationState();
|
const { selectedTool: selectedToolId } = useNavigationState();
|
||||||
@@ -92,8 +96,20 @@ export default function Workbench() {
|
|||||||
!isBaseWorkbench(currentView) ||
|
!isBaseWorkbench(currentView) ||
|
||||||
// Shared signing drives the viewer from the sidebar with no file in context.
|
// Shared signing drives the viewer from the sidebar with no file in context.
|
||||||
(currentView === "viewer" && !!signingOverlay?.file);
|
(currentView === "viewer" && !!signingOverlay?.file);
|
||||||
const showWorkbenchBar = topControlsAvailable && hasWorkbenchContent;
|
// Reading hides the bar; the rail's Reader entry is the way back.
|
||||||
const showFloatingSearch = topControlsAvailable && !hasWorkbenchContent;
|
const showWorkbenchBar =
|
||||||
|
topControlsAvailable && hasWorkbenchContent && !readerMode;
|
||||||
|
const showFloatingSearch =
|
||||||
|
topControlsAvailable && !hasWorkbenchContent && !readerMode;
|
||||||
|
|
||||||
|
// On the transition, so reading sets the toolbar's start state without locking it.
|
||||||
|
const prevReaderModeRef = useRef(readerMode);
|
||||||
|
useEffect(() => {
|
||||||
|
if (readerMode !== prevReaderModeRef.current) {
|
||||||
|
setViewerToolbarCollapsed(readerMode);
|
||||||
|
prevReaderModeRef.current = readerMode;
|
||||||
|
}
|
||||||
|
}, [readerMode]);
|
||||||
|
|
||||||
const handlePreviewClose = () => {
|
const handlePreviewClose = () => {
|
||||||
setPreviewFile(null);
|
setPreviewFile(null);
|
||||||
@@ -126,7 +142,7 @@ export default function Workbench() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The "My Files" workbench is available regardless of whether files are
|
// The file-library workbench is available regardless of whether files are
|
||||||
// currently loaded into the workbench - it lives on top of the IDB store.
|
// currently loaded into the workbench - it lives on top of the IDB store.
|
||||||
if (currentView === "myFiles") {
|
if (currentView === "myFiles") {
|
||||||
return <FileManagerView />;
|
return <FileManagerView />;
|
||||||
@@ -249,10 +265,8 @@ export default function Workbench() {
|
|||||||
data-tour="workbench"
|
data-tour="workbench"
|
||||||
style={{ backgroundColor: "var(--c-bg)", minWidth: 0 }}
|
style={{ backgroundColor: "var(--c-bg)", minWidth: 0 }}
|
||||||
>
|
>
|
||||||
{/* The bell normally rides in the workbench bar. Wherever that bar is not shown - My Files,
|
{/* Phone only: above that the rail carries the bell, and here no bar does. */}
|
||||||
an empty workbench, a custom view without top controls - it gets its own corner, rather
|
{isPhone && !showWorkbenchBar && (
|
||||||
than those being the places a user cannot see that something of theirs failed. */}
|
|
||||||
{!showWorkbenchBar && (
|
|
||||||
<div style={{ position: "absolute", top: 12, right: 12, zIndex: 20 }}>
|
<div style={{ position: "absolute", top: 12, right: 12, zIndex: 20 }}>
|
||||||
<NotificationBell />
|
<NotificationBell />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/* ========== WORKSPACE FRAME ========== */
|
||||||
|
/* Rail and sidebar side by side, full height. Shared by both apps. */
|
||||||
|
.workspace-frame {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background-color: var(--c-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On mobile the sidebar is a fixed drawer, so the frame stops laying out. */
|
||||||
|
@media (max-width: 48rem) {
|
||||||
|
.workspace-frame {
|
||||||
|
display: block;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
padding: var(--sp-2, 0.5rem);
|
padding: var(--sp-2, 0.5rem);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-md, 0.375rem);
|
border-radius: var(--radius-md);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--c-text-muted);
|
color: var(--c-text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -48,6 +48,12 @@
|
|||||||
box-shadow: 0 10px 30px rgb(0 0 0 / 25%);
|
box-shadow: 0 10px 30px rgb(0 0 0 / 25%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The rail's bell is at the foot of a full-height column, so its panel rises beside it. */
|
||||||
|
.notification-bell__panel--rail {
|
||||||
|
inset-inline-start: calc(var(--nav-rail-w) + var(--nav-gutter));
|
||||||
|
inset-block-end: var(--nav-gutter);
|
||||||
|
}
|
||||||
|
|
||||||
.notification-bell__heading {
|
.notification-bell__heading {
|
||||||
margin: 0 0 var(--sp-2, 0.5rem);
|
margin: 0 0 var(--sp-2, 0.5rem);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
|||||||
@@ -1,27 +1,15 @@
|
|||||||
import {
|
import { useLayoutEffect, useRef, useState } from "react";
|
||||||
Fragment,
|
|
||||||
useEffect,
|
|
||||||
useId,
|
|
||||||
useLayoutEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { BellIcon, Button } from "@app/ui";
|
import { BellIcon, Button } from "@app/ui";
|
||||||
import DividerWithText from "@app/components/shared/DividerWithText";
|
|
||||||
import { useNotifications } from "@app/hooks/useNotifications";
|
import { useNotifications } from "@app/hooks/useNotifications";
|
||||||
import { useNotificationActions } from "@app/components/notifications/notificationActions";
|
import { useNotificationActions } from "@app/components/notifications/notificationActions";
|
||||||
import { NotificationItem } from "@app/components/notifications/NotificationItem";
|
import { NotificationPanel } from "@app/components/notifications/NotificationPanel";
|
||||||
import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable";
|
import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable";
|
||||||
import "@app/components/notifications/NotificationBell.css";
|
import "@app/components/notifications/NotificationBell.css";
|
||||||
|
|
||||||
/**
|
/** For the narrow layouts where the rail, which carries the bell, is off screen. */
|
||||||
* Renders whatever the server sends without knowing which subsystem produced it or what its actions
|
|
||||||
* mean, so a new source or failure kind needs no change here. In core because both shells mount it.
|
|
||||||
*/
|
|
||||||
export function NotificationBell() {
|
export function NotificationBell() {
|
||||||
// A build with no notifications API gets no bell at all, rather than one that polls a
|
// No API means no bell at all, rather than one polling an endpoint that isn't there.
|
||||||
// nonexistent endpoint forever to show nothing.
|
|
||||||
const available = useNotificationsAvailable();
|
const available = useNotificationsAvailable();
|
||||||
if (!available) return null;
|
if (!available) return null;
|
||||||
return <MountedNotificationBell />;
|
return <MountedNotificationBell />;
|
||||||
@@ -29,14 +17,10 @@ export function NotificationBell() {
|
|||||||
|
|
||||||
function MountedNotificationBell() {
|
function MountedNotificationBell() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { notifications, unreadCount, documentStateFor, markAllSeen } =
|
const { unreadCount } = useNotifications();
|
||||||
useNotifications();
|
|
||||||
const registry = useNotificationActions();
|
const registry = useNotificationActions();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const container = useRef<HTMLDivElement>(null);
|
const container = useRef<HTMLDivElement>(null);
|
||||||
const headingId = useId();
|
|
||||||
// Where the new ones stop, frozen when the panel opens (opening marks everything read).
|
|
||||||
const [firstSeenId, setFirstSeenId] = useState<string | null>(null);
|
|
||||||
// Viewport-fixed, because the workbench bar clips its own overflow.
|
// Viewport-fixed, because the workbench bar clips its own overflow.
|
||||||
const [anchor, setAnchor] = useState<{ top: number; right: number } | null>(
|
const [anchor, setAnchor] = useState<{ top: number; right: number } | null>(
|
||||||
null,
|
null,
|
||||||
@@ -61,54 +45,17 @@ function MountedNotificationBell() {
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
// Opening marks them read, not closing: waiting would leave the badge lit while they read.
|
|
||||||
const toggle = () => {
|
|
||||||
setOpen((wasOpen) => {
|
|
||||||
if (!wasOpen) {
|
|
||||||
// Before marking, or there is nothing left to read.
|
|
||||||
setFirstSeenId(notifications[unreadCount]?.id ?? null);
|
|
||||||
markAllSeen();
|
|
||||||
}
|
|
||||||
return !wasOpen;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How many count as new. No boundary id means all of them were; one that has since left the list
|
|
||||||
* leaves nothing to divide on, so it reads as none rather than guessing at a row.
|
|
||||||
*/
|
|
||||||
const boundaryIndex = firstSeenId
|
|
||||||
? notifications.findIndex((notification) => notification.id === firstSeenId)
|
|
||||||
: notifications.length;
|
|
||||||
const dividedAt = Math.max(0, boundaryIndex);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const closeOnOutside = (event: MouseEvent) => {
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
if (!container.current?.contains(target)) setOpen(false);
|
|
||||||
};
|
|
||||||
const closeOnEscape = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Escape") setOpen(false);
|
|
||||||
};
|
|
||||||
document.addEventListener("mousedown", closeOnOutside);
|
|
||||||
document.addEventListener("keydown", closeOnEscape);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("mousedown", closeOnOutside);
|
|
||||||
document.removeEventListener("keydown", closeOnEscape);
|
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="notification-bell" ref={container}>
|
<div className="notification-bell" ref={container}>
|
||||||
<Button
|
<Button
|
||||||
variant="quiet"
|
variant="quiet"
|
||||||
size="md"
|
size="md"
|
||||||
shape="circle"
|
|
||||||
className="notification-bell__trigger"
|
className="notification-bell__trigger"
|
||||||
|
// Read by the panel's outside-click handler.
|
||||||
|
data-notifications-trigger
|
||||||
aria-label={t("notifications.open", "Notifications")}
|
aria-label={t("notifications.open", "Notifications")}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
onClick={toggle}
|
onClick={() => setOpen((wasOpen) => !wasOpen)}
|
||||||
>
|
>
|
||||||
<BellIcon />
|
<BellIcon />
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
@@ -119,53 +66,11 @@ function MountedNotificationBell() {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<NotificationPanel
|
||||||
className="notification-bell__panel"
|
onClose={() => setOpen(false)}
|
||||||
role="dialog"
|
registry={registry}
|
||||||
// Named by its own heading: a dialog with no accessible name is announced as just "dialog".
|
|
||||||
aria-labelledby={headingId}
|
|
||||||
style={anchor ? { top: anchor.top, right: anchor.right } : undefined}
|
style={anchor ? { top: anchor.top, right: anchor.right } : undefined}
|
||||||
>
|
/>
|
||||||
<h2 className="notification-bell__heading" id={headingId}>
|
|
||||||
{t("notifications.title", "Notifications")}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{notifications.length === 0 ? (
|
|
||||||
<p className="notification-bell__empty">
|
|
||||||
{t("notifications.empty", "Nothing to report.")}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<ul className="notification-bell__list">
|
|
||||||
{notifications.map((notification, index) => (
|
|
||||||
<Fragment key={notification.id}>
|
|
||||||
{index === 0 && dividedAt > 0 && (
|
|
||||||
<li aria-hidden>
|
|
||||||
<DividerWithText
|
|
||||||
text={t("notifications.section.new", "New")}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
{/* Only with something on both sides: a lone "Earlier" over everything says
|
|
||||||
nothing the empty badge has not. */}
|
|
||||||
{index === dividedAt && dividedAt > 0 && (
|
|
||||||
<li aria-hidden>
|
|
||||||
<DividerWithText
|
|
||||||
text={t("notifications.section.earlier", "Earlier")}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
<NotificationItem
|
|
||||||
notification={notification}
|
|
||||||
unread={index < dividedAt}
|
|
||||||
documentState={documentStateFor(notification)}
|
|
||||||
registry={registry}
|
|
||||||
onDismissPanel={() => setOpen(false)}
|
|
||||||
/>
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { Fragment, useEffect, useId, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import DividerWithText from "@app/components/shared/DividerWithText";
|
||||||
|
import { useNotifications } from "@app/hooks/useNotifications";
|
||||||
|
import type { ClientActionRegistry } from "@app/components/notifications/notificationActions";
|
||||||
|
import { NotificationItem } from "@app/components/notifications/NotificationItem";
|
||||||
|
import "@app/components/notifications/NotificationBell.css";
|
||||||
|
|
||||||
|
/** Named so a trigger in another tree can point at it with aria-controls. */
|
||||||
|
export const NOTIFICATIONS_PANEL_ID = "quick-nav-notifications-panel";
|
||||||
|
|
||||||
|
export interface NotificationPanelProps {
|
||||||
|
onClose: () => void;
|
||||||
|
id?: string;
|
||||||
|
/** Passed in: its document handover has to run whether the panel is open or not. */
|
||||||
|
registry: ClientActionRegistry;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mounted only while open, since mounting is what marks everything read. */
|
||||||
|
export function NotificationPanel({
|
||||||
|
onClose,
|
||||||
|
registry,
|
||||||
|
id,
|
||||||
|
style,
|
||||||
|
className,
|
||||||
|
}: NotificationPanelProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { notifications, unreadCount, documentStateFor, markAllSeen } =
|
||||||
|
useNotifications();
|
||||||
|
const panel = useRef<HTMLDivElement>(null);
|
||||||
|
const headingId = useId();
|
||||||
|
// Frozen on open, since opening marks them all read.
|
||||||
|
const [firstSeenId, setFirstSeenId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// On mount, not on close: waiting leaves the badge lit while they read.
|
||||||
|
const marked = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (marked.current) return;
|
||||||
|
marked.current = true;
|
||||||
|
// Before marking, or there is nothing left to divide on.
|
||||||
|
setFirstSeenId(notifications[unreadCount]?.id ?? null);
|
||||||
|
markAllSeen();
|
||||||
|
}, [notifications, unreadCount, markAllSeen]);
|
||||||
|
|
||||||
|
// No boundary means all were new; one that has left the list means none.
|
||||||
|
const boundaryIndex = firstSeenId
|
||||||
|
? notifications.findIndex((notification) => notification.id === firstSeenId)
|
||||||
|
: notifications.length;
|
||||||
|
const dividedAt = Math.max(0, boundaryIndex);
|
||||||
|
|
||||||
|
// Focus goes back to the opener only if it is still inside the panel on close.
|
||||||
|
useEffect(() => {
|
||||||
|
const opener = document.activeElement as HTMLElement | null;
|
||||||
|
panel.current?.focus();
|
||||||
|
return () => {
|
||||||
|
if (panel.current?.contains(document.activeElement)) opener?.focus();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const closeOnOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (panel.current?.contains(target)) return;
|
||||||
|
// A trigger closes this itself; counting it as outside would reopen it.
|
||||||
|
if (target.closest?.("[data-notifications-trigger]")) return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", closeOnOutside);
|
||||||
|
document.addEventListener("keydown", closeOnEscape);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", closeOnOutside);
|
||||||
|
document.removeEventListener("keydown", closeOnEscape);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={panel}
|
||||||
|
className={
|
||||||
|
className
|
||||||
|
? `notification-bell__panel ${className}`
|
||||||
|
: "notification-bell__panel"
|
||||||
|
}
|
||||||
|
id={id}
|
||||||
|
role="dialog"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-labelledby={headingId}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<h2 className="notification-bell__heading" id={headingId}>
|
||||||
|
{t("notifications.title", "Notifications")}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{notifications.length === 0 ? (
|
||||||
|
<p className="notification-bell__empty">
|
||||||
|
{t("notifications.empty", "Nothing to report.")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="notification-bell__list">
|
||||||
|
{notifications.map((notification, index) => (
|
||||||
|
<Fragment key={notification.id}>
|
||||||
|
{index === 0 && dividedAt > 0 && (
|
||||||
|
<li aria-hidden>
|
||||||
|
<DividerWithText
|
||||||
|
text={t("notifications.section.new", "New")}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{/* Only with unread rows above it. */}
|
||||||
|
{index === dividedAt && dividedAt > 0 && (
|
||||||
|
<li aria-hidden>
|
||||||
|
<DividerWithText
|
||||||
|
text={t("notifications.section.earlier", "Earlier")}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
<NotificationItem
|
||||||
|
notification={notification}
|
||||||
|
unread={index < dividedAt}
|
||||||
|
documentState={documentStateFor(notification)}
|
||||||
|
registry={registry}
|
||||||
|
onDismissPanel={onClose}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,12 +11,7 @@ interface AppSwitchMenuItemsProps {
|
|||||||
onSwitch: (app: AppSwitchTarget) => void;
|
onSwitch: (app: AppSwitchTarget) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** The editor / processor items for an app-switch menu. */
|
||||||
* The editor / processor items for the app-switch menu. Rendered inside the
|
|
||||||
* BrandSwitcher's logo dropdown, which both apps use as their switcher. The
|
|
||||||
* mark is the shared <BrandMark>, which recolours itself from the theme
|
|
||||||
* tokens, so no colour-scheme prop needs threading down here.
|
|
||||||
*/
|
|
||||||
export function AppSwitchMenuItems({
|
export function AppSwitchMenuItems({
|
||||||
current,
|
current,
|
||||||
onSwitch,
|
onSwitch,
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import { Logo } from "@app/ui/Logo";
|
|
||||||
|
|
||||||
export interface AppSwitcherProps {
|
|
||||||
/** Icon-only brand mark for the collapsed rail. */
|
|
||||||
collapsed?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sidebar brand header. Core has no admin portal to switch to, so it just
|
|
||||||
* shows the Stirling logo. Builds that bundle the portal (proprietary/saas)
|
|
||||||
* shadow this with a version whose logo doubles as the editor⇄processor
|
|
||||||
* switcher.
|
|
||||||
*/
|
|
||||||
export function AppSwitcher({ collapsed }: AppSwitcherProps) {
|
|
||||||
return (
|
|
||||||
<Logo
|
|
||||||
variant={collapsed ? "iconOnly" : "iconAndText"}
|
|
||||||
iconHeight="1.6rem"
|
|
||||||
textHeight="1.3rem"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
/* Logo + app-switch dropdown, shared between the editor and the processor.
|
|
||||||
The logo itself is the trigger (its mark morphs into a chevron on hover). */
|
|
||||||
.sui-brand-switcher {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tighten the ghost-button padding so the lockup sits flush like a plain logo,
|
|
||||||
and negative-margin it back so the hover surface still extends past the text. */
|
|
||||||
.sui-brand-switcher__trigger.sui-btn {
|
|
||||||
--button-padding-x: 0.375rem;
|
|
||||||
margin-inline: -0.375rem;
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from "@storybook/react";
|
|
||||||
import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
|
|
||||||
|
|
||||||
const meta: Meta<typeof BrandSwitcher> = {
|
|
||||||
title: "Brand/BrandSwitcher",
|
|
||||||
component: BrandSwitcher,
|
|
||||||
parameters: { layout: "centered" },
|
|
||||||
args: { current: "processor", onSwitch: () => {} },
|
|
||||||
argTypes: {
|
|
||||||
current: { control: "inline-radio", options: ["editor", "processor"] },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof BrandSwitcher>;
|
|
||||||
|
|
||||||
export const Playground: Story = {};
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Button, Dropdown } from "@app/ui";
|
|
||||||
import { Logo } from "@app/ui/Logo";
|
|
||||||
import { BrandMark } from "@app/components/shared/BrandMark";
|
|
||||||
import {
|
|
||||||
AppSwitchMenuItems,
|
|
||||||
type AppSwitchTarget,
|
|
||||||
} from "@app/components/shared/AppSwitch";
|
|
||||||
import "@app/components/shared/BrandSwitcher.css";
|
|
||||||
|
|
||||||
interface BrandSwitcherProps {
|
|
||||||
/** The app this is rendered in (shown active in the menu). */
|
|
||||||
current: AppSwitchTarget;
|
|
||||||
/** Called with the selected app (only for the non-current one). */
|
|
||||||
onSwitch: (app: AppSwitchTarget) => void;
|
|
||||||
/** Icon-only: drop the wordmark, keep the morphing mark as the trigger. */
|
|
||||||
collapsed?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Brand lockup that doubles as the editor⇄processor switcher. The whole logo
|
|
||||||
* is the dropdown trigger: on hover / focus / open the mark morphs into a
|
|
||||||
* downward chevron (see BrandMark), so no separate chevron button is needed.
|
|
||||||
* Shared so the editor and the processor present one identical header.
|
|
||||||
*/
|
|
||||||
export function BrandSwitcher({
|
|
||||||
current,
|
|
||||||
onSwitch,
|
|
||||||
collapsed = false,
|
|
||||||
className,
|
|
||||||
}: BrandSwitcherProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`sui-brand-switcher${className ? ` ${className}` : ""}`}>
|
|
||||||
<Dropdown.Root align="start" open={open} onOpenChange={setOpen}>
|
|
||||||
<Dropdown.Trigger>
|
|
||||||
<Button
|
|
||||||
variant="quiet"
|
|
||||||
data-brandmark-morph
|
|
||||||
className={`sui-brand-switcher__trigger${open ? " is-open" : ""}`}
|
|
||||||
aria-label={t("portal.shell.sidebar.switchApp", "Switch app")}
|
|
||||||
leftSection={<BrandMark height="1.6rem" />}
|
|
||||||
>
|
|
||||||
{!collapsed && <Logo variant="textOnly" textHeight="1.3rem" />}
|
|
||||||
</Button>
|
|
||||||
</Dropdown.Trigger>
|
|
||||||
<Dropdown.Menu width="11rem">
|
|
||||||
<AppSwitchMenuItems current={current} onSwitch={onSwitch} />
|
|
||||||
</Dropdown.Menu>
|
|
||||||
</Dropdown.Root>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
interface BrandTileProps {
|
||||||
|
/** CSS length. Omit to let the caller's CSS size it. */
|
||||||
|
size?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The mark in a rounded square. Decorative: call sites carry the accessible name. */
|
||||||
|
export function BrandTile({ size, className }: BrandTileProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
viewBox="0 0 256 256"
|
||||||
|
fill="none"
|
||||||
|
style={size ? { width: size, height: size } : undefined}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<rect width="256" height="256" rx="58" fill="var(--c-brand-mark)" />
|
||||||
|
<path
|
||||||
|
d="M39.2638 127.834L155.374 32L155.375 121.499L39.2638 217.333L39.2638 127.834Z"
|
||||||
|
fill="white"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M159 124.5L159 88.5L216.728 38.4472L216.728 128.052L100.479 224L100.479 172L159 124.5Z"
|
||||||
|
fill="white"
|
||||||
|
fillOpacity="0.6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
/* ========== FILE SIDEBAR ========== */
|
/* ========== FILE SIDEBAR ========== */
|
||||||
|
|
||||||
.file-sidebar {
|
.file-sidebar {
|
||||||
background-color: var(--c-bg);
|
/* One solid panel, with a rule only on the workbench side, so it frames the document. */
|
||||||
|
background-color: var(--c-surface);
|
||||||
|
border-inline-end: 1px solid var(--c-border-subtle);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -37,12 +39,19 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Brand header (logo / editor⇄processor switcher) ---- */
|
/* Flattened here; two classes deep to beat .sui-nav-surface without relying on order. */
|
||||||
.file-sidebar-brand {
|
.file-sidebar .sui-nav-surface {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Header row (wordmark + collapse toggle) ---- */
|
||||||
|
.file-sidebar-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 40px;
|
min-height: var(--nav-header-h);
|
||||||
padding: 0 0.375rem;
|
padding: 0 var(--nav-gutter);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,9 +60,9 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-sidebar[data-collapsed="true"] .file-sidebar-brand {
|
/* Collapsed the row holds only the toggle, so centre it. */
|
||||||
flex-direction: column;
|
.file-sidebar[data-collapsed="true"] .file-sidebar-header {
|
||||||
gap: 0.25rem;
|
justify-content: center;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.file-sidebar[data-collapsed="true"] .file-sidebar-collapse-toggle {
|
.file-sidebar[data-collapsed="true"] .file-sidebar-collapse-toggle {
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { useViewer } from "@app/contexts/ViewerContext";
|
|||||||
import { useFileHandler } from "@app/hooks/useFileHandler";
|
import { useFileHandler } from "@app/hooks/useFileHandler";
|
||||||
import { useAccountIdentity } from "@app/hooks/useAccountIdentity";
|
import { useAccountIdentity } from "@app/hooks/useAccountIdentity";
|
||||||
import { useFreeCreditsSummary } from "@app/hooks/useFreeCreditsSummary";
|
import { useFreeCreditsSummary } from "@app/hooks/useFreeCreditsSummary";
|
||||||
import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch";
|
|
||||||
import { useOpenPlan } from "@app/hooks/useOpenPlan";
|
import { useOpenPlan } from "@app/hooks/useOpenPlan";
|
||||||
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
|
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
|
||||||
import {
|
import {
|
||||||
@@ -32,8 +31,7 @@ import {
|
|||||||
useIndexedDBRevision,
|
useIndexedDBRevision,
|
||||||
} from "@app/contexts/IndexedDBContext";
|
} from "@app/contexts/IndexedDBContext";
|
||||||
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
|
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
|
||||||
import { AppSwitcher } from "@app/components/shared/AppSwitcher";
|
import { SidebarHeader } from "@app/components/shared/SidebarHeader";
|
||||||
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
|
|
||||||
import type { StirlingFileStub } from "@app/types/fileContext";
|
import type { StirlingFileStub } from "@app/types/fileContext";
|
||||||
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
|
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
|
||||||
import FolderSpecialIcon from "@mui/icons-material/FolderSpecial";
|
import FolderSpecialIcon from "@mui/icons-material/FolderSpecial";
|
||||||
@@ -78,8 +76,9 @@ import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags";
|
|||||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||||
import "@app/components/shared/FileSidebar.css";
|
import "@app/components/shared/FileSidebar.css";
|
||||||
|
|
||||||
const COLLAPSED_WIDTH = "3.5rem";
|
// Shared with the processor sidebar via tokens, so the two cannot drift.
|
||||||
const EXPANDED_WIDTH = "16.25rem"; // ~260px
|
const COLLAPSED_WIDTH = "var(--sidebar-collapsed-w)";
|
||||||
|
const EXPANDED_WIDTH = "var(--sidebar-w)";
|
||||||
|
|
||||||
// Inlined to avoid a circular import with WatchedFoldersRegistration.
|
// Inlined to avoid a circular import with WatchedFoldersRegistration.
|
||||||
const WATCHED_FOLDER_VIEW_ID = "watchedFolder";
|
const WATCHED_FOLDER_VIEW_ID = "watchedFolder";
|
||||||
@@ -98,9 +97,11 @@ export interface FileSidebarProps {
|
|||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
onToggleCollapse?: () => void;
|
onToggleCollapse?: () => void;
|
||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
/** Accessible name override for the toggle button. */
|
/** The quick nav rail owns the account control, so the footer drops its own row. */
|
||||||
|
accountHoisted?: boolean;
|
||||||
|
/** Accessible name override for the collapse toggle. */
|
||||||
toggleAriaLabel?: string;
|
toggleAriaLabel?: string;
|
||||||
/** Icon override for the toggle button (e.g. back-arrow on /files). */
|
/** Icon override for the collapse toggle (e.g. back-arrow on /files). */
|
||||||
toggleIcon?: React.ReactNode;
|
toggleIcon?: React.ReactNode;
|
||||||
/** Override the Open-from-computer handler (e.g. upload to /files folder). */
|
/** Override the Open-from-computer handler (e.g. upload to /files folder). */
|
||||||
onUploadFiles?: (files: File[]) => void | Promise<void>;
|
onUploadFiles?: (files: File[]) => void | Promise<void>;
|
||||||
@@ -155,11 +156,12 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
|||||||
collapsed = false,
|
collapsed = false,
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
|
accountHoisted = false,
|
||||||
|
toggleAriaLabel,
|
||||||
|
toggleIcon,
|
||||||
onUploadFiles,
|
onUploadFiles,
|
||||||
onPickGoogleDriveFiles,
|
onPickGoogleDriveFiles,
|
||||||
extraAction,
|
extraAction,
|
||||||
toggleAriaLabel,
|
|
||||||
toggleIcon,
|
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
@@ -249,7 +251,6 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
|||||||
const { displayName, profilePictureUrl, isAnonymous } =
|
const { displayName, profilePictureUrl, isAnonymous } =
|
||||||
useAccountIdentity();
|
useAccountIdentity();
|
||||||
const credits = useFreeCreditsSummary();
|
const credits = useFreeCreditsSummary();
|
||||||
const otherApp = useOtherAppSwitch();
|
|
||||||
const openPlan = useOpenPlan();
|
const openPlan = useOpenPlan();
|
||||||
|
|
||||||
// Leaf files = user-visible files (excludes intermediate tool outputs)
|
// Leaf files = user-visible files (excludes intermediate tool outputs)
|
||||||
@@ -943,25 +944,12 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="file-sidebar-inner">
|
<div className="file-sidebar-inner">
|
||||||
<div className="file-sidebar-brand">
|
<SidebarHeader
|
||||||
<AppSwitcher collapsed={collapsed} />
|
collapsed={collapsed}
|
||||||
{onToggleCollapse && (
|
onToggleCollapse={onToggleCollapse}
|
||||||
<ActionIcon
|
toggleAriaLabel={toggleAriaLabel}
|
||||||
variant="tertiary"
|
toggleIcon={toggleIcon}
|
||||||
size="md"
|
/>
|
||||||
className="file-sidebar-collapse-toggle"
|
|
||||||
onClick={() => onToggleCollapse()}
|
|
||||||
aria-label={
|
|
||||||
toggleAriaLabel ??
|
|
||||||
(collapsed
|
|
||||||
? t("fileSidebar.expand", "Expand sidebar")
|
|
||||||
: t("fileSidebar.collapse", "Collapse sidebar"))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{toggleIcon ?? <SidebarToggleIcon size={18} />}
|
|
||||||
</ActionIcon>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Box 1 — top controls (open / my files / cloud). No title. File
|
{/* Box 1 — top controls (open / my files / cloud). No title. File
|
||||||
search lives in the global super search (top bar), not here. */}
|
search lives in the global super search (top bar), not here. */}
|
||||||
@@ -984,7 +972,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
|||||||
{/* Tooltips only fire when collapsed - when expanded the visible
|
{/* Tooltips only fire when collapsed - when expanded the visible
|
||||||
text label below already identifies each row, so a tooltip
|
text label below already identifies each row, so a tooltip
|
||||||
would just flash a duplicate. Distinct icons (UploadFile for
|
would just flash a duplicate. Distinct icons (UploadFile for
|
||||||
"Open from computer" vs FolderOpen for "My Files") so the
|
"Open from computer" vs FolderOpen for "File library") so the
|
||||||
collapsed rail isn't two identical folder icons either. */}
|
collapsed rail isn't two identical folder icons either. */}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={t("fileSidebar.openFromComputer", "Open from computer")}
|
label={t("fileSidebar.openFromComputer", "Open from computer")}
|
||||||
@@ -1003,7 +991,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
// "Open from computer" goes straight to the native OS file
|
// "Open from computer" goes straight to the native OS file
|
||||||
// picker. The full file manager (recent + drives + folders)
|
// picker. The full file manager (recent + drives + folders)
|
||||||
// is reachable via "My Files" below.
|
// is reachable via "File library" below.
|
||||||
nativeFileInputRef.current?.click();
|
nativeFileInputRef.current?.click();
|
||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
@@ -1080,7 +1068,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={t("fileSidebar.myFiles", "My Files")}
|
label={t("fileSidebar.myFiles", "File library")}
|
||||||
position="right"
|
position="right"
|
||||||
withinPortal
|
withinPortal
|
||||||
disabled={!collapsed}
|
disabled={!collapsed}
|
||||||
@@ -1094,7 +1082,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
|||||||
}}
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={t("fileSidebar.myFiles", "My Files")}
|
aria-label={t("fileSidebar.myFiles", "File library")}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1105,7 +1093,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
|||||||
<FolderOpenIcon className="file-sidebar-action-icon" />
|
<FolderOpenIcon className="file-sidebar-action-icon" />
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<span className="file-sidebar-action-label sidebar-content-fade">
|
<span className="file-sidebar-action-label sidebar-content-fade">
|
||||||
{t("fileSidebar.myFiles", "My Files")}
|
{t("fileSidebar.myFiles", "File library")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1370,15 +1358,15 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
|||||||
{/* Getting-started checklist, floating above the footer (SaaS only). */}
|
{/* Getting-started checklist, floating above the footer (SaaS only). */}
|
||||||
<SidebarChecklistSlot collapsed={collapsed} />
|
<SidebarChecklistSlot collapsed={collapsed} />
|
||||||
|
|
||||||
{/* Box 3 — the shared footer: credits, app switch, account row. */}
|
{/* Box 3 — the shared footer: credits, plan, and the account row unless hoisted. */}
|
||||||
<NavFooter
|
<NavFooter
|
||||||
className="file-sidebar-footer-box"
|
className="file-sidebar-footer-box"
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
profilePictureUrl={profilePictureUrl}
|
profilePictureUrl={profilePictureUrl}
|
||||||
onOpenSettings={onOpenSettings}
|
onOpenSettings={onOpenSettings}
|
||||||
|
showAccount={!accountHoisted}
|
||||||
credits={credits}
|
credits={credits}
|
||||||
onOpenPlan={openPlan ?? undefined}
|
onOpenPlan={openPlan ?? undefined}
|
||||||
otherApp={otherApp}
|
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { Logo } from "@app/ui/Logo";
|
||||||
|
import { SidebarToggleButton } from "@app/components/shared/SidebarToggleButton";
|
||||||
|
|
||||||
|
export interface SidebarHeaderProps {
|
||||||
|
collapsed?: boolean;
|
||||||
|
onToggleCollapse?: () => void;
|
||||||
|
toggleAriaLabel?: string;
|
||||||
|
toggleIcon?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The wordmark and the collapse toggle; the brand mark sits in the rail beside it. */
|
||||||
|
export function SidebarHeader({
|
||||||
|
collapsed,
|
||||||
|
onToggleCollapse,
|
||||||
|
toggleAriaLabel,
|
||||||
|
toggleIcon,
|
||||||
|
className,
|
||||||
|
}: SidebarHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className={`file-sidebar-header${className ? ` ${className}` : ""}`}>
|
||||||
|
{!collapsed && <Logo variant="textOnly" textHeight="1.3rem" />}
|
||||||
|
{onToggleCollapse && (
|
||||||
|
<SidebarToggleButton
|
||||||
|
collapsed={collapsed}
|
||||||
|
onToggle={onToggleCollapse}
|
||||||
|
ariaLabel={toggleAriaLabel}
|
||||||
|
icon={toggleIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||||
|
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
|
||||||
|
|
||||||
|
export interface SidebarToggleButtonProps {
|
||||||
|
collapsed?: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
ariaLabel?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens and closes the sidebar; on /files the caller swaps in a back arrow. */
|
||||||
|
export function SidebarToggleButton({
|
||||||
|
collapsed,
|
||||||
|
onToggle,
|
||||||
|
ariaLabel,
|
||||||
|
icon,
|
||||||
|
}: SidebarToggleButtonProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<ActionIcon
|
||||||
|
variant="tertiary"
|
||||||
|
size="md"
|
||||||
|
className="file-sidebar-collapse-toggle"
|
||||||
|
onClick={() => onToggle()}
|
||||||
|
aria-label={
|
||||||
|
ariaLabel ??
|
||||||
|
(collapsed
|
||||||
|
? t("fileSidebar.expand", "Expand sidebar")
|
||||||
|
: t("fileSidebar.collapse", "Collapse sidebar"))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{icon ?? <SidebarToggleIcon size={18} />}
|
||||||
|
</ActionIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import { addEventListenerWithCleanup } from "@app/utils/genericUtils";
|
|||||||
import { useTooltipPosition } from "@app/hooks/useTooltipPosition";
|
import { useTooltipPosition } from "@app/hooks/useTooltipPosition";
|
||||||
import { TooltipTip } from "@app/types/tips";
|
import { TooltipTip } from "@app/types/tips";
|
||||||
import { TooltipContent } from "@app/components/shared/tooltip/TooltipContent";
|
import { TooltipContent } from "@app/components/shared/tooltip/TooltipContent";
|
||||||
import { useSidebarContext } from "@app/contexts/SidebarContext";
|
import { useOptionalSidebarContext } from "@app/contexts/SidebarContext";
|
||||||
import { useLogoAssets } from "@app/hooks/useLogoAssets";
|
import { useLogoAssets } from "@app/hooks/useLogoAssets";
|
||||||
import styles from "@app/components/shared/tooltip/Tooltip.module.css";
|
import styles from "@app/components/shared/tooltip/Tooltip.module.css";
|
||||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
|
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
|
||||||
@@ -59,6 +59,29 @@ export interface TooltipProps {
|
|||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Split out so only tooltips with a header need the logo and the providers behind it. */
|
||||||
|
function TooltipHeader({
|
||||||
|
header,
|
||||||
|
}: {
|
||||||
|
header: NonNullable<TooltipProps["header"]>;
|
||||||
|
}) {
|
||||||
|
const { tooltipLogo } = useLogoAssets();
|
||||||
|
return (
|
||||||
|
<div className={styles["tooltip-header"]}>
|
||||||
|
<div className={styles["tooltip-logo"]}>
|
||||||
|
{header.logo || (
|
||||||
|
<img
|
||||||
|
src={tooltipLogo}
|
||||||
|
alt="Stirling PDF"
|
||||||
|
style={{ width: "1.4rem", height: "1.4rem", display: "block" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={styles["tooltip-title"]}>{header.title}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const Tooltip: React.FC<TooltipProps> = ({
|
export const Tooltip: React.FC<TooltipProps> = ({
|
||||||
sidebarTooltip = false,
|
sidebarTooltip = false,
|
||||||
position,
|
position,
|
||||||
@@ -85,7 +108,6 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [internalOpen, setInternalOpen] = useState(false);
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
const [isPinned, setIsPinned] = useState(false);
|
const [isPinned, setIsPinned] = useState(false);
|
||||||
const { tooltipLogo } = useLogoAssets();
|
|
||||||
|
|
||||||
const triggerRef = useRef<HTMLElement | null>(null);
|
const triggerRef = useRef<HTMLElement | null>(null);
|
||||||
const tooltipRef = useRef<HTMLDivElement | null>(null);
|
const tooltipRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -105,9 +127,9 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Always call the hook unconditionally to satisfy React's rules of hooks.
|
// Always call the hook unconditionally to satisfy React's rules of hooks.
|
||||||
// The context is only used when sidebarTooltip is true.
|
// Optional: the plain tooltip renders outside the provider.
|
||||||
const sidebarContextValue = useSidebarContext();
|
const sidebarContextValue = useOptionalSidebarContext();
|
||||||
const sidebarContext = sidebarTooltip ? sidebarContextValue : null;
|
const sidebarContext = sidebarTooltip ? (sidebarContextValue ?? null) : null;
|
||||||
|
|
||||||
const isControlled = controlledOpen !== undefined;
|
const isControlled = controlledOpen !== undefined;
|
||||||
const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled;
|
const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled;
|
||||||
@@ -443,20 +465,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{header && (
|
{header && <TooltipHeader header={header} />}
|
||||||
<div className={styles["tooltip-header"]}>
|
|
||||||
<div className={styles["tooltip-logo"]}>
|
|
||||||
{header.logo || (
|
|
||||||
<img
|
|
||||||
src={tooltipLogo}
|
|
||||||
alt="Stirling PDF"
|
|
||||||
style={{ width: "1.4rem", height: "1.4rem", display: "block" }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className={styles["tooltip-title"]}>{header.title}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<TooltipContent
|
<TooltipContent
|
||||||
content={content}
|
content={content}
|
||||||
tips={tips}
|
tips={tips}
|
||||||
|
|||||||
@@ -14,13 +14,9 @@
|
|||||||
column-gap: 8px;
|
column-gap: 8px;
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
/* No left margin: the file sidebar's own 0.5rem padding already provides the
|
/* Flush, not a floating card: the same surface and hairline as the sidebars. */
|
||||||
gutter on that side, so adding one here would double it and leave the bar
|
|
||||||
further from the sidebar than it is from the tool panel. */
|
|
||||||
margin: var(--nav-gutter) var(--nav-gutter) 0 0;
|
|
||||||
background-color: var(--c-surface);
|
background-color: var(--c-surface);
|
||||||
border: 1px solid var(--c-border-subtle);
|
border-bottom: 1px solid var(--c-border-subtle);
|
||||||
border-radius: var(--radius-nav);
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
}
|
}
|
||||||
@@ -151,15 +147,18 @@
|
|||||||
/* ---- Tool buttons (own full-width row, always below the top row) ---- */
|
/* ---- Tool buttons (own full-width row, always below the top row) ---- */
|
||||||
.workbench-bar-center {
|
.workbench-bar-center {
|
||||||
order: 4;
|
order: 4;
|
||||||
flex: 0 0 100%;
|
/* Wider by the bar's paddings, cancelled by the negative margin, so the rule spans. */
|
||||||
|
flex: 0 0 calc(100% + 16px);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: calc(100% + 16px);
|
||||||
|
margin-inline: -8px;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
/* Symmetric side padding leaves room for the retract handle pinned right
|
/* Symmetric side padding leaves room for the retract handle pinned right
|
||||||
without knocking the centred tool icons off-centre. */
|
without knocking the centred tool icons off-centre. The extra 8px each side
|
||||||
padding: 4px 36px;
|
absorbs the negative margin. */
|
||||||
|
padding: 4px 44px;
|
||||||
border-top: 1px solid var(--c-border-subtle);
|
border-top: 1px solid var(--c-border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,6 +326,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.workbench-bar-center {
|
.workbench-bar-center {
|
||||||
|
flex-basis: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-inline: 0;
|
||||||
padding: 2px 4px 2px 8px;
|
padding: 2px 4px 2px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ import WorkbenchBarMobileActions from "@app/components/shared/workbenchBar/Workb
|
|||||||
import WorkbenchBarToolbarHandle from "@app/components/shared/workbenchBar/WorkbenchBarToolbarHandle";
|
import WorkbenchBarToolbarHandle from "@app/components/shared/workbenchBar/WorkbenchBarToolbarHandle";
|
||||||
import { renderWithTooltip } from "@app/components/shared/workbenchBar/workbenchBarTooltip";
|
import { renderWithTooltip } from "@app/components/shared/workbenchBar/workbenchBarTooltip";
|
||||||
import { WorkbenchBarActionsProps } from "@app/components/shared/workbenchBar/types";
|
import { WorkbenchBarActionsProps } from "@app/components/shared/workbenchBar/types";
|
||||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
import { useIsMobile, useIsPhone } from "@app/hooks/useIsMobile";
|
||||||
import "@app/components/shared/WorkbenchBar.css";
|
import "@app/components/shared/WorkbenchBar.css";
|
||||||
import { NotificationBell } from "@app/components/notifications/NotificationBell";
|
import { NotificationBell } from "@app/components/notifications/NotificationBell";
|
||||||
|
|
||||||
@@ -117,6 +117,8 @@ export default function WorkbenchBar({
|
|||||||
const { sharingEnabled } = useSharingEnabled();
|
const { sharingEnabled } = useSharingEnabled();
|
||||||
const viewerContext = React.useContext(ViewerContext);
|
const viewerContext = React.useContext(ViewerContext);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
// Below this width the rail, and the bell it carries, is hidden.
|
||||||
|
const isPhone = useIsPhone();
|
||||||
const [mobileToolsExpanded, setMobileToolsExpanded] = useState(false);
|
const [mobileToolsExpanded, setMobileToolsExpanded] = useState(false);
|
||||||
|
|
||||||
const selectors = useFileSelectors();
|
const selectors = useFileSelectors();
|
||||||
@@ -487,7 +489,7 @@ export default function WorkbenchBar({
|
|||||||
data-wrapped="false"
|
data-wrapped="false"
|
||||||
data-tour="workbench-bar"
|
data-tour="workbench-bar"
|
||||||
>
|
>
|
||||||
{/* Left: optional "Back to My Files" + view switcher */}
|
{/* Left: optional "Back to File library" + view switcher */}
|
||||||
<div className="workbench-bar-views" data-tour="view-switcher">
|
<div className="workbench-bar-views" data-tour="view-switcher">
|
||||||
{returnRoute && hasFiles && (
|
{returnRoute && hasFiles && (
|
||||||
<>
|
<>
|
||||||
@@ -501,7 +503,7 @@ export default function WorkbenchBar({
|
|||||||
: "filesPage.backToMyFiles",
|
: "filesPage.backToMyFiles",
|
||||||
returnRoute.label
|
returnRoute.label
|
||||||
? `Back to ${returnRoute.label}`
|
? `Back to ${returnRoute.label}`
|
||||||
: "Back to My Files",
|
: "Back to File library",
|
||||||
{ folder: returnRoute.label ?? "" },
|
{ folder: returnRoute.label ?? "" },
|
||||||
)}
|
)}
|
||||||
leftSection={<ArrowBackIcon style={{ fontSize: "1.1rem" }} />}
|
leftSection={<ArrowBackIcon style={{ fontSize: "1.1rem" }} />}
|
||||||
@@ -511,7 +513,7 @@ export default function WorkbenchBar({
|
|||||||
? t("filesPage.backToFolder", "Back to {{folder}}", {
|
? t("filesPage.backToFolder", "Back to {{folder}}", {
|
||||||
folder: returnRoute.label,
|
folder: returnRoute.label,
|
||||||
})
|
})
|
||||||
: t("filesPage.backToMyFiles", "Back to My Files")}
|
: t("filesPage.backToMyFiles", "Back to File library")}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<div className="workbench-bar-divider" />
|
<div className="workbench-bar-divider" />
|
||||||
@@ -603,9 +605,13 @@ export default function WorkbenchBar({
|
|||||||
enforcingProgress={enforcingProgress}
|
enforcingProgress={enforcingProgress}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* Last in the globals, so it is the rightmost control. */}
|
{isPhone && (
|
||||||
<div className="workbench-bar-divider workbench-bar-globals-sep" />
|
<>
|
||||||
<NotificationBell />
|
{/* Last in the globals, so it is the rightmost control. */}
|
||||||
|
<div className="workbench-bar-divider workbench-bar-globals-sep" />
|
||||||
|
<NotificationBell />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export interface NavFooterProps {
|
|||||||
otherApp?: NavFooterAppLink | null;
|
otherApp?: NavFooterAppLink | null;
|
||||||
/** Extra rows above the account row (the self-hosted link-account CTA). */
|
/** Extra rows above the account row (the self-hosted link-account CTA). */
|
||||||
accountExtras?: ReactNode;
|
accountExtras?: ReactNode;
|
||||||
|
/** False where the rail owns the account control, so only one avatar is drawn. */
|
||||||
|
showAccount?: boolean;
|
||||||
/** Icon-rail state: labels collapse to tooltips. */
|
/** Icon-rail state: labels collapse to tooltips. */
|
||||||
collapsed?: boolean;
|
collapsed?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -69,6 +71,7 @@ export function NavFooter({
|
|||||||
onOpenPlan,
|
onOpenPlan,
|
||||||
otherApp,
|
otherApp,
|
||||||
accountExtras,
|
accountExtras,
|
||||||
|
showAccount = true,
|
||||||
collapsed = false,
|
collapsed = false,
|
||||||
className,
|
className,
|
||||||
}: NavFooterProps) {
|
}: NavFooterProps) {
|
||||||
@@ -144,49 +147,51 @@ export function NavFooter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
rows.push({
|
if (showAccount) {
|
||||||
key: "account",
|
rows.push({
|
||||||
node: (
|
key: "account",
|
||||||
<Tooltip
|
node: (
|
||||||
label={accountLabel}
|
<Tooltip
|
||||||
position="right"
|
label={accountLabel}
|
||||||
withinPortal
|
position="right"
|
||||||
disabled={!collapsed}
|
withinPortal
|
||||||
>
|
disabled={!collapsed}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="nav-footer__row nav-footer__account"
|
|
||||||
// Called with no args: handlers that take optional params (the
|
|
||||||
// processor's openSettings(section?)) must not receive the event.
|
|
||||||
onClick={onOpenSettings ? () => onOpenSettings() : undefined}
|
|
||||||
disabled={!onOpenSettings}
|
|
||||||
data-testid={onOpenSettings ? "config-button" : undefined}
|
|
||||||
data-tour={onOpenSettings ? "config-button" : undefined}
|
|
||||||
aria-label={accountLabel}
|
|
||||||
>
|
>
|
||||||
{/* Decorative: the button's own label already names the account, so
|
<button
|
||||||
an alt/label here would just repeat it to a screen reader. */}
|
type="button"
|
||||||
<span aria-hidden>
|
className="nav-footer__row nav-footer__account"
|
||||||
<Avatar
|
// Called with no args: a handler with an optional param must not get the event.
|
||||||
size="sm"
|
onClick={onOpenSettings ? () => onOpenSettings() : undefined}
|
||||||
name={displayName}
|
disabled={!onOpenSettings}
|
||||||
src={profilePictureUrl ?? undefined}
|
data-testid={onOpenSettings ? "config-button" : undefined}
|
||||||
/>
|
data-tour={onOpenSettings ? "config-button" : undefined}
|
||||||
</span>
|
aria-label={accountLabel}
|
||||||
{!collapsed && (
|
>
|
||||||
<span className="nav-footer__row-label sidebar-content-fade">
|
{/* Decorative: the button's own label already names the account. */}
|
||||||
{displayName}
|
<span aria-hidden>
|
||||||
|
<Avatar
|
||||||
|
size="sm"
|
||||||
|
name={displayName}
|
||||||
|
src={profilePictureUrl ?? undefined}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
)}
|
{!collapsed && (
|
||||||
{onOpenSettings && !collapsed && (
|
<span className="nav-footer__row-label sidebar-content-fade">
|
||||||
<span className="nav-footer__trailing" aria-hidden>
|
{displayName}
|
||||||
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
{onOpenSettings && !collapsed && (
|
||||||
</button>
|
<span className="nav-footer__trailing" aria-hidden>
|
||||||
</Tooltip>
|
<SettingsIcon sx={{ fontSize: "1.1rem" }} />
|
||||||
),
|
</span>
|
||||||
});
|
)}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavSurface
|
<NavSurface
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||||
|
import { BrandMark } from "@app/components/shared/BrandMark";
|
||||||
|
|
||||||
|
export interface QuickNavBrandProps {
|
||||||
|
/** Returns the app you are in to its default state. */
|
||||||
|
onReturnHome: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuickNavBrand({ onReturnHome }: QuickNavBrandProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const label = t("quickNav.home", "Stirling");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="quick-nav-brand">
|
||||||
|
<Tooltip content={label} position="right" arrow>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="quick-nav-brand-button"
|
||||||
|
aria-label={label}
|
||||||
|
onClick={onReturnHome}
|
||||||
|
>
|
||||||
|
<BrandMark height="1.6rem" />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useAccountIdentity } from "@app/hooks/useAccountIdentity";
|
||||||
|
import {
|
||||||
|
NotificationPanel,
|
||||||
|
NOTIFICATIONS_PANEL_ID,
|
||||||
|
} from "@app/components/notifications/NotificationPanel";
|
||||||
|
import { useNotificationActions } from "@app/components/notifications/notificationActions";
|
||||||
|
import { useQuickNavToolReasons } from "@app/components/shared/quickNav/useQuickNavToolReasons";
|
||||||
|
import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable";
|
||||||
|
import { useSigningBadgeCount } from "@app/hooks/signing/useSigningBadgeCount";
|
||||||
|
import {
|
||||||
|
useRegisterQuickNavHost,
|
||||||
|
type QuickNavToolReasons,
|
||||||
|
} from "@app/contexts/QuickNavHostContext";
|
||||||
|
import type { ToolId } from "@app/types/toolId";
|
||||||
|
|
||||||
|
export interface QuickNavHostBridgeProps {
|
||||||
|
portalAccess?: boolean;
|
||||||
|
readerMode?: boolean;
|
||||||
|
onSetReaderMode?: (on: boolean) => void;
|
||||||
|
onOpenSettings: () => void;
|
||||||
|
requestNavigation?: (go: () => void) => void;
|
||||||
|
onGoToDefaultState?: () => void;
|
||||||
|
onSelectTool?: (toolId: ToolId) => void;
|
||||||
|
/** Merged over the reasons worked out here, for what only the app can see. */
|
||||||
|
toolReasons?: QuickNavToolReasons;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Registers with the rail what only the app can see, and owns the notifications panel. */
|
||||||
|
export function QuickNavHostBridge({
|
||||||
|
portalAccess = false,
|
||||||
|
readerMode = false,
|
||||||
|
onSetReaderMode,
|
||||||
|
onOpenSettings,
|
||||||
|
requestNavigation,
|
||||||
|
onSelectTool,
|
||||||
|
onGoToDefaultState,
|
||||||
|
toolReasons,
|
||||||
|
}: QuickNavHostBridgeProps) {
|
||||||
|
const { displayName, profilePictureUrl } = useAccountIdentity();
|
||||||
|
const signingBadge = useSigningBadgeCount();
|
||||||
|
const notificationsAvailable = useNotificationsAvailable();
|
||||||
|
// Built even when closed: it carries a one-shot document pickup that would sit unclaimed.
|
||||||
|
const notificationActions = useNotificationActions();
|
||||||
|
const endpointReasons = useQuickNavToolReasons();
|
||||||
|
const mergedToolReasons = useMemo(() => {
|
||||||
|
// An empty map from the app is silence, not an answer.
|
||||||
|
const extra =
|
||||||
|
toolReasons && Object.keys(toolReasons).length > 0 ? toolReasons : null;
|
||||||
|
if (!endpointReasons && !extra) return undefined;
|
||||||
|
return { ...endpointReasons, ...extra };
|
||||||
|
}, [endpointReasons, toolReasons]);
|
||||||
|
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||||||
|
const closeNotifications = useCallback(() => setNotificationsOpen(false), []);
|
||||||
|
|
||||||
|
useRegisterQuickNavHost(
|
||||||
|
{
|
||||||
|
identity: { displayName, profilePictureUrl },
|
||||||
|
signingBadge,
|
||||||
|
portalAccess,
|
||||||
|
readerMode,
|
||||||
|
notificationsOpen,
|
||||||
|
toolReasons: mergedToolReasons,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
openSettings: onOpenSettings,
|
||||||
|
requestNavigation,
|
||||||
|
selectTool: onSelectTool,
|
||||||
|
setReaderMode: onSetReaderMode,
|
||||||
|
goToDefaultState: onGoToDefaultState,
|
||||||
|
toggleNotifications: () => setNotificationsOpen((open) => !open),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mounted only while open, so a closed panel never subscribes to the poll.
|
||||||
|
if (!notificationsAvailable || !notificationsOpen) return null;
|
||||||
|
return (
|
||||||
|
<NotificationPanel
|
||||||
|
id={NOTIFICATIONS_PANEL_ID}
|
||||||
|
onClose={closeNotifications}
|
||||||
|
registry={notificationActions}
|
||||||
|
className="notification-bell__panel--rail"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/* ========== QUICK NAV RAIL ========== */
|
||||||
|
|
||||||
|
.quick-nav-rail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-nav-rail-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--quicknav-item-gap);
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The glyph stays at the sidebar's scale; the button fills the rail for hit area. */
|
||||||
|
.quick-nav-rail-item {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 2.25rem;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--c-text-subtle);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color var(--motion-fast),
|
||||||
|
color var(--motion-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-nav-rail-item svg,
|
||||||
|
.quick-nav-rail-item img {
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
color: inherit;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Taller than wide (71x79), so height drives the width. */
|
||||||
|
.quick-nav-rail-item .sui-brandmark {
|
||||||
|
width: auto;
|
||||||
|
height: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-nav-rail-item[aria-disabled="true"] .sui-brandmark,
|
||||||
|
.quick-nav-rail-item[aria-disabled="true"] svg[viewBox="0 0 256 256"] {
|
||||||
|
filter: grayscale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-nav-rail-item:hover {
|
||||||
|
background: var(--c-hover);
|
||||||
|
color: var(--c-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Opacity rather than a colour: there is no disabled-text token. */
|
||||||
|
.quick-nav-rail-item[aria-disabled="true"] {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.quick-nav-rail-item[aria-disabled="true"]:hover {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--c-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-nav-rail-item:focus-visible {
|
||||||
|
outline: 0.125rem solid var(--c-primary);
|
||||||
|
outline-offset: -0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-nav-rail-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.125rem;
|
||||||
|
inset-inline-end: 0.125rem;
|
||||||
|
min-width: 0.875rem;
|
||||||
|
height: 0.875rem;
|
||||||
|
padding: 0 0.1875rem;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
/* The solid step: 9px numerals need the darker end of the ramp. */
|
||||||
|
background: var(--c-danger-solid);
|
||||||
|
color: var(--c-text-on-primary);
|
||||||
|
font-size: 0.5625rem;
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
line-height: 0.875rem;
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-nav-rail-badge[data-tone="warning"] {
|
||||||
|
background: var(--c-warning-solid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top margin only: the group below supplies the other half, centring the rule. */
|
||||||
|
.quick-nav-rail-divider {
|
||||||
|
width: 100%;
|
||||||
|
height: 0;
|
||||||
|
margin: var(--quicknav-item-gap) 0 0;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--c-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-nav-rail-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--quicknav-item-gap);
|
||||||
|
width: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Brand: one header row tall, so it lines up with the sidebar's wordmark ---- */
|
||||||
|
.quick-nav-brand {
|
||||||
|
width: 100%;
|
||||||
|
height: var(--nav-header-h);
|
||||||
|
flex-shrink: 0;
|
||||||
|
/* The bar's inset supplies part of the gap; only the remainder is added here. */
|
||||||
|
margin-bottom: calc(var(--quicknav-item-gap) - var(--quicknav-surface-pad));
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-nav-brand-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-nav-brand-button:focus-visible {
|
||||||
|
outline: 0.125rem solid var(--c-primary);
|
||||||
|
outline-offset: -0.125rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The "on" state: a solid block with the glyph knocked out, hover owning the tints. */
|
||||||
|
.quick-nav-rail-item[aria-current="true"],
|
||||||
|
.quick-nav-rail-item[aria-current="true"]:hover,
|
||||||
|
.quick-nav-rail-item[aria-pressed="true"],
|
||||||
|
.quick-nav-rail-item[aria-pressed="true"]:hover {
|
||||||
|
background: var(--c-text);
|
||||||
|
color: var(--c-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On a dark ground full ink is white, so mix the block back toward the surface. */
|
||||||
|
[data-theme="dark"] .quick-nav-rail-item[aria-current="true"],
|
||||||
|
[data-theme="dark"] .quick-nav-rail-item[aria-current="true"]:hover,
|
||||||
|
[data-theme="dark"] .quick-nav-rail-item[aria-pressed="true"],
|
||||||
|
[data-theme="dark"] .quick-nav-rail-item[aria-pressed="true"]:hover,
|
||||||
|
html[data-app-theme="midnight"] .quick-nav-rail-item[aria-current="true"],
|
||||||
|
html[data-app-theme="midnight"] .quick-nav-rail-item[aria-current="true"]:hover,
|
||||||
|
html[data-app-theme="midnight"] .quick-nav-rail-item[aria-pressed="true"],
|
||||||
|
html[data-app-theme="midnight"] .quick-nav-rail-item[aria-pressed="true"]:hover,
|
||||||
|
[data-mantine-color-scheme="dark"] .quick-nav-rail-item[aria-current="true"],
|
||||||
|
[data-mantine-color-scheme="dark"]
|
||||||
|
.quick-nav-rail-item[aria-current="true"]:hover,
|
||||||
|
[data-mantine-color-scheme="dark"] .quick-nav-rail-item[aria-pressed="true"],
|
||||||
|
[data-mantine-color-scheme="dark"]
|
||||||
|
.quick-nav-rail-item[aria-pressed="true"]:hover {
|
||||||
|
background: color-mix(in srgb, var(--c-text) 80%, var(--c-surface));
|
||||||
|
color: var(--c-surface);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/* The account control, pinned to the bottom of the bar. */
|
||||||
|
|
||||||
|
.quick-nav-rail-account {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
flex-shrink: 0;
|
||||||
|
/* Further than the shortcut gap: a filled disc reads heavier than a line glyph. */
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
/* Matches the slack centring the brand mark leaves at the top. */
|
||||||
|
padding-bottom: 0.3125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-nav-rail-avatar-target {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Appearance comes from the shared Avatar; only the button reset is ours. */
|
||||||
|
.quick-nav-rail-avatar {
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-nav-rail-avatar:focus-visible {
|
||||||
|
outline: 0.125rem solid var(--c-primary);
|
||||||
|
outline-offset: 0.125rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||||
|
import { Avatar } from "@app/ui/Avatar";
|
||||||
|
import type { QuickNavIdentity } from "@app/contexts/QuickNavHostContext";
|
||||||
|
import "@app/components/shared/quickNav/QuickNavRailAccount.css";
|
||||||
|
|
||||||
|
export interface QuickNavRailAccountProps {
|
||||||
|
onOpenSettings: () => void;
|
||||||
|
/** Null between apps; the disc still renders, so the bar keeps its shape. */
|
||||||
|
identity: QuickNavIdentity | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The avatar opens settings, so there is no separate gear beside it. */
|
||||||
|
export function QuickNavRailAccount({
|
||||||
|
onOpenSettings,
|
||||||
|
identity,
|
||||||
|
}: QuickNavRailAccountProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const displayName =
|
||||||
|
identity?.displayName ?? t("auth.displayName.user", "User");
|
||||||
|
const profilePictureUrl = identity?.profilePictureUrl ?? null;
|
||||||
|
const label = `${displayName} — ${t("fileSidebar.openSettings", "Open settings")}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="quick-nav-rail-account">
|
||||||
|
<Tooltip content={label} position="right" arrow>
|
||||||
|
{/* A span, not the Avatar: Tooltip binds by cloning its child. */}
|
||||||
|
<span
|
||||||
|
className="quick-nav-rail-avatar-target"
|
||||||
|
data-testid="config-button"
|
||||||
|
data-tour="config-button"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
src={profilePictureUrl ?? undefined}
|
||||||
|
name={displayName}
|
||||||
|
size="sm"
|
||||||
|
onClick={onOpenSettings}
|
||||||
|
ariaLabel={label}
|
||||||
|
className="quick-nav-rail-avatar"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import {
|
||||||
|
QuickNavRailBase,
|
||||||
|
type QuickNavEntry,
|
||||||
|
} from "@app/components/shared/quickNav/QuickNavRailBase";
|
||||||
|
|
||||||
|
/** The rail needs no providers. */
|
||||||
|
function withProviders(ui: React.ReactNode) {
|
||||||
|
return <>{ui}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entry(
|
||||||
|
id: string,
|
||||||
|
overrides: Partial<QuickNavEntry> = {},
|
||||||
|
): QuickNavEntry {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: id,
|
||||||
|
icon: null,
|
||||||
|
onClick: () => {},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROCESSOR = entry("processor");
|
||||||
|
const WITHIN = [entry("files"), entry("reader")];
|
||||||
|
|
||||||
|
function renderRail(groups: QuickNavEntry[][]) {
|
||||||
|
const { container } = render(
|
||||||
|
withProviders(<QuickNavRailBase groups={groups} />),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
labels: [...container.querySelectorAll(".quick-nav-rail-item")].map((b) =>
|
||||||
|
b.getAttribute("aria-label"),
|
||||||
|
),
|
||||||
|
dividers: container.querySelectorAll(".quick-nav-rail-divider").length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("QuickNavRailBase — groups", () => {
|
||||||
|
it("divides one group from the next", () => {
|
||||||
|
const { labels, dividers } = renderRail([[PROCESSOR], WITHIN]);
|
||||||
|
|
||||||
|
expect(labels).toEqual(["processor", "files", "reader"]);
|
||||||
|
expect(dividers).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops an empty group, and the divider with it", () => {
|
||||||
|
const { labels, dividers } = renderRail([[], WITHIN]);
|
||||||
|
|
||||||
|
expect(labels).toEqual(["files", "reader"]);
|
||||||
|
expect(dividers).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("QuickNavRailBase — entry state", () => {
|
||||||
|
it("reports on/off for a toggle and nothing for the rest", () => {
|
||||||
|
// Nothing here is a view you occupy, so only a real toggle has state.
|
||||||
|
const { container } = render(
|
||||||
|
withProviders(
|
||||||
|
<QuickNavRailBase
|
||||||
|
groups={[
|
||||||
|
[PROCESSOR],
|
||||||
|
[entry("reader", { pressed: true }), entry("files")],
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const state = [...container.querySelectorAll(".quick-nav-rail-item")].map(
|
||||||
|
(b) => [b.getAttribute("aria-label"), b.getAttribute("aria-pressed")],
|
||||||
|
);
|
||||||
|
expect(state).toEqual([
|
||||||
|
["processor", null],
|
||||||
|
["reader", "true"],
|
||||||
|
["files", null],
|
||||||
|
]);
|
||||||
|
expect(container.querySelectorAll("[aria-current]")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a disabled entry in the tab order so its reason stays reachable", () => {
|
||||||
|
// The tooltip carrying the reason is only reachable while it can be focused.
|
||||||
|
const { container } = render(
|
||||||
|
withProviders(
|
||||||
|
<QuickNavRailBase
|
||||||
|
groups={[
|
||||||
|
[
|
||||||
|
entry("automate", {
|
||||||
|
disabled: true,
|
||||||
|
reason: "Disabled by server administrator",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const automate = container.querySelector('[aria-label="automate"]')!;
|
||||||
|
expect(automate.getAttribute("aria-disabled")).toBe("true");
|
||||||
|
expect(automate.hasAttribute("disabled")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an unavailable entry rendered, disabled rather than dropped", () => {
|
||||||
|
// Slots must not appear and vanish as access resolves.
|
||||||
|
const { container } = render(
|
||||||
|
withProviders(
|
||||||
|
<QuickNavRailBase
|
||||||
|
groups={[
|
||||||
|
[entry("processor", { disabled: true, reason: "no access" })],
|
||||||
|
WITHIN,
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const processor = container.querySelector('[aria-label="processor"]');
|
||||||
|
expect(processor).not.toBeNull();
|
||||||
|
expect(processor?.getAttribute("aria-disabled")).toBe("true");
|
||||||
|
// aria-disabled, not the disabled attribute: it stays focusable for its tooltip.
|
||||||
|
expect(processor?.hasAttribute("disabled")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||||
|
import "@app/components/shared/quickNav/QuickNavRail.css";
|
||||||
|
|
||||||
|
export type QuickNavTarget = "reader" | "editor" | "files" | "processor";
|
||||||
|
|
||||||
|
export interface QuickNavEntry {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
/** The app you are in, drawn with an edge bar. */
|
||||||
|
current?: boolean;
|
||||||
|
/** Only for entries that toggle something; use `current` for the app you are in. */
|
||||||
|
pressed?: boolean;
|
||||||
|
/** Inert, with `reason` as its tooltip. Entries are dimmed, never dropped. */
|
||||||
|
disabled?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
badge?: number;
|
||||||
|
/** Popup semantics for an entry whose panel is rendered in another tree. */
|
||||||
|
expanded?: boolean;
|
||||||
|
controls?: string;
|
||||||
|
/** "danger" waits on the user; "warning" is awareness only. */
|
||||||
|
badgeTone?: "danger" | "warning";
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuickNavRailBaseProps {
|
||||||
|
/** Divided by a rule; empty groups are dropped. */
|
||||||
|
groups: QuickNavEntry[][];
|
||||||
|
footer?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exported so footer entries reuse it rather than a lookalike. */
|
||||||
|
export function RailButton({
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
pressed,
|
||||||
|
disabled,
|
||||||
|
reason,
|
||||||
|
badge,
|
||||||
|
badgeTone = "danger",
|
||||||
|
current,
|
||||||
|
expanded,
|
||||||
|
controls,
|
||||||
|
onClick,
|
||||||
|
}: Omit<QuickNavEntry, "id">) {
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
content={disabled && reason ? `${label} — ${reason}` : label}
|
||||||
|
position="right"
|
||||||
|
arrow
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="quick-nav-rail-item"
|
||||||
|
aria-pressed={pressed}
|
||||||
|
aria-current={current ? "true" : undefined}
|
||||||
|
aria-label={label}
|
||||||
|
aria-haspopup={expanded === undefined ? undefined : "dialog"}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-controls={expanded === undefined ? undefined : controls}
|
||||||
|
// aria-disabled, not `disabled`: stays focusable, so its tooltip is reachable.
|
||||||
|
aria-disabled={disabled || undefined}
|
||||||
|
onClick={disabled ? undefined : onClick}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{badge !== undefined && badge > 0 && (
|
||||||
|
<span
|
||||||
|
className="quick-nav-rail-badge"
|
||||||
|
data-tone={badgeTone}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{badge > 9 ? "9+" : badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuickNavRailBase({ groups, footer }: QuickNavRailBaseProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const populated = groups.filter((entries) => entries.length > 0);
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
className="quick-nav-rail"
|
||||||
|
aria-label={t("quickNav.landmark", "Quick navigation")}
|
||||||
|
>
|
||||||
|
{populated.map((group, index) => (
|
||||||
|
<div className="quick-nav-rail-group" key={group[0].id}>
|
||||||
|
{index > 0 && <hr className="quick-nav-rail-divider" />}
|
||||||
|
{group.map((entry) => (
|
||||||
|
<RailButton key={entry.id} {...entry} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{footer}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
.quick-nav-rail-container {
|
||||||
|
/* On the column, so the parts outside the nav inherit them too. */
|
||||||
|
--quicknav-item-gap: var(--space-3);
|
||||||
|
--quicknav-surface-pad: 0.375rem;
|
||||||
|
|
||||||
|
width: var(--nav-rail-w);
|
||||||
|
height: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
/* On the column, not the bar, so the fill covers the gutters too. */
|
||||||
|
background-color: var(--c-surface);
|
||||||
|
border-inline-end: 1px solid var(--c-border-subtle);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding-block: var(--nav-gutter);
|
||||||
|
padding-inline: calc(var(--nav-gutter) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Child selector to beat .sui-nav-surface, which would win on order. */
|
||||||
|
.quick-nav-rail-container > .quick-nav-rail-surface {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-nav-rail-surface {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
/* No inline padding: the bar is one target wide and would squeeze the buttons. */
|
||||||
|
padding: var(--quicknav-surface-pad) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Below this width the sidebar is an off-canvas drawer, and the rail is just noise. */
|
||||||
|
@media (max-width: 48rem) {
|
||||||
|
.quick-nav-rail-container {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { NavSurface } from "@app/ui/NavSurface";
|
||||||
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
|
import { QuickNavBrand } from "@app/components/shared/quickNav/QuickNavBrand";
|
||||||
|
import type { QuickNavIdentity } from "@app/contexts/QuickNavHostContext";
|
||||||
|
import {
|
||||||
|
QuickNavRailBase,
|
||||||
|
RailButton,
|
||||||
|
type QuickNavRailBaseProps,
|
||||||
|
} from "@app/components/shared/quickNav/QuickNavRailBase";
|
||||||
|
import { QuickNavRailAccount } from "@app/components/shared/quickNav/QuickNavRailAccount";
|
||||||
|
import { QuickNavRailNotifications } from "@app/components/shared/quickNav/QuickNavRailNotifications";
|
||||||
|
import "@app/components/shared/quickNav/QuickNavRailContainer.css";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
QuickNavEntry,
|
||||||
|
QuickNavTarget,
|
||||||
|
} from "@app/components/shared/quickNav/QuickNavRailBase";
|
||||||
|
|
||||||
|
export interface QuickNavRailContainerProps extends Omit<
|
||||||
|
QuickNavRailBaseProps,
|
||||||
|
"footer"
|
||||||
|
> {
|
||||||
|
/** The rail owns the account control, so the sidebars drop their own row. */
|
||||||
|
onOpenSettings?: () => void;
|
||||||
|
/** Omitted in builds with no processor to invite anyone into. */
|
||||||
|
onInvite?: () => void;
|
||||||
|
onToggleNotifications?: () => void;
|
||||||
|
notificationsOpen?: boolean;
|
||||||
|
identity?: QuickNavIdentity | null;
|
||||||
|
onReturnHome: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The fixed-width column the rail sits in. */
|
||||||
|
export function QuickNavRailContainer({
|
||||||
|
onOpenSettings,
|
||||||
|
onInvite,
|
||||||
|
onToggleNotifications,
|
||||||
|
notificationsOpen,
|
||||||
|
identity = null,
|
||||||
|
onReturnHome,
|
||||||
|
...railProps
|
||||||
|
}: QuickNavRailContainerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="quick-nav-rail-container">
|
||||||
|
<QuickNavBrand onReturnHome={onReturnHome} />
|
||||||
|
<NavSurface className="quick-nav-rail-surface">
|
||||||
|
<QuickNavRailBase
|
||||||
|
{...railProps}
|
||||||
|
footer={
|
||||||
|
// Always rendered: the bell lives here too, so gating the footer hides it.
|
||||||
|
<div className="quick-nav-rail-footer">
|
||||||
|
<QuickNavRailNotifications
|
||||||
|
onToggle={onToggleNotifications}
|
||||||
|
open={notificationsOpen}
|
||||||
|
/>
|
||||||
|
{onInvite && (
|
||||||
|
<RailButton
|
||||||
|
label={t("quickNav.invite", "Invite")}
|
||||||
|
icon={
|
||||||
|
<LocalIcon
|
||||||
|
icon="person-add-outline-rounded"
|
||||||
|
width="1.125rem"
|
||||||
|
height="1.125rem"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onClick={onInvite}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{onOpenSettings && (
|
||||||
|
<QuickNavRailAccount
|
||||||
|
onOpenSettings={onOpenSettings}
|
||||||
|
identity={identity}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</NavSurface>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
|
import { QuickNavRailContainer } from "@app/components/shared/quickNav/QuickNavRailContainer";
|
||||||
|
import type { QuickNavEntry } from "@app/components/shared/quickNav/QuickNavRailBase";
|
||||||
|
import type { ToolId } from "@app/types/toolId";
|
||||||
|
import { useQuickNavHost } from "@app/contexts/QuickNavHostContext";
|
||||||
|
import { requestReaderMode } from "@app/utils/pendingReaderMode";
|
||||||
|
import {
|
||||||
|
saveEditorReturnPath,
|
||||||
|
takeEditorReturnPath,
|
||||||
|
} from "@app/services/workbenchSession";
|
||||||
|
import { EDITOR_BASENAME } from "@app/routes/editorBasename";
|
||||||
|
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
|
||||||
|
import { HAS_PORTAL } from "@app/routes/hasPortal";
|
||||||
|
|
||||||
|
const SIZE = "1.125rem";
|
||||||
|
|
||||||
|
/** Entries come from the URL, not either app's context, so the rail survives a switch. */
|
||||||
|
export function QuickNavRailHost() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { pathname, search } = useLocation();
|
||||||
|
const host = useQuickNavHost();
|
||||||
|
|
||||||
|
const appMounted = Boolean(host?.appMounted);
|
||||||
|
|
||||||
|
const inPortal = pathname.startsWith(PORTAL_BASENAME);
|
||||||
|
|
||||||
|
// Only the app knows its own default state.
|
||||||
|
const returnHome = () => {
|
||||||
|
const reset = host?.actions.current?.goToDefaultState;
|
||||||
|
if (reset) reset();
|
||||||
|
else navigate(inPortal ? PORTAL_BASENAME : EDITOR_BASENAME);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Guarded where the app supplies a guard, so leaving mid-edit still prompts.
|
||||||
|
const go = (to: string) => {
|
||||||
|
const guard = host?.actions.current?.requestNavigation;
|
||||||
|
if (guard) guard(() => navigate(to));
|
||||||
|
else navigate(to);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Through the app where possible: its route only selects a tool on a fresh mount.
|
||||||
|
const openTool = (toolId: ToolId, route: string) => {
|
||||||
|
const select = host?.actions.current?.selectTool;
|
||||||
|
if (select) select(toolId);
|
||||||
|
else go(route);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unusable = (id: ToolId) => {
|
||||||
|
const reason = host?.toolReasons?.[id];
|
||||||
|
return { disabled: Boolean(reason), reason };
|
||||||
|
};
|
||||||
|
|
||||||
|
const apps: QuickNavEntry[] = [
|
||||||
|
{
|
||||||
|
id: "processor",
|
||||||
|
label: t("quickNav.processor", "Processor"),
|
||||||
|
// Two literals, not a computed name: the offline icon bundle scans for `icon="..."`.
|
||||||
|
icon: inPortal ? (
|
||||||
|
<LocalIcon icon="memory-rounded" width={SIZE} height={SIZE} />
|
||||||
|
) : (
|
||||||
|
<LocalIcon icon="memory-outline-rounded" width={SIZE} height={SIZE} />
|
||||||
|
),
|
||||||
|
current: inPortal,
|
||||||
|
disabled: HAS_PORTAL && !inPortal && !host?.portalAccess,
|
||||||
|
reason:
|
||||||
|
HAS_PORTAL && !inPortal && !host?.portalAccess
|
||||||
|
? t("quickNav.noProcessorAccess", "Ask an admin for processor access")
|
||||||
|
: undefined,
|
||||||
|
onClick: () => {
|
||||||
|
if (inPortal) {
|
||||||
|
returnHome();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveEditorReturnPath(pathname + search);
|
||||||
|
go(PORTAL_BASENAME);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "editor",
|
||||||
|
label: t("quickNav.editor", "Editor"),
|
||||||
|
icon: inPortal ? (
|
||||||
|
<LocalIcon icon="edit-outline-rounded" width={SIZE} height={SIZE} />
|
||||||
|
) : (
|
||||||
|
<LocalIcon icon="edit-rounded" width={SIZE} height={SIZE} />
|
||||||
|
),
|
||||||
|
current: !inPortal,
|
||||||
|
onClick: () => {
|
||||||
|
if (!inPortal) {
|
||||||
|
returnHome();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Back to where you left the editor, not its front door.
|
||||||
|
navigate(takeEditorReturnPath() ?? EDITOR_BASENAME);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const within: QuickNavEntry[] = [
|
||||||
|
{
|
||||||
|
id: "files",
|
||||||
|
label: t("fileSidebar.myFiles", "File library"),
|
||||||
|
icon: (
|
||||||
|
<LocalIcon icon="folder-outline-rounded" width={SIZE} height={SIZE} />
|
||||||
|
),
|
||||||
|
onClick: () => go("/files"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "reader",
|
||||||
|
label: t("quickNav.reader", "Reader"),
|
||||||
|
icon: (
|
||||||
|
<LocalIcon
|
||||||
|
icon="menu-book-outline-rounded"
|
||||||
|
width={SIZE}
|
||||||
|
height={SIZE}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
pressed: Boolean(host?.readerMode),
|
||||||
|
// From the processor there is no editor to toggle - see pendingReaderMode.
|
||||||
|
onClick: () => {
|
||||||
|
const setMode = host?.actions.current?.setReaderMode;
|
||||||
|
if (setMode) {
|
||||||
|
setMode(!host?.readerMode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestReaderMode();
|
||||||
|
go(EDITOR_BASENAME);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "automate",
|
||||||
|
label: t("quickAccess.automate", "Automate"),
|
||||||
|
icon: (
|
||||||
|
<LocalIcon icon="rebase-outline-rounded" width={SIZE} height={SIZE} />
|
||||||
|
),
|
||||||
|
...unusable("automate"),
|
||||||
|
onClick: () => openTool("automate", "/automate"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sharedSign",
|
||||||
|
label: t("home.sharedSign.title", "Shared Signing"),
|
||||||
|
icon: (
|
||||||
|
<LocalIcon icon="draw-outline-rounded" width={SIZE} height={SIZE} />
|
||||||
|
),
|
||||||
|
badge: host?.signingBadge,
|
||||||
|
badgeTone: "warning",
|
||||||
|
...unusable("sharedSign"),
|
||||||
|
onClick: () => openTool("sharedSign", "/shared-sign"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Read at click time, so it's always the mounted app's.
|
||||||
|
const openSettings = () => host?.actions.current?.openSettings?.();
|
||||||
|
|
||||||
|
// A route that isn't the app hides the bar - see useSuppressQuickNavRail.
|
||||||
|
if (!appMounted || host?.chromeless) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QuickNavRailContainer
|
||||||
|
groups={HAS_PORTAL ? [apps, within] : [within]}
|
||||||
|
onReturnHome={returnHome}
|
||||||
|
identity={host?.identity ?? null}
|
||||||
|
onOpenSettings={host?.hasSettings ? openSettings : undefined}
|
||||||
|
onInvite={
|
||||||
|
// Spelt out: VIEW_PATHS lives in the portal, which core cannot import.
|
||||||
|
HAS_PORTAL && host?.portalAccess
|
||||||
|
? () => go(`${PORTAL_BASENAME}/users`)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onToggleNotifications={() =>
|
||||||
|
host?.actions.current?.toggleNotifications?.()
|
||||||
|
}
|
||||||
|
notificationsOpen={host?.notificationsOpen}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import type { AppNotification } from "@app/services/notifications";
|
||||||
|
import { QuickNavRailNotifications } from "@app/components/shared/quickNav/QuickNavRailNotifications";
|
||||||
|
|
||||||
|
const fetchNotifications = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@app/services/notifications", () => ({
|
||||||
|
fetchNotifications: (...args: unknown[]) => fetchNotifications(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@app/services/localFilePresence", () => ({
|
||||||
|
hasLocalFile: () => Promise.resolve(false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const h = vi.hoisted(() => ({ notificationsAvailable: true }));
|
||||||
|
|
||||||
|
vi.mock("@app/components/notifications/useNotificationsAvailable", () => ({
|
||||||
|
useNotificationsAvailable: () => h.notificationsAvailable,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function notification(id: string): AppNotification {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
kind: "PIPELINE_FAILED",
|
||||||
|
title: id,
|
||||||
|
createdAt: "2026-01-01T00:00:00Z",
|
||||||
|
fileId: null,
|
||||||
|
sourceId: null,
|
||||||
|
count: 1,
|
||||||
|
actions: [],
|
||||||
|
} as unknown as AppNotification;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("QuickNavRailNotifications", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
fetchNotifications.mockReset().mockResolvedValue([]);
|
||||||
|
h.notificationsAvailable = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps out of a build with no notifications API, and off its timer", async () => {
|
||||||
|
// No endpoint to poll and nothing it could show.
|
||||||
|
h.notificationsAvailable = false;
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<QuickNavRailNotifications onToggle={() => {}} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(container.querySelector(".quick-nav-rail-item")).toBeNull();
|
||||||
|
expect(fetchNotifications).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("carries the unread count on the icon", async () => {
|
||||||
|
fetchNotifications.mockResolvedValue([
|
||||||
|
notification("a"),
|
||||||
|
notification("b"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<QuickNavRailNotifications onToggle={() => {}} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText("2")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks the mounted app to open the panel rather than opening one itself", async () => {
|
||||||
|
const onToggle = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<QuickNavRailNotifications onToggle={onToggle} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetchNotifications).toHaveBeenCalled());
|
||||||
|
fireEvent.click(container.querySelector(".quick-nav-rail-item")!);
|
||||||
|
|
||||||
|
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||||
|
// No panel of its own: a row's actions would have no workbench to act on.
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays pressable before an app has registered, doing nothing", async () => {
|
||||||
|
// Between apps there is briefly no handler.
|
||||||
|
const { container } = render(<QuickNavRailNotifications />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetchNotifications).toHaveBeenCalled());
|
||||||
|
const button = container.querySelector(".quick-nav-rail-item")!;
|
||||||
|
expect(() => fireEvent.click(button)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
|
import { RailButton } from "@app/components/shared/quickNav/QuickNavRailBase";
|
||||||
|
import { useNotifications } from "@app/hooks/useNotifications";
|
||||||
|
import { useNotificationsAvailable } from "@app/components/notifications/useNotificationsAvailable";
|
||||||
|
import { NOTIFICATIONS_PANEL_ID } from "@app/components/notifications/NotificationPanel";
|
||||||
|
|
||||||
|
export interface QuickNavRailNotificationsProps {
|
||||||
|
onToggle?: () => void;
|
||||||
|
/** Whether the app's panel is open, which this button reports but does not own. */
|
||||||
|
open?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The count is read here; the app owns the panel - see NotificationPanel. */
|
||||||
|
export function QuickNavRailNotifications({
|
||||||
|
onToggle,
|
||||||
|
open = false,
|
||||||
|
}: QuickNavRailNotificationsProps) {
|
||||||
|
// Gated before the count is read: subscribing starts the poll.
|
||||||
|
const available = useNotificationsAvailable();
|
||||||
|
if (!available) return null;
|
||||||
|
return <MountedRailNotifications onToggle={onToggle} open={open} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MountedRailNotifications({
|
||||||
|
onToggle,
|
||||||
|
open,
|
||||||
|
}: QuickNavRailNotificationsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { unreadCount } = useNotifications();
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Read by the panel's outside-click handler; on a wrapper, RailButton's props being fixed.
|
||||||
|
<span data-notifications-trigger>
|
||||||
|
<RailButton
|
||||||
|
label={t("quickNav.notifications", "Notifications")}
|
||||||
|
icon={
|
||||||
|
<LocalIcon
|
||||||
|
icon="notifications-outline-rounded"
|
||||||
|
width="1.125rem"
|
||||||
|
height="1.125rem"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
badge={unreadCount}
|
||||||
|
expanded={Boolean(open)}
|
||||||
|
controls={NOTIFICATIONS_PANEL_ID}
|
||||||
|
onClick={() => onToggle?.()}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { useQuickNavToolReasons } from "@app/components/shared/quickNav/useQuickNavToolReasons";
|
||||||
|
|
||||||
|
const h = vi.hoisted(() => ({
|
||||||
|
endpointStatus: {} as Record<string, boolean>,
|
||||||
|
endpointDetails: {} as Record<string, { reason?: string }>,
|
||||||
|
loading: false,
|
||||||
|
configLoading: false,
|
||||||
|
groupSigningEnabled: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@app/hooks/useEndpointConfig", () => ({
|
||||||
|
useMultipleEndpointsEnabled: () => ({
|
||||||
|
endpointStatus: h.endpointStatus,
|
||||||
|
endpointDetails: h.endpointDetails,
|
||||||
|
loading: h.loading,
|
||||||
|
error: null,
|
||||||
|
refetch: async () => {},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@app/contexts/AppConfigContext", () => ({
|
||||||
|
useAppConfig: () => ({
|
||||||
|
config: null,
|
||||||
|
loading: h.configLoading,
|
||||||
|
error: null,
|
||||||
|
refetch: async () => {},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@app/hooks/useGroupSigningEnabled", () => ({
|
||||||
|
useGroupSigningEnabled: () => h.groupSigningEnabled,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (_key: string, fallback?: string) => fallback ?? _key,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("useQuickNavToolReasons", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
h.endpointStatus = {};
|
||||||
|
h.endpointDetails = {};
|
||||||
|
h.loading = false;
|
||||||
|
h.configLoading = false;
|
||||||
|
h.groupSigningEnabled = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admits it does not know rather than reporting nothing wrong", () => {
|
||||||
|
h.loading = true;
|
||||||
|
h.endpointStatus = { automate: false };
|
||||||
|
|
||||||
|
expect(
|
||||||
|
renderHook(() => useQuickNavToolReasons()).result.current,
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports what it last knew while the answer is being fetched again", () => {
|
||||||
|
// Each app has its own query cache, and a reload has none at all.
|
||||||
|
h.endpointStatus = { automate: false };
|
||||||
|
h.endpointDetails = { automate: { reason: "CONFIG" } };
|
||||||
|
renderHook(() => useQuickNavToolReasons());
|
||||||
|
|
||||||
|
h.loading = true;
|
||||||
|
h.endpointStatus = {};
|
||||||
|
h.endpointDetails = {};
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuickNavToolReasons());
|
||||||
|
expect(result.current?.automate).toBe("Disabled by server administrator");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forgets a reason once the server stops reporting it", () => {
|
||||||
|
h.endpointStatus = { automate: false };
|
||||||
|
h.endpointDetails = { automate: { reason: "CONFIG" } };
|
||||||
|
renderHook(() => useQuickNavToolReasons());
|
||||||
|
|
||||||
|
h.endpointStatus = { automate: true };
|
||||||
|
h.endpointDetails = {};
|
||||||
|
expect(renderHook(() => useQuickNavToolReasons()).result.current).toEqual(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
// The cleared state, not the old reason, is what a reload reads back.
|
||||||
|
h.loading = true;
|
||||||
|
expect(renderHook(() => useQuickNavToolReasons()).result.current).toEqual(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing about an endpoint the server reports as available", () => {
|
||||||
|
h.endpointStatus = { automate: true };
|
||||||
|
|
||||||
|
expect(renderHook(() => useQuickNavToolReasons()).result.current).toEqual(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blames the administrator when the endpoint was turned off by config", () => {
|
||||||
|
h.endpointStatus = { automate: false };
|
||||||
|
h.endpointDetails = { automate: { reason: "CONFIG" } };
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuickNavToolReasons());
|
||||||
|
// The tool picker's label with its trailing colon stripped.
|
||||||
|
expect(result.current?.automate).toBe("Disabled by server administrator");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blames the missing dependency when that is what the server said", () => {
|
||||||
|
h.endpointStatus = { automate: false };
|
||||||
|
h.endpointDetails = { automate: { reason: "DEPENDENCY" } };
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuickNavToolReasons());
|
||||||
|
expect(result.current?.automate).toBe(
|
||||||
|
"Unavailable - required tool missing on server",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("greys out shared signing when the server has the feature switched off", () => {
|
||||||
|
// A whole feature rather than a removable endpoint, so it has its own signal.
|
||||||
|
h.groupSigningEnabled = false;
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useQuickNavToolReasons());
|
||||||
|
expect(result.current?.sharedSign).toBe(
|
||||||
|
"Collaborative signing isn't enabled on this server",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for the config before judging shared signing", () => {
|
||||||
|
// The config loads separately and reads as "off" before it arrives.
|
||||||
|
h.configLoading = true;
|
||||||
|
h.groupSigningEnabled = false;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
renderHook(() => useQuickNavToolReasons()).result.current,
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useMultipleEndpointsEnabled } from "@app/hooks/useEndpointConfig";
|
||||||
|
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||||
|
import { useGroupSigningEnabled } from "@app/hooks/useGroupSigningEnabled";
|
||||||
|
import { getDisabledLabel } from "@app/components/tools/fullscreen/shared";
|
||||||
|
import type { QuickNavToolReasons } from "@app/contexts/QuickNavHostContext";
|
||||||
|
import type { ToolId } from "@app/types/toolId";
|
||||||
|
|
||||||
|
const ENTRY_ENDPOINTS = {
|
||||||
|
automate: ["automate"],
|
||||||
|
} satisfies Partial<Record<ToolId, string[]>>;
|
||||||
|
|
||||||
|
// Object.keys widens to string, which a tool-id-keyed record can't be indexed by.
|
||||||
|
const ENDPOINT_ENTRIES = Object.keys(
|
||||||
|
ENTRY_ENDPOINTS,
|
||||||
|
) as (keyof typeof ENTRY_ENDPOINTS)[];
|
||||||
|
|
||||||
|
/** Shared signing is a feature toggle rather than an endpoint, so it has its own cause. */
|
||||||
|
type EndpointCause = "missingDependency" | "disabledByAdmin";
|
||||||
|
type Cause = EndpointCause | "groupSigningOff";
|
||||||
|
type Causes = Partial<Record<ToolId, Cause>>;
|
||||||
|
const CAUSES: Cause[] = [
|
||||||
|
"missingDependency",
|
||||||
|
"disabledByAdmin",
|
||||||
|
"groupSigningOff",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Causes, not sentences, so a language change can't resurrect stale text. */
|
||||||
|
const STORAGE_KEY = "stirling.quickNav.toolCauses";
|
||||||
|
|
||||||
|
function readRemembered(): Causes | null {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const known = Object.entries(parsed as Record<string, unknown>).filter(
|
||||||
|
([, cause]) => CAUSES.includes(cause as Cause),
|
||||||
|
) as [ToolId, Cause][];
|
||||||
|
return Object.fromEntries(known);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remember(causes: Causes): void {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(causes));
|
||||||
|
} catch {
|
||||||
|
// Won't survive the next reload.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function causesFor(
|
||||||
|
endpointStatus: Record<string, boolean>,
|
||||||
|
endpointDetails: Record<string, { reason?: string | null }>,
|
||||||
|
): Causes {
|
||||||
|
const causes: Causes = {};
|
||||||
|
for (const entry of ENDPOINT_ENTRIES) {
|
||||||
|
const off = ENTRY_ENDPOINTS[entry].filter(
|
||||||
|
(name) => endpointStatus[name] === false,
|
||||||
|
);
|
||||||
|
if (off.length === 0) continue;
|
||||||
|
causes[entry] = off.some(
|
||||||
|
(name) => endpointDetails[name]?.reason === "DEPENDENCY",
|
||||||
|
)
|
||||||
|
? "missingDependency"
|
||||||
|
: "disabledByAdmin";
|
||||||
|
}
|
||||||
|
return causes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why a rail entry can't be used. Null means no answer yet, an empty map nothing wrong. */
|
||||||
|
export function useQuickNavToolReasons(): QuickNavToolReasons | null {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const endpoints = useMemo(() => Object.values(ENTRY_ENDPOINTS).flat(), []);
|
||||||
|
const { endpointStatus, endpointDetails, loading } =
|
||||||
|
useMultipleEndpointsEnabled(endpoints);
|
||||||
|
|
||||||
|
// Read once: later reads would fight the live answer.
|
||||||
|
const [remembered] = useState(readRemembered);
|
||||||
|
|
||||||
|
const { loading: configLoading } = useAppConfig();
|
||||||
|
const groupSigningEnabled = useGroupSigningEnabled();
|
||||||
|
|
||||||
|
const live = useMemo(() => {
|
||||||
|
// A half answer would dim entries it can't see yet.
|
||||||
|
if (loading || configLoading) return null;
|
||||||
|
const causes = causesFor(endpointStatus, endpointDetails);
|
||||||
|
if (!groupSigningEnabled) causes.sharedSign = "groupSigningOff";
|
||||||
|
return causes;
|
||||||
|
}, [
|
||||||
|
loading,
|
||||||
|
configLoading,
|
||||||
|
endpointStatus,
|
||||||
|
endpointDetails,
|
||||||
|
groupSigningEnabled,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Keyed on contents: the object is rebuilt every render.
|
||||||
|
const liveKey = live ? JSON.stringify(live) : null;
|
||||||
|
useEffect(() => {
|
||||||
|
if (liveKey) remember(JSON.parse(liveKey) as Causes);
|
||||||
|
}, [liveKey]);
|
||||||
|
|
||||||
|
const causes = live ?? remembered;
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!causes) return null;
|
||||||
|
const reasons: QuickNavToolReasons = {};
|
||||||
|
for (const entry of Object.keys(causes) as ToolId[]) {
|
||||||
|
const cause = causes[entry];
|
||||||
|
if (cause === "groupSigningOff") {
|
||||||
|
// The tool's own wording, minus the full stop.
|
||||||
|
reasons[entry] = t(
|
||||||
|
"sharedSign.disabledBody",
|
||||||
|
"Collaborative signing isn't enabled on this server.",
|
||||||
|
).replace(/\.\s*$/, "");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!cause) continue;
|
||||||
|
// These labels normally sit in front of a tool name, hence the trailing colon.
|
||||||
|
const { key, fallback } = getDisabledLabel(cause);
|
||||||
|
reasons[entry] = t(key, fallback).replace(/:\s*$/, "");
|
||||||
|
}
|
||||||
|
return reasons;
|
||||||
|
}, [causes, t]);
|
||||||
|
}
|
||||||
@@ -115,7 +115,7 @@ export default function RightSidebar() {
|
|||||||
|
|
||||||
const computedWidth = () => {
|
const computedWidth = () => {
|
||||||
if (isMobile) return "100%";
|
if (isMobile) return "100%";
|
||||||
if (!isPanelVisible) return "3.5rem";
|
if (!isPanelVisible) return "var(--nav-rail-w)";
|
||||||
return expandedWidth;
|
return expandedWidth;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -181,7 +181,8 @@ export default function RightSidebar() {
|
|||||||
content={tool.name}
|
content={tool.name}
|
||||||
position="left"
|
position="left"
|
||||||
arrow
|
arrow
|
||||||
delay={300}
|
// No delay: collapsed to icons, the tooltip is the only label.
|
||||||
|
delay={0}
|
||||||
>
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
aria-label={tool.name}
|
aria-label={tool.name}
|
||||||
@@ -206,7 +207,7 @@ export default function RightSidebar() {
|
|||||||
/* Fixed width matches the expanded panel width so the inner content is
|
/* Fixed width matches the expanded panel width so the inner content is
|
||||||
laid out at its final size from the moment it mounts. The outer
|
laid out at its final size from the moment it mounts. The outer
|
||||||
.tool-panel clips it (overflow-hidden) while it animates from the
|
.tool-panel clips it (overflow-hidden) while it animates from the
|
||||||
collapsed 3.5rem width — text/icons stay put and just come into view
|
collapsed rail width — text/icons stay put and just come into view
|
||||||
instead of jiggling as space becomes available. */
|
instead of jiggling as space becomes available. */
|
||||||
style={{
|
style={{
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
|
|||||||
@@ -19,12 +19,12 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Flush, with a rule on the workbench side mirroring the file sidebar's. */
|
||||||
.tool-panel--floating {
|
.tool-panel--floating {
|
||||||
margin: var(--nav-gutter) var(--nav-gutter) var(--nav-gutter) 0;
|
/* Tracks the frame, which is dvh; vh would cut the bottom off on mobile. */
|
||||||
height: calc(100vh - (var(--nav-gutter) * 2));
|
height: 100%;
|
||||||
background: var(--c-surface);
|
background: var(--c-surface);
|
||||||
border: 1px solid var(--c-border-subtle);
|
border-inline-start: 1px solid var(--c-border-subtle);
|
||||||
border-radius: var(--radius-nav);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-panel__collapsed-strip {
|
.tool-panel__collapsed-strip {
|
||||||
|
|||||||
@@ -185,23 +185,24 @@ export function useViewerWorkbenchBarButtons(
|
|||||||
section: "top" as const,
|
section: "top" as const,
|
||||||
order: 10,
|
order: 10,
|
||||||
render: ({ disabled }) => (
|
render: ({ disabled }) => (
|
||||||
<Tooltip
|
<Popover
|
||||||
content={searchLabel}
|
|
||||||
position={tooltipPosition}
|
position={tooltipPosition}
|
||||||
offset={12}
|
withArrow
|
||||||
arrow
|
shadow="md"
|
||||||
portalTarget={document.body}
|
offset={8}
|
||||||
|
opened={isSearchInterfaceVisible}
|
||||||
|
onClose={viewer.searchInterfaceActions.close}
|
||||||
>
|
>
|
||||||
<Popover
|
<Popover.Target>
|
||||||
position={tooltipPosition}
|
<div style={{ display: "inline-flex" }}>
|
||||||
withArrow
|
{/* Inside the Popover: Tooltip binds by cloning, and Popover passes no ref on. */}
|
||||||
shadow="md"
|
<Tooltip
|
||||||
offset={8}
|
content={searchLabel}
|
||||||
opened={isSearchInterfaceVisible}
|
position={tooltipPosition}
|
||||||
onClose={viewer.searchInterfaceActions.close}
|
offset={12}
|
||||||
>
|
arrow
|
||||||
<Popover.Target>
|
portalTarget={document.body}
|
||||||
<div style={{ display: "inline-flex" }}>
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
className="workbench-bar-action-icon"
|
className="workbench-bar-action-icon"
|
||||||
@@ -215,18 +216,18 @@ export function useViewerWorkbenchBarButtons(
|
|||||||
height="1.25rem"
|
height="1.25rem"
|
||||||
/>
|
/>
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</div>
|
</Tooltip>
|
||||||
</Popover.Target>
|
</div>
|
||||||
<Popover.Dropdown>
|
</Popover.Target>
|
||||||
<div style={{ minWidth: "20rem" }}>
|
<Popover.Dropdown>
|
||||||
<SearchInterface
|
<div style={{ minWidth: "20rem" }}>
|
||||||
visible={isSearchInterfaceVisible}
|
<SearchInterface
|
||||||
onClose={viewer.searchInterfaceActions.close}
|
visible={isSearchInterfaceVisible}
|
||||||
/>
|
onClose={viewer.searchInterfaceActions.close}
|
||||||
</div>
|
/>
|
||||||
</Popover.Dropdown>
|
</div>
|
||||||
</Popover>
|
</Popover.Dropdown>
|
||||||
</Tooltip>
|
</Popover>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, act } from "@testing-library/react";
|
||||||
|
import {
|
||||||
|
QuickNavHostProvider,
|
||||||
|
useQuickNavHost,
|
||||||
|
useRegisterQuickNavHost,
|
||||||
|
useSuppressQuickNavRail,
|
||||||
|
} from "@app/contexts/QuickNavHostContext";
|
||||||
|
|
||||||
|
function Probe({ onRead }: { onRead: (value: unknown) => void }) {
|
||||||
|
const host = useQuickNavHost();
|
||||||
|
onRead({
|
||||||
|
appMounted: host?.appMounted,
|
||||||
|
chromeless: host?.chromeless,
|
||||||
|
identity: host?.identity,
|
||||||
|
openSettings: Boolean(host?.actions.current?.openSettings),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
useRegisterQuickNavHost(
|
||||||
|
{ identity: { displayName: "Ada", profilePictureUrl: null } },
|
||||||
|
{ openSettings: () => {} },
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginRoute() {
|
||||||
|
useSuppressQuickNavRail();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
let latest: Record<string, unknown> = {};
|
||||||
|
const view = render(
|
||||||
|
<QuickNavHostProvider>
|
||||||
|
<Probe onRead={(value) => (latest = value as Record<string, unknown>)} />
|
||||||
|
<App />
|
||||||
|
</QuickNavHostProvider>,
|
||||||
|
);
|
||||||
|
return { view, read: () => latest };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("QuickNavHostContext", () => {
|
||||||
|
it("keeps what the app published after it unmounts, but drops its handlers", () => {
|
||||||
|
// Data survives the gap between one app unmounting and the next registering.
|
||||||
|
const { view, read } = setup();
|
||||||
|
|
||||||
|
expect(read().appMounted).toBe(true);
|
||||||
|
expect(read().identity).toEqual({
|
||||||
|
displayName: "Ada",
|
||||||
|
profilePictureUrl: null,
|
||||||
|
});
|
||||||
|
expect(read().openSettings).toBe(true);
|
||||||
|
|
||||||
|
view.rerender(
|
||||||
|
<QuickNavHostProvider>
|
||||||
|
<Probe onRead={() => {}} />
|
||||||
|
</QuickNavHostProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Re-read through a fresh probe in the same provider.
|
||||||
|
let after: Record<string, unknown> = {};
|
||||||
|
view.rerender(
|
||||||
|
<QuickNavHostProvider>
|
||||||
|
<Probe onRead={(value) => (after = value as Record<string, unknown>)} />
|
||||||
|
</QuickNavHostProvider>,
|
||||||
|
);
|
||||||
|
expect(after.appMounted).toBe(true);
|
||||||
|
expect(after.openSettings).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the bar while a route with no app chrome is on screen", () => {
|
||||||
|
// appMounted is sticky, so it can't answer "is an app on screen now".
|
||||||
|
const { view, read } = setup();
|
||||||
|
expect(read().chromeless).toBe(false);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
view.rerender(
|
||||||
|
<QuickNavHostProvider>
|
||||||
|
<Probe onRead={() => {}} />
|
||||||
|
<App />
|
||||||
|
<LoginRoute />
|
||||||
|
</QuickNavHostProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let during: Record<string, unknown> = {};
|
||||||
|
view.rerender(
|
||||||
|
<QuickNavHostProvider>
|
||||||
|
<Probe
|
||||||
|
onRead={(value) => (during = value as Record<string, unknown>)}
|
||||||
|
/>
|
||||||
|
<App />
|
||||||
|
<LoginRoute />
|
||||||
|
</QuickNavHostProvider>,
|
||||||
|
);
|
||||||
|
expect(during.chromeless).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("brings the bar back when that route leaves", () => {
|
||||||
|
const { view } = setup();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
view.rerender(
|
||||||
|
<QuickNavHostProvider>
|
||||||
|
<Probe onRead={() => {}} />
|
||||||
|
<App />
|
||||||
|
<LoginRoute />
|
||||||
|
</QuickNavHostProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let after: Record<string, unknown> = {};
|
||||||
|
act(() => {
|
||||||
|
view.rerender(
|
||||||
|
<QuickNavHostProvider>
|
||||||
|
<Probe
|
||||||
|
onRead={(value) => (after = value as Record<string, unknown>)}
|
||||||
|
/>
|
||||||
|
<App />
|
||||||
|
</QuickNavHostProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(after.chromeless).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import type { ToolId } from "@app/types/toolId";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
export type QuickNavToolReasons = Partial<Record<ToolId, string>>;
|
||||||
|
|
||||||
|
export interface QuickNavIdentity {
|
||||||
|
displayName: string;
|
||||||
|
profilePictureUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuickNavHostData {
|
||||||
|
/** Sticky: one app unmounts before the next one registers. */
|
||||||
|
appMounted: boolean;
|
||||||
|
identity: QuickNavIdentity | null;
|
||||||
|
signingBadge: number;
|
||||||
|
portalAccess: boolean;
|
||||||
|
readerMode: boolean;
|
||||||
|
/** The app owns the panel; the rail's bell only reports its state. */
|
||||||
|
notificationsOpen: boolean;
|
||||||
|
/** Translated; absent means usable. */
|
||||||
|
toolReasons: QuickNavToolReasons;
|
||||||
|
/** Mirrors `openSettings`, which lives in a ref and so cannot trigger a render. */
|
||||||
|
hasSettings: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuickNavHostActions {
|
||||||
|
openSettings?: () => void;
|
||||||
|
/** The editor reads its tool from the URL only on mount. */
|
||||||
|
selectTool?: (toolId: ToolId) => void;
|
||||||
|
setReaderMode?: (on: boolean) => void;
|
||||||
|
toggleNotifications?: () => void;
|
||||||
|
goToDefaultState?: () => void;
|
||||||
|
requestNavigation?: (go: () => void) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuickNavHostValue extends QuickNavHostData {
|
||||||
|
/** Reset on unmount, unlike the data above. */
|
||||||
|
chromeless: boolean;
|
||||||
|
setChromeless: (chromeless: boolean) => void;
|
||||||
|
/** A ref, so a click reaches the app currently mounted. */
|
||||||
|
actions: React.RefObject<QuickNavHostActions>;
|
||||||
|
setData: (data: Partial<QuickNavHostData>) => void;
|
||||||
|
setActions: (actions: QuickNavHostActions) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_REASONS: QuickNavToolReasons = {};
|
||||||
|
|
||||||
|
const EMPTY_DATA: QuickNavHostData = {
|
||||||
|
appMounted: false,
|
||||||
|
toolReasons: EMPTY_REASONS,
|
||||||
|
identity: null,
|
||||||
|
signingBadge: 0,
|
||||||
|
portalAccess: false,
|
||||||
|
readerMode: false,
|
||||||
|
notificationsOpen: false,
|
||||||
|
hasSettings: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function sameReasons(
|
||||||
|
next: QuickNavToolReasons,
|
||||||
|
prev: QuickNavToolReasons,
|
||||||
|
): boolean {
|
||||||
|
const nextKeys = Object.keys(next);
|
||||||
|
if (nextKeys.length !== Object.keys(prev).length) return false;
|
||||||
|
return nextKeys.every((key) => next[key as ToolId] === prev[key as ToolId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const QuickNavHostContext = createContext<QuickNavHostValue | null>(null);
|
||||||
|
|
||||||
|
/** Outside both apps' providers, so each app registers what only it knows. */
|
||||||
|
export function QuickNavHostProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [data, setDataState] = useState<QuickNavHostData>(EMPTY_DATA);
|
||||||
|
const [chromeless, setChromelessState] = useState(false);
|
||||||
|
const actions = useRef<QuickNavHostActions>({});
|
||||||
|
|
||||||
|
const setData = useCallback((next: Partial<QuickNavHostData>) => {
|
||||||
|
setDataState((prev) => {
|
||||||
|
const merged = { ...prev, ...next };
|
||||||
|
const unchanged =
|
||||||
|
merged.appMounted === prev.appMounted &&
|
||||||
|
merged.signingBadge === prev.signingBadge &&
|
||||||
|
merged.portalAccess === prev.portalAccess &&
|
||||||
|
merged.readerMode === prev.readerMode &&
|
||||||
|
merged.notificationsOpen === prev.notificationsOpen &&
|
||||||
|
merged.hasSettings === prev.hasSettings &&
|
||||||
|
merged.identity?.displayName === prev.identity?.displayName &&
|
||||||
|
merged.identity?.profilePictureUrl ===
|
||||||
|
prev.identity?.profilePictureUrl &&
|
||||||
|
// Compared by value: the object is rebuilt every render.
|
||||||
|
sameReasons(merged.toolReasons, prev.toolReasons);
|
||||||
|
return unchanged ? prev : merged;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setActions = useCallback((next: QuickNavHostActions) => {
|
||||||
|
actions.current = next;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setChromeless = useCallback((next: boolean) => {
|
||||||
|
setChromelessState(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo<QuickNavHostValue>(
|
||||||
|
() => ({
|
||||||
|
...data,
|
||||||
|
chromeless,
|
||||||
|
actions,
|
||||||
|
setData,
|
||||||
|
setActions,
|
||||||
|
setChromeless,
|
||||||
|
}),
|
||||||
|
[data, chromeless, setData, setActions, setChromeless],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QuickNavHostContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</QuickNavHostContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useQuickNavHost(): QuickNavHostValue | null {
|
||||||
|
return useContext(QuickNavHostContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No-ops outside the provider. */
|
||||||
|
export function useRegisterQuickNavHost(
|
||||||
|
data: Partial<QuickNavHostData>,
|
||||||
|
actions: QuickNavHostActions,
|
||||||
|
): void {
|
||||||
|
const host = useQuickNavHost();
|
||||||
|
const {
|
||||||
|
identity,
|
||||||
|
signingBadge,
|
||||||
|
portalAccess,
|
||||||
|
readerMode,
|
||||||
|
notificationsOpen,
|
||||||
|
toolReasons,
|
||||||
|
} = data;
|
||||||
|
const hasSettings = Boolean(actions.openSettings);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
host?.setData({
|
||||||
|
appMounted: true,
|
||||||
|
identity: identity ?? null,
|
||||||
|
signingBadge: signingBadge ?? 0,
|
||||||
|
portalAccess: portalAccess ?? false,
|
||||||
|
readerMode: readerMode ?? false,
|
||||||
|
notificationsOpen: notificationsOpen ?? false,
|
||||||
|
// Omitted when unknown, so the last answer survives a re-fetch.
|
||||||
|
...(toolReasons ? { toolReasons } : {}),
|
||||||
|
hasSettings,
|
||||||
|
});
|
||||||
|
// By field: identity is rebuilt every render.
|
||||||
|
}, [
|
||||||
|
host,
|
||||||
|
identity?.displayName,
|
||||||
|
identity?.profilePictureUrl,
|
||||||
|
signingBadge,
|
||||||
|
portalAccess,
|
||||||
|
readerMode,
|
||||||
|
notificationsOpen,
|
||||||
|
toolReasons,
|
||||||
|
hasSettings,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const setActions = host?.setActions;
|
||||||
|
|
||||||
|
// No deps: a click has to reach the current closure.
|
||||||
|
useEffect(() => {
|
||||||
|
setActions?.(actions);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handlers only: clearing the data too would blink the controls mid-switch.
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
setActions?.({});
|
||||||
|
},
|
||||||
|
[setActions],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `appMounted` is sticky, so a screen that isn't the app has to say so itself. */
|
||||||
|
export function useSuppressQuickNavRail(active = true): void {
|
||||||
|
const host = useQuickNavHost();
|
||||||
|
const setChromeless = host?.setChromeless;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
setChromeless?.(true);
|
||||||
|
return () => setChromeless?.(false);
|
||||||
|
}, [active, setChromeless]);
|
||||||
|
}
|
||||||
@@ -62,6 +62,11 @@ export function SidebarProvider({ children }: SidebarProviderProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** For components that render outside a SidebarProvider, such as the rail's tooltips. */
|
||||||
|
export function useOptionalSidebarContext(): SidebarContextValue | undefined {
|
||||||
|
return useContext(SidebarContext);
|
||||||
|
}
|
||||||
|
|
||||||
export function useSidebarContext(): SidebarContextValue {
|
export function useSidebarContext(): SidebarContextValue {
|
||||||
const context = useContext(SidebarContext);
|
const context = useContext(SidebarContext);
|
||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
|
|||||||
@@ -218,8 +218,8 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
|||||||
const setReaderMode = useCallback(
|
const setReaderMode = useCallback(
|
||||||
(mode: boolean) => {
|
(mode: boolean) => {
|
||||||
if (mode) {
|
if (mode) {
|
||||||
|
// Reading is a mode the open document is put into, not a tool run on it.
|
||||||
actions.setWorkbench("viewer");
|
actions.setWorkbench("viewer");
|
||||||
actions.setSelectedTool("read");
|
|
||||||
}
|
}
|
||||||
dispatch({ type: "SET_READER_MODE", payload: mode });
|
dispatch({ type: "SET_READER_MODE", payload: mode });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { forwardRef, useCallback, useEffect, useRef, useState } from "react";
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||||
import { Group } from "@mantine/core";
|
import { Group } from "@mantine/core";
|
||||||
@@ -14,6 +21,7 @@ import { useFileContext } from "@app/contexts/file/fileHooks";
|
|||||||
import {
|
import {
|
||||||
useNavigationState,
|
useNavigationState,
|
||||||
useNavigationActions,
|
useNavigationActions,
|
||||||
|
useNavigationGuard,
|
||||||
} from "@app/contexts/NavigationContext";
|
} from "@app/contexts/NavigationContext";
|
||||||
import { isApplyingRestoredView } from "@app/services/workbenchSession";
|
import { isApplyingRestoredView } from "@app/services/workbenchSession";
|
||||||
import { useViewer } from "@app/contexts/ViewerContext";
|
import { useViewer } from "@app/contexts/ViewerContext";
|
||||||
@@ -28,10 +36,21 @@ import FileSidebar from "@app/components/shared/FileSidebar";
|
|||||||
import FileManager from "@app/components/FileManager";
|
import FileManager from "@app/components/FileManager";
|
||||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
import AppConfigModal from "@app/components/shared/AppConfigModalLazy";
|
import AppConfigModal from "@app/components/shared/AppConfigModalLazy";
|
||||||
import { getStartupNavigationAction } from "@app/utils/homePageNavigation";
|
import {
|
||||||
|
getStartupNavigationAction,
|
||||||
|
getDefaultWorkbenchForFileCount,
|
||||||
|
} from "@app/utils/homePageNavigation";
|
||||||
import { EDITOR_BASENAME } from "@app/routes/editorBasename";
|
import { EDITOR_BASENAME } from "@app/routes/editorBasename";
|
||||||
import { stripBasePath } from "@app/constants/app";
|
import { stripBasePath } from "@app/constants/app";
|
||||||
import { HomePageExtensions } from "@app/components/home/HomePageExtensions";
|
import { HomePageExtensions } from "@app/components/home/HomePageExtensions";
|
||||||
|
import { QuickNavHostBridge } from "@app/components/shared/quickNav/QuickNavHostBridge";
|
||||||
|
import type { QuickNavToolReasons } from "@app/contexts/QuickNavHostContext";
|
||||||
|
import {
|
||||||
|
getToolDisabledReason,
|
||||||
|
getDisabledLabel,
|
||||||
|
} from "@app/components/tools/fullscreen/shared";
|
||||||
|
import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch";
|
||||||
|
import { consumeReaderModeRequest } from "@app/utils/pendingReaderMode";
|
||||||
import {
|
import {
|
||||||
FilesPageProvider,
|
FilesPageProvider,
|
||||||
useFilesPage,
|
useFilesPage,
|
||||||
@@ -42,6 +61,7 @@ import { FolderTreePanel } from "@app/components/filesPage/FolderTreePanel";
|
|||||||
import type { FileSidebarProps } from "@app/components/shared/FileSidebar";
|
import type { FileSidebarProps } from "@app/components/shared/FileSidebar";
|
||||||
|
|
||||||
import { Button } from "@app/ui/Button";
|
import { Button } from "@app/ui/Button";
|
||||||
|
import "@app/components/layout/WorkspaceFrame.css";
|
||||||
import "@app/pages/HomePage.css";
|
import "@app/pages/HomePage.css";
|
||||||
|
|
||||||
const SIDEBAR_COLLAPSED_STORAGE_KEY = "stirling.fileSidebarCollapsed";
|
const SIDEBAR_COLLAPSED_STORAGE_KEY = "stirling.fileSidebarCollapsed";
|
||||||
@@ -90,9 +110,11 @@ export default function HomePage() {
|
|||||||
handleToolSelect,
|
handleToolSelect,
|
||||||
handleBackToTools,
|
handleBackToTools,
|
||||||
readerMode,
|
readerMode,
|
||||||
|
setReaderMode,
|
||||||
setLeftPanelView,
|
setLeftPanelView,
|
||||||
toolAvailability,
|
toolAvailability,
|
||||||
customWorkbenchViews,
|
customWorkbenchViews,
|
||||||
|
toolRegistry,
|
||||||
} = useToolWorkflow();
|
} = useToolWorkflow();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -103,6 +125,7 @@ export default function HomePage() {
|
|||||||
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
|
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
|
||||||
const isProgrammaticScroll = useRef(false);
|
const isProgrammaticScroll = useRef(false);
|
||||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||||
|
const otherApp = useOtherAppSwitch();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
// Persisted user preference for the FileSidebar collapsed state. Auto-
|
// Persisted user preference for the FileSidebar collapsed state. Auto-
|
||||||
// collapse on /files is layered on top in the transition effect below and
|
// collapse on /files is layered on top in the transition effect below and
|
||||||
@@ -152,8 +175,64 @@ export default function HomePage() {
|
|||||||
|
|
||||||
const { activeFiles } = useFileContext();
|
const { activeFiles } = useFileContext();
|
||||||
const navigationState = useNavigationState();
|
const navigationState = useNavigationState();
|
||||||
|
const { requestNavigation } = useNavigationGuard();
|
||||||
|
|
||||||
|
// From the processor's Reader entry. Ref-guarded: one-shot, and StrictMode double-invokes.
|
||||||
|
const consumedReaderRequest = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (consumedReaderRequest.current) return;
|
||||||
|
consumedReaderRequest.current = true;
|
||||||
|
if (consumeReaderModeRequest()) setReaderMode(true);
|
||||||
|
}, [setReaderMode]);
|
||||||
const { actions } = useNavigationActions();
|
const { actions } = useNavigationActions();
|
||||||
|
|
||||||
|
const { searchInterfaceActions } = useViewer();
|
||||||
|
|
||||||
|
// Reading hides both search controls, so leave it first. e.code, for non-QWERTY layouts.
|
||||||
|
const focusSearchAfterRestore = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!readerMode) return;
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
const combo = (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey;
|
||||||
|
if (!combo) return;
|
||||||
|
if (e.code !== "KeyK" && e.code !== "KeyF") return;
|
||||||
|
// Same carve-out the search itself makes: a dialog owns the keyboard.
|
||||||
|
if ((e.target as HTMLElement | null)?.closest?.('[role="dialog"]'))
|
||||||
|
return;
|
||||||
|
e.preventDefault();
|
||||||
|
setReaderMode(false);
|
||||||
|
if (e.code === "KeyK") {
|
||||||
|
focusSearchAfterRestore.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Visibility is state, so it can open before the bar it renders in exists.
|
||||||
|
searchInterfaceActions.open();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, [readerMode, setReaderMode, searchInterfaceActions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (readerMode || !focusSearchAfterRestore.current) return;
|
||||||
|
focusSearchAfterRestore.current = false;
|
||||||
|
requestAnimationFrame(() =>
|
||||||
|
window.dispatchEvent(new Event("superSearch:focus")),
|
||||||
|
);
|
||||||
|
}, [readerMode]);
|
||||||
|
|
||||||
|
// Clean slate: no tool, out of the file library and reading.
|
||||||
|
const goToDefaultState = useCallback(() => {
|
||||||
|
handleBackToTools();
|
||||||
|
if (location.pathname.startsWith("/files")) navigate(EDITOR_BASENAME);
|
||||||
|
actions.setWorkbench(getDefaultWorkbenchForFileCount(activeFiles.length));
|
||||||
|
}, [
|
||||||
|
handleBackToTools,
|
||||||
|
location.pathname,
|
||||||
|
navigate,
|
||||||
|
actions,
|
||||||
|
activeFiles.length,
|
||||||
|
]);
|
||||||
|
|
||||||
// Sync the /files* URL into the workbench state so the file manager view
|
// Sync the /files* URL into the workbench state so the file manager view
|
||||||
// takes over the workbench area when the user lands on it. This is the
|
// takes over the workbench area when the user lands on it. This is the
|
||||||
// only state-of-truth for the active workbench, so keep the URL pinned.
|
// only state-of-truth for the active workbench, so keep the URL pinned.
|
||||||
@@ -194,6 +273,17 @@ export default function HomePage() {
|
|||||||
prevWorkbenchRef.current = curr;
|
prevWorkbenchRef.current = curr;
|
||||||
// fileSidebarCollapsed read as snapshot on transition only.
|
// fileSidebarCollapsed read as snapshot on transition only.
|
||||||
}, [navigationState.workbench]);
|
}, [navigationState.workbench]);
|
||||||
|
// Imperative, so the toggle still works while reading. Never persisted: not a preference.
|
||||||
|
const prevReaderModeRef = useRef(readerMode);
|
||||||
|
useEffect(() => {
|
||||||
|
if (readerMode !== prevReaderModeRef.current) {
|
||||||
|
setFileSidebarCollapsed(
|
||||||
|
readerMode ? true : readPersistedSidebarCollapsed(),
|
||||||
|
);
|
||||||
|
prevReaderModeRef.current = readerMode;
|
||||||
|
}
|
||||||
|
}, [readerMode]);
|
||||||
|
|
||||||
const { setActiveFileIndex } = useViewer();
|
const { setActiveFileIndex } = useViewer();
|
||||||
const prevFileCountRef = useRef(activeFiles.length);
|
const prevFileCountRef = useRef(activeFiles.length);
|
||||||
|
|
||||||
@@ -242,6 +332,38 @@ export default function HomePage() {
|
|||||||
|
|
||||||
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
|
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
|
||||||
|
|
||||||
|
// The tool picker's own helpers, so the wording can't drift.
|
||||||
|
const quickNavToolReasons = useMemo(() => {
|
||||||
|
const reasons: QuickNavToolReasons = {};
|
||||||
|
for (const id of ["automate", "sharedSign"] as const) {
|
||||||
|
const tool = toolRegistry[id];
|
||||||
|
if (!tool) continue;
|
||||||
|
const disabledReason = getToolDisabledReason(
|
||||||
|
id,
|
||||||
|
tool,
|
||||||
|
toolAvailability,
|
||||||
|
config?.premiumEnabled,
|
||||||
|
);
|
||||||
|
if (!disabledReason) continue;
|
||||||
|
const { key, fallback } = getDisabledLabel(disabledReason);
|
||||||
|
reasons[id] = t(key, fallback).replace(/:\s*$/, "");
|
||||||
|
}
|
||||||
|
return reasons;
|
||||||
|
}, [toolRegistry, toolAvailability, config?.premiumEnabled, t]);
|
||||||
|
|
||||||
|
// Shared with the sidebar's own toggle. On /files it leaves rather than collapses.
|
||||||
|
const handleSidebarToggle = useCallback(() => {
|
||||||
|
if (navigationState.workbench === "myFiles") {
|
||||||
|
navigate(EDITOR_BASENAME);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFileSidebarCollapsed((c) => {
|
||||||
|
const next = !c;
|
||||||
|
writePersistedSidebarCollapsed(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [navigationState.workbench, navigate]);
|
||||||
|
|
||||||
const [showSwipeHint, setShowSwipeHint] = useState(
|
const [showSwipeHint, setShowSwipeHint] = useState(
|
||||||
() => !readSwipeHintSeen(),
|
() => !readSwipeHintSeen(),
|
||||||
);
|
);
|
||||||
@@ -395,6 +517,16 @@ export default function HomePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="h-screen overflow-hidden">
|
<div className="h-screen overflow-hidden">
|
||||||
<HomePageExtensions />
|
<HomePageExtensions />
|
||||||
|
<QuickNavHostBridge
|
||||||
|
portalAccess={Boolean(otherApp)}
|
||||||
|
onOpenSettings={() => setConfigModalOpen(true)}
|
||||||
|
requestNavigation={requestNavigation}
|
||||||
|
readerMode={readerMode}
|
||||||
|
onSetReaderMode={setReaderMode}
|
||||||
|
onGoToDefaultState={goToDefaultState}
|
||||||
|
onSelectTool={handleToolSelect}
|
||||||
|
toolReasons={quickNavToolReasons}
|
||||||
|
/>
|
||||||
<FilesPageProvider>
|
<FilesPageProvider>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<div
|
<div
|
||||||
@@ -577,39 +709,30 @@ export default function HomePage() {
|
|||||||
className="flex-nowrap flex"
|
className="flex-nowrap flex"
|
||||||
bg="var(--c-bg)"
|
bg="var(--c-bg)"
|
||||||
>
|
>
|
||||||
<MyFilesAwareFileSidebar
|
<div className="workspace-frame">
|
||||||
ref={quickAccessRef}
|
<MyFilesAwareFileSidebar
|
||||||
active={navigationState.workbench === "myFiles"}
|
ref={quickAccessRef}
|
||||||
// /files always shows the rail collapsed - force it here so a
|
accountHoisted
|
||||||
// deep-link/reload onto /files (no workbench transition) still
|
toggleAriaLabel={
|
||||||
// collapses, and a manual expand can't stick.
|
navigationState.workbench === "myFiles"
|
||||||
collapsed={
|
? t("fileSidebar.leaveMyFiles", "Leave File library")
|
||||||
navigationState.workbench === "myFiles" || fileSidebarCollapsed
|
: undefined
|
||||||
}
|
|
||||||
toggleAriaLabel={
|
|
||||||
navigationState.workbench === "myFiles"
|
|
||||||
? t("fileSidebar.leaveMyFiles", "Leave My Files")
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
// Back-arrow on /files; burger elsewhere.
|
|
||||||
toggleIcon={
|
|
||||||
navigationState.workbench === "myFiles" ? (
|
|
||||||
<ArrowBackIcon />
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
onToggleCollapse={() => {
|
|
||||||
if (navigationState.workbench === "myFiles") {
|
|
||||||
navigate(EDITOR_BASENAME);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
setFileSidebarCollapsed((c) => {
|
toggleIcon={
|
||||||
const next = !c;
|
navigationState.workbench === "myFiles" ? (
|
||||||
writePersistedSidebarCollapsed(next);
|
<ArrowBackIcon />
|
||||||
return next;
|
) : undefined
|
||||||
});
|
}
|
||||||
}}
|
active={navigationState.workbench === "myFiles"}
|
||||||
onOpenSettings={() => setConfigModalOpen(true)}
|
// Forced: a deep link to /files has no transition to collapse on.
|
||||||
/>
|
collapsed={
|
||||||
|
navigationState.workbench === "myFiles" ||
|
||||||
|
fileSidebarCollapsed
|
||||||
|
}
|
||||||
|
onToggleCollapse={handleSidebarToggle}
|
||||||
|
onOpenSettings={() => setConfigModalOpen(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<FolderTreePanel active={navigationState.workbench === "myFiles"} />
|
<FolderTreePanel active={navigationState.workbench === "myFiles"} />
|
||||||
<Workbench />
|
<Workbench />
|
||||||
{!hideToolPanel && <RightSidebar />}
|
{!hideToolPanel && <RightSidebar />}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/** Whether this build ships the processor. Shadowed per build. */
|
||||||
|
export const HAS_PORTAL = false;
|
||||||
@@ -60,7 +60,8 @@ function fixture(filename: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openSamplePdfInViewer(page: import("@playwright/test").Page) {
|
async function openSamplePdfInViewer(page: import("@playwright/test").Page) {
|
||||||
await page.goto("/read");
|
// Not /read: reading collapses the workbench bar these sidebars are toggled from.
|
||||||
|
await page.goto("/");
|
||||||
await page.waitForLoadState("domcontentloaded");
|
await page.waitForLoadState("domcontentloaded");
|
||||||
await page
|
await page
|
||||||
.locator('[data-testid="file-input"]')
|
.locator('[data-testid="file-input"]')
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ const SAMPLE_PDF = path.join(
|
|||||||
);
|
);
|
||||||
|
|
||||||
async function openViewerWithSample(page: import("@playwright/test").Page) {
|
async function openViewerWithSample(page: import("@playwright/test").Page) {
|
||||||
await page.goto("/read");
|
// Not /read: reading collapses the workbench bar these sidebars are toggled from.
|
||||||
|
await page.goto("/");
|
||||||
await page.waitForLoadState("domcontentloaded");
|
await page.waitForLoadState("domcontentloaded");
|
||||||
await page
|
await page
|
||||||
.locator('[data-testid="file-input"]')
|
.locator('[data-testid="file-input"]')
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ async function restoreEnabled(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const NO_RESTORE = "this build ships the workbench restore off";
|
const NO_RESTORE = "this build ships the workbench restore off";
|
||||||
|
const NO_PORTAL = "this build ships no processor to switch to";
|
||||||
|
|
||||||
// Switching editor -> processor unmounts every editor provider; the session record
|
// Switching editor -> processor unmounts every editor provider; the session record
|
||||||
// in sessionStorage is what brings the workbench back on return.
|
// in sessionStorage is what brings the workbench back on return.
|
||||||
@@ -73,8 +74,13 @@ test.describe("Workbench survives the editor/processor switch", () => {
|
|||||||
page.getByRole("radio", { name: /Active Files/i }),
|
page.getByRole("radio", { name: /Active Files/i }),
|
||||||
).toBeChecked();
|
).toBeChecked();
|
||||||
|
|
||||||
// Out through the sidebar footer switch - the real user path.
|
// Out through the rail's processor mark, the only chrome that offers the switch.
|
||||||
await page.getByRole("button", { name: "Open PDF Processor" }).click();
|
const processorMark = page.getByRole("button", { name: /^Processor$/i });
|
||||||
|
test.skip(
|
||||||
|
!(await processorMark.isVisible({ timeout: 5_000 }).catch(() => false)),
|
||||||
|
NO_PORTAL,
|
||||||
|
);
|
||||||
|
await processorMark.click();
|
||||||
await expect(page).toHaveURL(/\/processor/, { timeout: 15000 });
|
await expect(page).toHaveURL(/\/processor/, { timeout: 15000 });
|
||||||
|
|
||||||
// Split the two halves of the feature: if this fails the writer is at fault,
|
// Split the two halves of the feature: if this fails the writer is at fault,
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ html[data-app-theme="light"] {
|
|||||||
non-text floor applies. Scheme-independent: a filled badge reads white on
|
non-text floor applies. Scheme-independent: a filled badge reads white on
|
||||||
either ground. */
|
either ground. */
|
||||||
--c-success-solid: var(--p-green-700);
|
--c-success-solid: var(--p-green-700);
|
||||||
--c-danger-solid: var(--p-red-600);
|
--c-danger-solid: var(--p-red-700);
|
||||||
--c-warning-solid: var(--p-amber-700);
|
--c-warning-solid: var(--p-amber-700);
|
||||||
--c-neutral-solid: var(--p-gray-600);
|
--c-neutral-solid: var(--p-gray-600);
|
||||||
--c-accent-solid: var(--p-blue-600);
|
--c-accent-solid: var(--p-blue-600);
|
||||||
|
|||||||
@@ -30,7 +30,12 @@
|
|||||||
|
|
||||||
--radius-nav: 0.625rem;
|
--radius-nav: 0.625rem;
|
||||||
--nav-gutter: 0.5rem;
|
--nav-gutter: 0.5rem;
|
||||||
--nav-rail-w: 3.5rem;
|
/* Every minimised rail is this wide, so they line up as one column of icons. */
|
||||||
|
--nav-rail-w: 3rem;
|
||||||
|
/* Header row, so the rail's brand and a sidebar's wordmark line up. */
|
||||||
|
--nav-header-h: 3rem;
|
||||||
|
--sidebar-w: 16.25rem;
|
||||||
|
--sidebar-collapsed-w: var(--nav-rail-w);
|
||||||
|
|
||||||
/* ── Layout sizing ── */
|
/* ── Layout sizing ── */
|
||||||
--footer-height: 2rem;
|
--footer-height: 2rem;
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { forwardRef, type HTMLAttributes } from "react";
|
|||||||
import "@app/ui/NavSurface.css";
|
import "@app/ui/NavSurface.css";
|
||||||
|
|
||||||
export interface NavSurfaceProps extends HTMLAttributes<HTMLDivElement> {
|
export interface NavSurfaceProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
/** Element to render; `section`/`aside` when the box is a landmark. */
|
/** Element to render; `section`/`aside`/`nav` when the box is a landmark. */
|
||||||
as?: "div" | "section" | "aside";
|
as?: "div" | "section" | "aside" | "nav";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { WorkbenchType } from "@app/types/workbench";
|
import { getDefaultWorkbench, type WorkbenchType } from "@app/types/workbench";
|
||||||
|
|
||||||
export type StartupWorkbench = "viewer" | "fileEditor";
|
export type StartupWorkbench = "viewer" | "fileEditor";
|
||||||
|
|
||||||
@@ -7,6 +7,13 @@ export interface StartupNavigationAction {
|
|||||||
activeFileIndex?: number;
|
activeFileIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Several files means the file editor; one or none the viewer. */
|
||||||
|
export function getDefaultWorkbenchForFileCount(
|
||||||
|
fileCount: number,
|
||||||
|
): WorkbenchType {
|
||||||
|
return fileCount > 1 ? "fileEditor" : getDefaultWorkbench();
|
||||||
|
}
|
||||||
|
|
||||||
export function getStartupNavigationAction(
|
export function getStartupNavigationAction(
|
||||||
previousFileCount: number,
|
previousFileCount: number,
|
||||||
currentFileCount: number,
|
currentFileCount: number,
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
let pending = false;
|
||||||
|
|
||||||
|
/** Carries "open in reading mode" across an app switch, and deliberately not a reload. */
|
||||||
|
export function requestReaderMode(): void {
|
||||||
|
pending = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True once per request. */
|
||||||
|
export function consumeReaderModeRequest(): boolean {
|
||||||
|
if (!pending) return false;
|
||||||
|
pending = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { withViewTransition } from "@app/utils/viewTransition";
|
||||||
|
|
||||||
|
// The stub carries only the field the helper reads, hence the cast through unknown.
|
||||||
|
type MutableDoc = { startViewTransition?: unknown };
|
||||||
|
const doc = document as unknown as MutableDoc;
|
||||||
|
|
||||||
|
function stubApi(): ReturnType<typeof vi.fn> {
|
||||||
|
const start = vi.fn((cb: () => void) => {
|
||||||
|
cb();
|
||||||
|
return { finished: Promise.resolve() };
|
||||||
|
});
|
||||||
|
doc.startViewTransition = start;
|
||||||
|
return start;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubReducedMotion(reduced: boolean): void {
|
||||||
|
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||||
|
matches: reduced && query.includes("prefers-reduced-motion"),
|
||||||
|
media: query,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete doc.startViewTransition;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("withViewTransition", () => {
|
||||||
|
it("runs the update inside a transition when one is possible", async () => {
|
||||||
|
const start = stubApi();
|
||||||
|
stubReducedMotion(false);
|
||||||
|
const update = vi.fn();
|
||||||
|
|
||||||
|
await withViewTransition(update);
|
||||||
|
|
||||||
|
expect(start).toHaveBeenCalledTimes(1);
|
||||||
|
expect(update).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the transition when the user asked for less motion", async () => {
|
||||||
|
// The state change must still happen - only the animation is dropped.
|
||||||
|
const start = stubApi();
|
||||||
|
stubReducedMotion(true);
|
||||||
|
const update = vi.fn();
|
||||||
|
|
||||||
|
await withViewTransition(update);
|
||||||
|
|
||||||
|
expect(start).not.toHaveBeenCalled();
|
||||||
|
expect(update).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still applies the update where the API is unavailable", async () => {
|
||||||
|
stubReducedMotion(false);
|
||||||
|
const update = vi.fn();
|
||||||
|
|
||||||
|
await withViewTransition(update);
|
||||||
|
|
||||||
|
expect(update).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,21 +4,20 @@ type ViewTransitionDoc = Document & {
|
|||||||
startViewTransition?: (cb: () => void) => { finished: Promise<void> };
|
startViewTransition?: (cb: () => void) => { finished: Promise<void> };
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** Runs a state update in a View Transition, plainly where that is unavailable. */
|
||||||
* Run a state update inside a View Transition so the browser cross-fades
|
|
||||||
* (and morphs any elements sharing a {@code view-transition-name}) between
|
|
||||||
* the before/after DOMs.
|
|
||||||
*
|
|
||||||
* Falls back to a plain synchronous update when the API is unavailable
|
|
||||||
* (Firefox <130, JSDOM, motion-reduced preference).
|
|
||||||
*/
|
|
||||||
export function withViewTransition(update: () => void): Promise<void> {
|
export function withViewTransition(update: () => void): Promise<void> {
|
||||||
if (typeof document === "undefined") {
|
if (typeof document === "undefined") {
|
||||||
update();
|
update();
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
// Callers don't each check: reduced motion still gets the state change.
|
||||||
|
const reduced =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
typeof window.matchMedia === "function" &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
|
||||||
const doc = document as ViewTransitionDoc;
|
const doc = document as ViewTransitionDoc;
|
||||||
if (doc.startViewTransition) {
|
if (doc.startViewTransition && !reduced) {
|
||||||
return doc.startViewTransition(() => flushSync(update)).finished;
|
return doc.startViewTransition(() => flushSync(update)).finished;
|
||||||
}
|
}
|
||||||
update();
|
update();
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import { Logo } from "@app/ui/Logo";
|
|
||||||
import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Desktop inherits proprietary's layers but does not ship the portal (see
|
|
||||||
* desktop/routes/adminRouteExtensions), so there's nothing to switch to —
|
|
||||||
* shadow the brand header back to a plain logo. (Also avoids the desktop
|
|
||||||
* bundle referencing @portal via the proprietary switcher's imports.)
|
|
||||||
*/
|
|
||||||
export function AppSwitcher({ collapsed }: AppSwitcherProps) {
|
|
||||||
return (
|
|
||||||
<Logo
|
|
||||||
variant={collapsed ? "iconOnly" : "iconAndText"}
|
|
||||||
iconHeight="1.6rem"
|
|
||||||
textHeight="1.3rem"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/** Desktop inherits proprietary's app but never ships the portal. */
|
||||||
|
export const HAS_PORTAL = false;
|
||||||
@@ -7,8 +7,11 @@ import { PortalSearchBar } from "@portal/components/PortalSearchBar";
|
|||||||
import { useUI } from "@portal/contexts/UIContext";
|
import { useUI } from "@portal/contexts/UIContext";
|
||||||
import { MenuIcon, SearchIcon } from "@portal/components/icons";
|
import { MenuIcon, SearchIcon } from "@portal/components/icons";
|
||||||
import { Logo } from "@app/ui/Logo";
|
import { Logo } from "@app/ui/Logo";
|
||||||
|
import "@app/components/layout/WorkspaceFrame.css";
|
||||||
|
import { QuickNavHostBridge } from "@app/components/shared/quickNav/QuickNavHostBridge";
|
||||||
import "@portal/components/AppShell.css";
|
import "@portal/components/AppShell.css";
|
||||||
import { NotificationBell } from "@app/components/notifications/NotificationBell";
|
import { NotificationBell } from "@app/components/notifications/NotificationBell";
|
||||||
|
import { useIsPhone } from "@app/hooks/useIsMobile";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact header shown only under the mobile breakpoint (CSS-hidden on
|
* Compact header shown only under the mobile breakpoint (CSS-hidden on
|
||||||
@@ -58,8 +61,10 @@ function MobileTopbar() {
|
|||||||
* prop-free.
|
* prop-free.
|
||||||
*/
|
*/
|
||||||
export function AppShell({ children }: { children: ReactNode }) {
|
export function AppShell({ children }: { children: ReactNode }) {
|
||||||
const { mobileNavOpen, closeMobileNav } = useUI();
|
const { mobileNavOpen, closeMobileNav, openSettings } = useUI();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
// Below this width the rail, and the bell it carries, is gone.
|
||||||
|
const isPhone = useIsPhone();
|
||||||
|
|
||||||
// Navigating (tap on a nav row, back button, deep link) always dismisses the
|
// Navigating (tap on a nav row, back button, deep link) always dismisses the
|
||||||
// drawer. Depends on pathname only: the close fn's identity changes with any
|
// drawer. Depends on pathname only: the close fn's identity changes with any
|
||||||
@@ -79,7 +84,11 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="portal-shell">
|
<div className="portal-shell">
|
||||||
<Sidebar />
|
{/* portalAccess: being here is proof the processor is available. */}
|
||||||
|
<QuickNavHostBridge portalAccess onOpenSettings={() => openSettings()} />
|
||||||
|
<div className="workspace-frame">
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
{mobileNavOpen && (
|
{mobileNavOpen && (
|
||||||
<div
|
<div
|
||||||
className="portal-shell__scrim"
|
className="portal-shell__scrim"
|
||||||
@@ -90,9 +99,12 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<div className="portal-shell__main">
|
<div className="portal-shell__main">
|
||||||
<MobileTopbar />
|
<MobileTopbar />
|
||||||
<PortalSearchBar />
|
<PortalSearchBar />
|
||||||
<div className="portal-shell__notifications">
|
{/* Phone only: above that the rail carries it, and this would be a second. */}
|
||||||
<NotificationBell />
|
{isPhone && (
|
||||||
</div>
|
<div className="portal-shell__notifications">
|
||||||
|
<NotificationBell />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<main className="portal-shell__view">{children}</main>
|
<main className="portal-shell__view">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,31 +11,9 @@ import {
|
|||||||
import { type EditorInstance } from "@portal/api/editorDeploy";
|
import { type EditorInstance } from "@portal/api/editorDeploy";
|
||||||
import { DownloadEditorModal } from "@portal/components/DownloadEditorModal";
|
import { DownloadEditorModal } from "@portal/components/DownloadEditorModal";
|
||||||
import "@portal/theme/surface.css";
|
import "@portal/theme/surface.css";
|
||||||
|
import { BrandTile } from "@app/components/shared/BrandTile";
|
||||||
import "@portal/components/EditorStatusCard.css";
|
import "@portal/components/EditorStatusCard.css";
|
||||||
|
|
||||||
/** The Stirling brand mark, drawn at the hero size. Decorative. */
|
|
||||||
function StirlingMark() {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
className="portal-editor-hero__mark"
|
|
||||||
viewBox="0 0 256 256"
|
|
||||||
fill="none"
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
<rect width="256" height="256" rx="58" fill="var(--c-brand-mark)" />
|
|
||||||
<path
|
|
||||||
d="M39.2638 127.834L155.374 32L155.375 121.499L39.2638 217.333L39.2638 127.834Z"
|
|
||||||
fill="white"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M159 124.5L159 88.5L216.728 38.4472L216.728 128.052L100.479 224L100.479 172L159 124.5Z"
|
|
||||||
fill="white"
|
|
||||||
fillOpacity="0.6"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The instance to headline: the busiest healthy one, else the first. */
|
/** The instance to headline: the busiest healthy one, else the first. */
|
||||||
function primaryInstance(instances: EditorInstance[]): EditorInstance | null {
|
function primaryInstance(instances: EditorInstance[]): EditorInstance | null {
|
||||||
if (instances.length === 0) return null;
|
if (instances.length === 0) return null;
|
||||||
@@ -126,7 +104,7 @@ export function EditorStatusCard({ footer }: EditorStatusCardProps) {
|
|||||||
>
|
>
|
||||||
<div className="portal-editor-hero__row">
|
<div className="portal-editor-hero__row">
|
||||||
<div className="portal-editor-hero__logo">
|
<div className="portal-editor-hero__logo">
|
||||||
<StirlingMark />
|
<BrandTile className="portal-editor-hero__mark" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="portal-editor-hero__info">
|
<div className="portal-editor-hero__info">
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
/* Unpainted strip at the top of the main column. Height matches the sidebar's
|
/* Unpainted strip at the top of the main column, matching the sidebar header's height. */
|
||||||
logo row (.portal-sidebar__logo, 51px) so the search lines up with the brand. */
|
|
||||||
.portal-searchbar {
|
.portal-searchbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
.portal-sidebar {
|
.portal-sidebar {
|
||||||
width: 15rem;
|
width: var(--sidebar-w);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
height: 100dvh; /* track mobile browser chrome */
|
height: 100dvh; /* track mobile browser chrome */
|
||||||
background: var(--c-bg);
|
/* Matches the editor's sidebar: one solid panel with a rule on the content side. */
|
||||||
|
background: var(--c-surface);
|
||||||
|
border-inline-end: 1px solid var(--c-border-subtle);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -21,10 +23,6 @@
|
|||||||
|
|
||||||
/* Nav labels stay on one line and are clipped by the narrowing rail so they
|
/* Nav labels stay on one line and are clipped by the narrowing rail so they
|
||||||
reveal/hide cleanly as the width animates rather than wrapping. */
|
reveal/hide cleanly as the width animates rather than wrapping. */
|
||||||
.portal-sidebar__nav,
|
|
||||||
.portal-sidebar__footer {
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
.portal-sidebar .sui-navitem__label,
|
.portal-sidebar .sui-navitem__label,
|
||||||
.portal-sidebar__section-label {
|
.portal-sidebar__section-label {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -34,6 +32,8 @@
|
|||||||
.portal-sidebar__close {
|
.portal-sidebar__close {
|
||||||
display: none;
|
display: none;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
/* Trailing edge: on mobile this is the row's only control. */
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-sidebar__collapse {
|
.portal-sidebar__collapse {
|
||||||
@@ -78,23 +78,16 @@
|
|||||||
|
|
||||||
/* ---- Collapsed icon rail (desktop only) ---- */
|
/* ---- Collapsed icon rail (desktop only) ---- */
|
||||||
.portal-sidebar[data-collapsed] {
|
.portal-sidebar[data-collapsed] {
|
||||||
width: var(--nav-rail-w);
|
width: var(--sidebar-collapsed-w);
|
||||||
}
|
|
||||||
.portal-sidebar[data-collapsed] .portal-sidebar__logo {
|
|
||||||
flex-direction: column;
|
|
||||||
height: auto;
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
gap: 0.375rem;
|
|
||||||
}
|
|
||||||
.portal-sidebar[data-collapsed] .portal-sidebar__collapse {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
}
|
||||||
|
/* Flush, like the nav: the selected row runs the full width of the rail. */
|
||||||
.portal-sidebar[data-collapsed] .portal-sidebar__nav {
|
.portal-sidebar[data-collapsed] .portal-sidebar__nav {
|
||||||
padding-inline: 0.375rem;
|
padding-inline: 0;
|
||||||
}
|
}
|
||||||
|
/* Stretch, not centre: a centred group shrinks to its content, so rows can't fill the rail. */
|
||||||
.portal-sidebar[data-collapsed] .portal-sidebar__section {
|
.portal-sidebar[data-collapsed] .portal-sidebar__section {
|
||||||
padding-inline: 0;
|
padding-inline: 0;
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.portal-sidebar[data-collapsed] .portal-sidebar__section-label {
|
.portal-sidebar[data-collapsed] .portal-sidebar__section-label {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -111,23 +104,16 @@
|
|||||||
margin-inline: 0;
|
margin-inline: 0;
|
||||||
padding-inline: 0;
|
padding-inline: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
/* A square target, so it takes the rail's radius rather than NavItem's pill. */
|
||||||
/* Neutralise the active-item edge-bar geometry (negative margins + overhang)
|
border-radius: var(--radius-md);
|
||||||
that assumes the full-width rail. */
|
|
||||||
.portal-sidebar[data-collapsed] .sui-navitem.is-active {
|
|
||||||
width: 100%;
|
|
||||||
margin-inline: 0;
|
|
||||||
border-left: none;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
}
|
||||||
.portal-sidebar[data-collapsed] .portal-sidebar__footer {
|
.portal-sidebar[data-collapsed] .portal-sidebar__footer {
|
||||||
margin-inline: 0.375rem;
|
margin-inline: 0.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-sidebar__logo {
|
.portal-sidebar__header {
|
||||||
height: 3.1875rem; /* 51px */
|
height: var(--nav-header-h);
|
||||||
padding: 0 0.875rem;
|
padding: 0 var(--nav-gutter);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -136,35 +122,58 @@
|
|||||||
.portal-sidebar__nav {
|
.portal-sidebar__nav {
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0.75rem 0.625rem;
|
overflow-x: clip;
|
||||||
|
/* No inline inset above the rows, so a row is full width and needs no bleed past the clip. */
|
||||||
|
padding: var(--nav-gutter) 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: var(--nav-gutter);
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-sidebar .sui-navitem {
|
.portal-sidebar .sui-navitem {
|
||||||
margin-inline: 0.25rem;
|
margin-inline: 0;
|
||||||
padding-inline: 0.625rem;
|
padding-inline: 1.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-sidebar .sui-navitem.is-active {
|
/* The selected view, marked as the rail marks the current app: a knocked-out solid block. */
|
||||||
width: calc(100% + 0.75rem);
|
.portal-sidebar .sui-navitem.is-active,
|
||||||
margin-inline: -0.375rem;
|
.portal-sidebar .sui-navitem.is-active:hover {
|
||||||
|
background: var(--c-text);
|
||||||
|
color: var(--c-surface);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border-left: 3px solid var(--c-primary);
|
}
|
||||||
padding-left: calc(1.25rem - 3px);
|
|
||||||
|
/* On a dark ground full ink is white, so mix the block back toward the surface. */
|
||||||
|
[data-theme="dark"] .portal-sidebar .sui-navitem.is-active,
|
||||||
|
[data-theme="dark"] .portal-sidebar .sui-navitem.is-active:hover,
|
||||||
|
html[data-app-theme="midnight"] .portal-sidebar .sui-navitem.is-active,
|
||||||
|
html[data-app-theme="midnight"] .portal-sidebar .sui-navitem.is-active:hover,
|
||||||
|
[data-mantine-color-scheme="dark"] .portal-sidebar .sui-navitem.is-active,
|
||||||
|
[data-mantine-color-scheme="dark"]
|
||||||
|
.portal-sidebar
|
||||||
|
.sui-navitem.is-active:hover {
|
||||||
|
background: color-mix(in srgb, var(--c-text) 80%, var(--c-surface));
|
||||||
|
color: var(--c-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-sidebar__section {
|
.portal-sidebar__section {
|
||||||
padding: 0.5rem 0.375rem 0.375rem;
|
padding: 0.5rem 0 0.375rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.375rem;
|
gap: 0.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Flattened here; two classes deep to beat .sui-nav-surface regardless of load order. */
|
||||||
|
.portal-sidebar .sui-nav-surface {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.portal-sidebar__section-label {
|
.portal-sidebar__section-label {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 0.5rem;
|
/* Its own 0.5rem, plus the inset the nav and section no longer add. */
|
||||||
|
padding: 0 1.375rem;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
@@ -181,4 +190,18 @@
|
|||||||
only positions it. */
|
only positions it. */
|
||||||
.portal-sidebar__footer {
|
.portal-sidebar__footer {
|
||||||
margin: 0 0.625rem 0.75rem;
|
margin: 0 0.625rem 0.75rem;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fills the frame, not the viewport: a 100vh sticky column would overhang it. */
|
||||||
|
.workspace-frame .portal-sidebar {
|
||||||
|
height: 100%;
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 48rem) {
|
||||||
|
.workspace-frame .portal-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
import { useMediaQuery } from "@mantine/hooks";
|
import { useMediaQuery } from "@mantine/hooks";
|
||||||
import { Tooltip } from "@mantine/core";
|
import { Tooltip } from "@mantine/core";
|
||||||
import { ActionIcon, NavItem, NavSurface } from "@app/ui";
|
import { ActionIcon, NavItem, NavSurface } from "@app/ui";
|
||||||
import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
|
import { SidebarToggleButton } from "@app/components/shared/SidebarToggleButton";
|
||||||
|
import { Logo } from "@app/ui/Logo";
|
||||||
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
|
import { NavFooter } from "@app/components/shared/navFooter/NavFooter";
|
||||||
import { useAccountIdentity } from "@app/hooks/useAccountIdentity";
|
import { useAccountIdentity } from "@app/hooks/useAccountIdentity";
|
||||||
import { useFreeCreditsSummary } from "@portal/hooks/useFreeCreditsSummary";
|
import { useFreeCreditsSummary } from "@portal/hooks/useFreeCreditsSummary";
|
||||||
import { useOpenPlan } from "@portal/hooks/useOpenPlan";
|
import { useOpenPlan } from "@portal/hooks/useOpenPlan";
|
||||||
import { SidebarToggleIcon } from "@app/components/shared/SidebarToggleIcon";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useView, type ViewId } from "@portal/contexts/ViewContext";
|
import { useView, type ViewId } from "@portal/contexts/ViewContext";
|
||||||
import { useUI } from "@portal/contexts/UIContext";
|
import { useUI } from "@portal/contexts/UIContext";
|
||||||
import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem";
|
import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem";
|
||||||
import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl";
|
|
||||||
import { EDITOR_BASENAME } from "@app/routes/editorBasename";
|
|
||||||
import { takeEditorReturnPath } from "@app/services/workbenchSession";
|
|
||||||
import { CloseIcon } from "@portal/components/icons";
|
import { CloseIcon } from "@portal/components/icons";
|
||||||
import {
|
import {
|
||||||
GROUP_PROCESSOR,
|
GROUP_PROCESSOR,
|
||||||
@@ -30,19 +26,17 @@ const NAV_SECTIONS: NavGroup[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/** Must match the shell breakpoint in AppShell.css / Sidebar.css. */
|
/** Must match the shell breakpoint in AppShell.css / Sidebar.css. */
|
||||||
const MOBILE_QUERY = "(max-width: 48rem)";
|
export const MOBILE_QUERY = "(max-width: 48rem)";
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const { activeView, setActiveView } = useView();
|
const { activeView, setActiveView } = useView();
|
||||||
const {
|
const {
|
||||||
openSettings,
|
|
||||||
mobileNavOpen,
|
mobileNavOpen,
|
||||||
closeMobileNav,
|
closeMobileNav,
|
||||||
sidebarCollapsed,
|
sidebarCollapsed,
|
||||||
toggleSidebarCollapsed,
|
toggleSidebarCollapsed,
|
||||||
} = useUI();
|
} = useUI();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
|
||||||
const isMobile = useMediaQuery(MOBILE_QUERY, false, {
|
const isMobile = useMediaQuery(MOBILE_QUERY, false, {
|
||||||
getInitialValueInEffect: false,
|
getInitialValueInEffect: false,
|
||||||
});
|
});
|
||||||
@@ -54,14 +48,6 @@ export function Sidebar() {
|
|||||||
// off-canvas drawer, so the icon-rail state never applies there.
|
// off-canvas drawer, so the icon-rail state never applies there.
|
||||||
const collapsed = sidebarCollapsed && !isMobile;
|
const collapsed = sidebarCollapsed && !isMobile;
|
||||||
|
|
||||||
// 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(takeEditorReturnPath() ?? EDITOR_BASENAME);
|
|
||||||
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
|
// 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).
|
// a takeover modal (matching the marketing prototype).
|
||||||
|
|
||||||
@@ -107,25 +93,13 @@ export function Sidebar() {
|
|||||||
// Off-canvas on mobile: remove from the tab order and accessibility tree.
|
// Off-canvas on mobile: remove from the tab order and accessibility tree.
|
||||||
inert={isMobile && !mobileNavOpen}
|
inert={isMobile && !mobileNavOpen}
|
||||||
>
|
>
|
||||||
<div className="portal-sidebar__logo">
|
<div className="portal-sidebar__header">
|
||||||
<BrandSwitcher
|
{!collapsed && <Logo variant="textOnly" textHeight="1.3rem" />}
|
||||||
current="processor"
|
|
||||||
onSwitch={goToEditor}
|
|
||||||
collapsed={collapsed}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ActionIcon
|
<SidebarToggleButton
|
||||||
variant="tertiary"
|
collapsed={collapsed}
|
||||||
className="portal-sidebar__collapse"
|
onToggle={toggleSidebarCollapsed}
|
||||||
aria-label={
|
/>
|
||||||
collapsed
|
|
||||||
? t("fileSidebar.expand", "Expand sidebar")
|
|
||||||
: t("fileSidebar.collapse", "Collapse sidebar")
|
|
||||||
}
|
|
||||||
onClick={toggleSidebarCollapsed}
|
|
||||||
>
|
|
||||||
<SidebarToggleIcon size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
@@ -158,10 +132,9 @@ export function Sidebar() {
|
|||||||
className="portal-sidebar__footer"
|
className="portal-sidebar__footer"
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
profilePictureUrl={profilePictureUrl}
|
profilePictureUrl={profilePictureUrl}
|
||||||
onOpenSettings={openSettings}
|
showAccount={false}
|
||||||
credits={credits}
|
credits={credits}
|
||||||
onOpenPlan={openPlan ?? undefined}
|
onOpenPlan={openPlan ?? undefined}
|
||||||
otherApp={{ app: "editor", onOpen: goToEditor }}
|
|
||||||
accountExtras={<LinkAccountFooterItem />}
|
accountExtras={<LinkAccountFooterItem />}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ const MobileScannerPage = lazy(() => import("@app/pages/MobileScannerPage"));
|
|||||||
const MobileSignPage = lazy(() => import("@app/pages/MobileSignPage"));
|
const MobileSignPage = lazy(() => import("@app/pages/MobileSignPage"));
|
||||||
import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags";
|
import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags";
|
||||||
import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions";
|
import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions";
|
||||||
|
import { AppFrame } from "@app/components/layout/AppFrame";
|
||||||
|
import { NoAppChrome } from "@app/components/layout/NoAppChrome";
|
||||||
import { RootGate } from "@app/routes/RootGate";
|
import { RootGate } from "@app/routes/RootGate";
|
||||||
|
|
||||||
// Import global styles
|
// Import global styles
|
||||||
@@ -80,40 +82,52 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Admin-only route-set (the portal): its own top-level shell, mounted
|
{/* Both apps, under a shared frame so the rail renders once outside them. */}
|
||||||
before the catch-all. Absent from core/desktop builds (empty stub). */}
|
<Route element={<AppFrame />}>
|
||||||
{getAdminRouteExtensions()}
|
{/* The portal: its own shell, before the catch-all. An empty stub in core. */}
|
||||||
|
{getAdminRouteExtensions()}
|
||||||
|
|
||||||
{/* All other routes need AppProviders for backend integration.
|
{/* All other routes need AppProviders for backend integration. RootGate
|
||||||
RootGate makes "/" route by role BEFORE any of it mounts, so a user
|
routes "/" by role before any of it mounts. */}
|
||||||
bound for the processor never boots the editor on the way. */}
|
<Route
|
||||||
<Route
|
path="*"
|
||||||
path="*"
|
element={
|
||||||
element={
|
<RootGate>
|
||||||
<RootGate>
|
<AppProviders>
|
||||||
<AppProviders>
|
<AppLayout>
|
||||||
<AppLayout>
|
<Routes>
|
||||||
<Routes>
|
{/* Not the app: no rail over any of these, ever. */}
|
||||||
<Route path="/login" element={<Login />} />
|
<Route element={<NoAppChrome />}>
|
||||||
{/* Self-hosted has no signup - accounts are created by an
|
<Route path="/login" element={<Login />} />
|
||||||
admin. Old links land on login instead. */}
|
{/* Self-hosted has no signup: old links land on login. */}
|
||||||
<Route
|
<Route
|
||||||
path="/signup"
|
path="/signup"
|
||||||
element={<Navigate to="/login" replace />}
|
element={<Navigate to="/login" replace />}
|
||||||
/>
|
/>
|
||||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
<Route
|
||||||
<Route path="/invite/:token" element={<InviteAccept />} />
|
path="/auth/callback"
|
||||||
<Route path="/share/:token" element={<ShareLinkPage />} />
|
element={<AuthCallback />}
|
||||||
{/* The editor and its tool routes - Landing handles auth logic */}
|
/>
|
||||||
<Route path="/*" element={<Landing />} />
|
<Route
|
||||||
</Routes>
|
path="/invite/:token"
|
||||||
<Onboarding />
|
element={<InviteAccept />}
|
||||||
{WATCHED_FOLDERS_ENABLED && <WatchedFoldersRegistration />}
|
/>
|
||||||
</AppLayout>
|
<Route
|
||||||
</AppProviders>
|
path="/share/:token"
|
||||||
</RootGate>
|
element={<ShareLinkPage />}
|
||||||
}
|
/>
|
||||||
/>
|
</Route>
|
||||||
|
{/* The editor and its tool routes - Landing handles auth logic */}
|
||||||
|
<Route path="/*" element={<Landing />} />
|
||||||
|
</Routes>
|
||||||
|
<Onboarding />
|
||||||
|
{WATCHED_FOLDERS_ENABLED && <WatchedFoldersRegistration />}
|
||||||
|
</AppLayout>
|
||||||
|
</AppProviders>
|
||||||
|
</RootGate>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { Logo } from "@app/ui/Logo";
|
|
||||||
import { BrandSwitcher } from "@app/components/shared/BrandSwitcher";
|
|
||||||
import { type AppSwitcherProps } from "@core/components/shared/AppSwitcher";
|
|
||||||
import { useOtherAppSwitch } from "@app/hooks/useOtherAppSwitch";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sidebar brand header for builds that ship the processor. When this user can
|
|
||||||
* open it, the Stirling logo doubles as the editor⇄processor switcher: the mark
|
|
||||||
* morphs into a chevron and opens the switch menu (the same BrandSwitcher the
|
|
||||||
* processor sidebar uses). Users without access get a plain logo.
|
|
||||||
*
|
|
||||||
* The access gate lives in {@link useOtherAppSwitch} so this header and the
|
|
||||||
* sidebar footer's "Open PDF Processor" row are driven by one answer.
|
|
||||||
*/
|
|
||||||
export function AppSwitcher({ collapsed }: AppSwitcherProps) {
|
|
||||||
const otherApp = useOtherAppSwitch();
|
|
||||||
|
|
||||||
if (!otherApp) {
|
|
||||||
return (
|
|
||||||
<Logo
|
|
||||||
variant={collapsed ? "iconOnly" : "iconAndText"}
|
|
||||||
iconHeight="1.6rem"
|
|
||||||
textHeight="1.3rem"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BrandSwitcher
|
|
||||||
current="editor"
|
|
||||||
onSwitch={otherApp.onOpen}
|
|
||||||
collapsed={collapsed}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,15 +6,10 @@ import type {
|
|||||||
PortalEntityItems,
|
PortalEntityItems,
|
||||||
PortalEntityScopeId,
|
PortalEntityScopeId,
|
||||||
} from "@portal/search/entitySearch";
|
} from "@portal/search/entitySearch";
|
||||||
|
import { HAS_PORTAL } from "@app/routes/hasPortal";
|
||||||
|
|
||||||
type EntitySearchModule = typeof import("@portal/search/entitySearch");
|
type EntitySearchModule = typeof import("@portal/search/entitySearch");
|
||||||
|
|
||||||
// Mirrors the admin-route seam's gate: the portal route-set is only mounted in
|
|
||||||
// dev and in builds made with VITE_INCLUDE_PORTAL=true, so the search must not
|
|
||||||
// fetch or offer entities that have nowhere to open.
|
|
||||||
const includePortal =
|
|
||||||
import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
|
|
||||||
|
|
||||||
const NO_GROUPS: SuperSearchGroup[] = [];
|
const NO_GROUPS: SuperSearchGroup[] = [];
|
||||||
const NO_SCOPES: readonly PortalEntityScopeId[] = [];
|
const NO_SCOPES: readonly PortalEntityScopeId[] = [];
|
||||||
|
|
||||||
@@ -41,7 +36,8 @@ export function useProcessorEntityGroups(
|
|||||||
): SuperSearchGroup[] {
|
): SuperSearchGroup[] {
|
||||||
const [mod, setMod] = useState<EntitySearchModule | null>(null);
|
const [mod, setMod] = useState<EntitySearchModule | null>(null);
|
||||||
const modRef = useRef<EntitySearchModule | null>(null);
|
const modRef = useRef<EntitySearchModule | null>(null);
|
||||||
const active = enabled && includePortal;
|
// Without the portal these entities have nowhere to open, so don't fetch them.
|
||||||
|
const active = enabled && HAS_PORTAL;
|
||||||
const hasQuery = trimmed.length > 0;
|
const hasQuery = trimmed.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -3,15 +3,10 @@ import { PORTAL_BASENAME } from "@app/routes/portalBasename";
|
|||||||
// the lazy portal chunk into the main bundle the way @portal/* values would.
|
// the lazy portal chunk into the main bundle the way @portal/* values would.
|
||||||
import { usersCapabilities } from "@app/portal/usersCapabilities";
|
import { usersCapabilities } from "@app/portal/usersCapabilities";
|
||||||
import type { ProcessorSearchEntry } from "@core/data/processorSearchIndex";
|
import type { ProcessorSearchEntry } from "@core/data/processorSearchIndex";
|
||||||
|
import { HAS_PORTAL } from "@app/routes/hasPortal";
|
||||||
|
|
||||||
export type { ProcessorSearchEntry };
|
export type { ProcessorSearchEntry };
|
||||||
|
|
||||||
// Mirrors the admin-route seam's gate: the portal route-set is only mounted in
|
|
||||||
// dev and in builds made with VITE_INCLUDE_PORTAL=true, so the search must not
|
|
||||||
// offer destinations that would 404 elsewhere.
|
|
||||||
const includePortal =
|
|
||||||
import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The portal's in-app views. Deliberately a static mirror of the portal's nav
|
* The portal's in-app views. Deliberately a static mirror of the portal's nav
|
||||||
* (labels via the same portal.nav.* keys its sidebar uses) rather than an
|
* (labels via the same portal.nav.* keys its sidebar uses) rather than an
|
||||||
@@ -91,7 +86,8 @@ const VIEWS: ProcessorSearchEntry[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = includePortal
|
// Empty without the portal: these destinations would 404.
|
||||||
|
export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = HAS_PORTAL
|
||||||
? VIEWS
|
? VIEWS
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect } from "react";
|
|||||||
import { Navigate, useLocation, useNavigate } from "react-router-dom";
|
import { Navigate, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "@app/auth/UseSession";
|
import { useAuth } from "@app/auth/UseSession";
|
||||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||||
|
import { useSuppressQuickNavRail } from "@app/contexts/QuickNavHostContext";
|
||||||
import HomePage from "@app/pages/HomePage";
|
import HomePage from "@app/pages/HomePage";
|
||||||
import { useBackendProbe } from "@app/hooks/useBackendProbe";
|
import { useBackendProbe } from "@app/hooks/useBackendProbe";
|
||||||
import { EDITOR_BASENAME } from "@app/routes/editorBasename";
|
import { EDITOR_BASENAME } from "@app/routes/editorBasename";
|
||||||
@@ -27,6 +28,9 @@ export default function Landing() {
|
|||||||
|
|
||||||
const loading = authLoading || configLoading || backendProbe.loading;
|
const loading = authLoading || configLoading || backendProbe.loading;
|
||||||
|
|
||||||
|
// The backend-down screen is not the app. Loading is: it resolves in a moment.
|
||||||
|
useSuppressQuickNavRail(!session && backendProbe.status !== "up");
|
||||||
|
|
||||||
// Debug: Track Landing component lifecycle
|
// Debug: Track Landing component lifecycle
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mountId = Math.random().toString(36).substring(7);
|
const mountId = Math.random().toString(36).substring(7);
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ import { lazy } from "react";
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
import { Route } from "react-router-dom";
|
import { Route } from "react-router-dom";
|
||||||
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
|
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
|
||||||
|
import { HAS_PORTAL } from "@app/routes/hasPortal";
|
||||||
|
|
||||||
const includePortal =
|
const PortalApp = HAS_PORTAL
|
||||||
import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
|
|
||||||
|
|
||||||
const PortalApp = includePortal
|
|
||||||
? lazy(async () => {
|
? lazy(async () => {
|
||||||
const m = await import("@portal/PortalApp");
|
const m = await import("@portal/PortalApp");
|
||||||
return { default: m.PortalApp };
|
return { default: m.PortalApp };
|
||||||
@@ -16,7 +14,7 @@ const PortalApp = includePortal
|
|||||||
/**
|
/**
|
||||||
* Return leg of the account-link handshake, which Stirling redirects to with the admin's session in the URL fragment.
|
* Return leg of the account-link handshake, which Stirling redirects to with the admin's session in the URL fragment.
|
||||||
*/
|
*/
|
||||||
const ConnectCallback = includePortal
|
const ConnectCallback = HAS_PORTAL
|
||||||
? lazy(async () => {
|
? lazy(async () => {
|
||||||
const m = await import("@portal/views/ConnectCallback");
|
const m = await import("@portal/views/ConnectCallback");
|
||||||
return { default: m.default };
|
return { default: m.default };
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/** Dev always ships it, so the switch is there to work on. */
|
||||||
|
export const HAS_PORTAL =
|
||||||
|
import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
|
||||||
@@ -19,6 +19,8 @@ import OAuthConsent from "@app/routes/OAuthConsent";
|
|||||||
import ConnectApprove from "@app/routes/ConnectApprove";
|
import ConnectApprove from "@app/routes/ConnectApprove";
|
||||||
import ShareLinkPage from "@app/routes/ShareLinkPage";
|
import ShareLinkPage from "@app/routes/ShareLinkPage";
|
||||||
import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions";
|
import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions";
|
||||||
|
import { AppFrame } from "@app/components/layout/AppFrame";
|
||||||
|
import { NoAppChrome } from "@app/components/layout/NoAppChrome";
|
||||||
import OnboardingBootstrap from "@app/components/OnboardingBootstrap";
|
import OnboardingBootstrap from "@app/components/OnboardingBootstrap";
|
||||||
import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap";
|
import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap";
|
||||||
import UsageLimitModalHost from "@app/components/UsageLimitModalHost";
|
import UsageLimitModalHost from "@app/components/UsageLimitModalHost";
|
||||||
@@ -96,46 +98,63 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Admin-only route-set (the portal): its own top-level shell, mounted
|
{/* Both apps, under a shared frame so the rail renders once outside them. */}
|
||||||
before the catch-all. */}
|
<Route element={<AppFrame />}>
|
||||||
{getAdminRouteExtensions()}
|
{/* The portal: its own top-level shell, before the catch-all. */}
|
||||||
|
{getAdminRouteExtensions()}
|
||||||
|
|
||||||
{/* Everything else needs the auth/backend providers. RootGate makes "/"
|
{/* Everything else needs the auth/backend providers. RootGate routes "/"
|
||||||
route by role BEFORE any of it mounts, so a user bound for the
|
by role before any of it mounts. */}
|
||||||
processor never boots the editor on the way. */}
|
<Route
|
||||||
<Route
|
path="*"
|
||||||
path="*"
|
element={
|
||||||
element={
|
<RootGate>
|
||||||
<RootGate>
|
<AppProviders
|
||||||
<AppProviders
|
appConfigProviderProps={{
|
||||||
appConfigProviderProps={{ onConfigLoaded: handleConfigLoaded }}
|
onConfigLoaded: handleConfigLoaded,
|
||||||
>
|
}}
|
||||||
<AppLayout>
|
>
|
||||||
<NonAuthBootstraps />
|
<AppLayout>
|
||||||
<ResumePendingConnect />
|
<NonAuthBootstraps />
|
||||||
<Routes>
|
<ResumePendingConnect />
|
||||||
<Route path="/login" element={<Login />} />
|
<Routes>
|
||||||
<Route path="/signup" element={<Signup />} />
|
{/* Not the app: no rail over any of these, ever. */}
|
||||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
<Route element={<NoAppChrome />}>
|
||||||
<Route path="/auth/reset" element={<ResetPassword />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/oauth/consent" element={<OAuthConsent />} />
|
<Route path="/signup" element={<Signup />} />
|
||||||
{/* Human half of the self-hosted account-link handshake. It
|
<Route
|
||||||
lives on this origin because a customer hostname can
|
path="/auth/callback"
|
||||||
never be in the provider's redirect allow-list. */}
|
element={<AuthCallback />}
|
||||||
<Route path="/link" element={<ConnectApprove />} />
|
/>
|
||||||
{/* Shared-file links. Team invites are NOT routed here: on
|
<Route path="/auth/reset" element={<ResetPassword />} />
|
||||||
SaaS they are accepted in-app via the Supabase team
|
<Route
|
||||||
invitation banner, not the Spring password-based
|
path="/oauth/consent"
|
||||||
/invite/:token page used by the self-hosted build. */}
|
element={<OAuthConsent />}
|
||||||
<Route path="/share/:token" element={<ShareLinkPage />} />
|
/>
|
||||||
<Route path="/*" element={<Landing />} />
|
{/* Human half of the self-hosted account-link handshake.
|
||||||
</Routes>
|
It lives on this origin because a customer hostname can
|
||||||
<OnboardingTour />
|
never be in the provider's redirect allow-list. Grouped
|
||||||
</AppLayout>
|
with the pages above: it is an approval step, not the
|
||||||
</AppProviders>
|
app. */}
|
||||||
</RootGate>
|
<Route path="/link" element={<ConnectApprove />} />
|
||||||
}
|
{/* Shared-file links. Team invites are NOT routed here:
|
||||||
/>
|
on SaaS they are accepted in-app via the Supabase team
|
||||||
|
invitation banner, not the Spring password-based
|
||||||
|
/invite/:token page used by the self-hosted build. */}
|
||||||
|
<Route
|
||||||
|
path="/share/:token"
|
||||||
|
element={<ShareLinkPage />}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
<Route path="/*" element={<Landing />} />
|
||||||
|
</Routes>
|
||||||
|
<OnboardingTour />
|
||||||
|
</AppLayout>
|
||||||
|
</AppProviders>
|
||||||
|
</RootGate>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user