Restructure/frontend editor (#6404)

## Move editor under `frontend/editor/`

Pure restructure: `frontend/` becomes the workspace, `frontend/editor/`
holds
  the PDF editor. 1775 file renames + 40 wiring edits. No logic changes.

  ### Why

`frontend/` is currently the editor — its `src/`, `public/`,
`src-tauri/`,
  config files all sit at the root. Promoting `frontend/` to a
workspace and putting the editor in a sibling folder leaves room for
future
apps to drop in alongside it, sharing one `package.json` /
`node_modules` /
  lint config / Storybook.

  ### What moves

  frontend/
  ├── editor/                ← NEW: everything editor-specific
  │   ├── src/               ← was frontend/src/
  │   ├── public/            ← was frontend/public/
  │   ├── src-tauri/         ← was frontend/src-tauri/
│ ├── index.html, vite.config.ts, vitest.config.ts, playwright.config.ts
  │   ├── tsconfig*.json, tailwind.config.js, postcss.config.js
  │   ├── scripts/
  │   ├── .env, .env.desktop, .env.saas
  │   └── DeveloperGuide.md
├── package.json, package-lock.json, node_modules/ ← workspace install
  ├── eslint.config.mjs, .prettierrc, .prettierignore ← shared tooling
  ├── .gitignore
  └── README.md

  ### Wiring edits (40 files)

  - `.taskfiles/frontend.yml`, `desktop.yml`, `e2e.yml`
  - `build.gradle`, `app/core/build.gradle`
- `eslint.config.mjs`, `frontend/package.json`, `.gitignore`,
`.prettierignore`
  - `docker/frontend/Dockerfile`
  - 8 `.github/workflows/*.yml`, plus `.github/dependabot.yml`,
    `.github/config/.files.yaml`, `.github/labeler-config-srvaroa.yml`
  - `scripts/translations/**`
- Docs: `AGENTS.md`, `CLAUDE.md`, `ADDING_TOOLS.md`,
`DeveloperGuide.md`,
`WINDOWS_SIGNING.md`, `devGuide/HowToAddNewLanguage.md`,
`frontend/README.md`,
    `frontend/editor/DeveloperGuide.md`

Plus 3 renamed + edited: `editor/vite.config.ts` (env path +
node_modules
  walk-up), `editor/scripts/setup-env.mts` (renamed from `.ts` for
`import.meta.url`), `editor/scripts/build-provisioner.mjs` (resolve
src-tauri
  relative to script).

  ### Verification

  | Check | Result |
  |---|---|
  | `task frontend:typecheck:all` (6 variants) | exit 0 |
  | `task frontend:lint` (eslint + dpdm) | exit 0 |
  | `task frontend:format:check` | exit 0 |
  | `task frontend:test` | 657 tests pass, 50 files |
| `task frontend:build:{core,proprietary,saas,desktop,prototypes}` | all
green |
| `task desktop:build` | full Tauri pipeline →
`Stirling-PDF_2.11.0_x64_en-US.msi` |
  | `playwright test --list --project=stubbed` | 172 tests discovered |

`task desktop:build` exercises the heaviest path — Rust + WiX + MSI
bundle
against the moved `editor/src-tauri/`. If anything in the restructure
was
  wrong it wouldn't have built.

  ### Test plan

  - [ ] `frontend-validation.yml` green
  - [ ] `e2e-stubbed.yml` green
  - [ ] `tauri-build.yml` green on at least one platform
  - [ ] `check_toml.yml` runs on a translation-touching PR

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Reece Browne
2026-05-22 13:40:34 +01:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 48027ee9d6
commit 0a50e765b7
1820 changed files with 300 additions and 226 deletions
@@ -0,0 +1,326 @@
{
"dependencies": [
{
"moduleName": "@atlaskit/pragmatic-drag-and-drop",
"moduleUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git",
"moduleVersion": "1.7.7",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git"
},
{
"moduleName": "@embedpdf/core",
"moduleUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz"
},
{
"moduleName": "@embedpdf/engines",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-annotation",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-export",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-history",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-interaction-manager",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-loader",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-pan",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-render",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-rotate",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-scroll",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-search",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-selection",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-spread",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-thumbnail",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-tiling",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-viewport",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@embedpdf/plugin-zoom",
"moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git",
"moduleVersion": "1.3.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git"
},
{
"moduleName": "@emotion/react",
"moduleUrl": "git+https://github.com/emotion-js/emotion.git#main",
"moduleVersion": "11.14.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/emotion-js/emotion.git#main"
},
{
"moduleName": "@emotion/styled",
"moduleUrl": "git+https://github.com/emotion-js/emotion.git#main",
"moduleVersion": "11.14.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/emotion-js/emotion.git#main"
},
{
"moduleName": "@iconify/react",
"moduleUrl": "git+https://github.com/iconify/iconify.git",
"moduleVersion": "6.0.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/iconify/iconify.git"
},
{
"moduleName": "@mantine/core",
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
"moduleVersion": "8.3.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
},
{
"moduleName": "@mantine/dates",
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
"moduleVersion": "8.3.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
},
{
"moduleName": "@mantine/dropzone",
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
"moduleVersion": "8.3.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
},
{
"moduleName": "@mantine/hooks",
"moduleUrl": "git+https://github.com/mantinedev/mantine.git",
"moduleVersion": "8.3.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git"
},
{
"moduleName": "@mui/icons-material",
"moduleUrl": "git+https://github.com/mui/material-ui.git",
"moduleVersion": "7.3.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mui/material-ui.git"
},
{
"moduleName": "@mui/material",
"moduleUrl": "git+https://github.com/mui/material-ui.git",
"moduleVersion": "7.3.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/mui/material-ui.git"
},
{
"moduleName": "@tailwindcss/postcss",
"moduleUrl": "git+https://github.com/tailwindlabs/tailwindcss.git",
"moduleVersion": "4.1.13",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/tailwindlabs/tailwindcss.git"
},
{
"moduleName": "@tanstack/react-virtual",
"moduleUrl": "git+https://github.com/TanStack/virtual.git",
"moduleVersion": "3.13.12",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/TanStack/virtual.git"
},
{
"moduleName": "autoprefixer",
"moduleUrl": "git+https://github.com/postcss/autoprefixer.git",
"moduleVersion": "10.4.21",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/postcss/autoprefixer.git"
},
{
"moduleName": "axios",
"moduleUrl": "git+https://github.com/axios/axios.git",
"moduleVersion": "1.12.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/axios/axios.git"
},
{
"moduleName": "i18next",
"moduleUrl": "git+https://github.com/i18next/i18next.git",
"moduleVersion": "25.5.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/i18next/i18next.git"
},
{
"moduleName": "i18next-browser-languagedetector",
"moduleUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git",
"moduleVersion": "8.2.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git"
},
{
"moduleName": "i18next-http-backend",
"moduleUrl": "git+ssh://[email protected]/i18next/i18next-http-backend.git",
"moduleVersion": "3.0.2",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+ssh://[email protected]/i18next/i18next-http-backend.git"
},
{
"moduleName": "jszip",
"moduleUrl": "git+https://github.com/Stuk/jszip.git",
"moduleVersion": "3.10.1",
"moduleLicense": "(MIT OR GPL-3.0-or-later)",
"moduleLicenseUrl": "git+https://github.com/Stuk/jszip.git"
},
{
"moduleName": "license-report",
"moduleUrl": "git+https://github.com/kessler/license-report.git",
"moduleVersion": "6.8.0",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/kessler/license-report.git"
},
{
"moduleName": "pdf-lib",
"moduleUrl": "git+https://github.com/Hopding/pdf-lib.git",
"moduleVersion": "1.17.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/Hopding/pdf-lib.git"
},
{
"moduleName": "pdfjs-dist",
"moduleUrl": "git+https://github.com/mozilla/pdf.js.git",
"moduleVersion": "5.4.149",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "git+https://github.com/mozilla/pdf.js.git"
},
{
"moduleName": "posthog-js",
"moduleUrl": "git+https://github.com/PostHog/posthog-js.git",
"moduleVersion": "1.268.0",
"moduleLicense": "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE",
"moduleLicenseUrl": "git+https://github.com/PostHog/posthog-js.git"
},
{
"moduleName": "react",
"moduleUrl": "git+https://github.com/facebook/react.git",
"moduleVersion": "19.1.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/facebook/react.git"
},
{
"moduleName": "react-dom",
"moduleUrl": "git+https://github.com/facebook/react.git",
"moduleVersion": "19.1.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/facebook/react.git"
},
{
"moduleName": "react-i18next",
"moduleUrl": "git+https://github.com/i18next/react-i18next.git",
"moduleVersion": "15.7.3",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/i18next/react-i18next.git"
},
{
"moduleName": "react-router-dom",
"moduleUrl": "git+https://github.com/remix-run/react-router.git",
"moduleVersion": "7.9.1",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/remix-run/react-router.git"
},
{
"moduleName": "tailwindcss",
"moduleUrl": "git+https://github.com/tailwindlabs/tailwindcss.git",
"moduleVersion": "4.1.13",
"moduleLicense": "MIT",
"moduleLicenseUrl": "git+https://github.com/tailwindlabs/tailwindcss.git"
},
{
"moduleName": "web-vitals",
"moduleUrl": "git+https://github.com/GoogleChrome/web-vitals.git",
"moduleVersion": "5.1.0",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "git+https://github.com/GoogleChrome/web-vitals.git"
}
]
}
+58
View File
@@ -0,0 +1,58 @@
import { Suspense } from "react";
import { Routes, Route } from "react-router-dom";
import { AppProviders } from "@app/components/AppProviders";
import { AppLayout } from "@app/components/AppLayout";
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvider";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import HomePage from "@app/pages/HomePage";
import MobileScannerPage from "@app/pages/MobileScannerPage";
import Onboarding from "@app/components/onboarding/Onboarding";
// Import global styles
import "@app/styles/tailwind.css";
import "@app/styles/cookieconsent.css";
import "@app/styles/index.css";
// Import file ID debugging helpers (development only)
import "@app/utils/fileIdSafety";
// Minimal providers for mobile scanner - no API calls, no authentication
function MobileScannerProviders({ children }: { children: React.ReactNode }) {
return (
<PreferencesProvider>
<RainbowThemeProvider>{children}</RainbowThemeProvider>
</PreferencesProvider>
);
}
export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
<Routes>
{/* Mobile scanner route - no backend needed, pure P2P WebRTC */}
<Route
path="/mobile-scanner"
element={
<MobileScannerProviders>
<MobileScannerPage />
</MobileScannerProviders>
}
/>
{/* All other routes need AppProviders for backend integration */}
<Route
path="*"
element={
<AppProviders>
<AppLayout>
<HomePage />
<Onboarding />
</AppLayout>
</AppProviders>
}
/>
</Routes>
</Suspense>
);
}
@@ -0,0 +1,23 @@
export interface AuthContextType {
session: null;
user: { id?: string; email?: string; [key: string]: unknown } | null;
loading: boolean;
error: Error | null;
signOut: () => Promise<void>;
refreshSession: () => Promise<void>;
}
/**
* Core (open-source) auth hook stub.
* Proprietary/desktop builds override this file via path resolution.
*/
export function useAuth(): AuthContextType {
return {
session: null,
user: null,
loading: false,
error: null,
signOut: async () => {},
refreshSession: async () => {},
};
}
@@ -0,0 +1,32 @@
import { ReactNode } from "react";
import { useBanner } from "@app/contexts/BannerContext";
import NavigationWarningModal from "@app/components/shared/NavigationWarningModal";
interface AppLayoutProps {
children: ReactNode;
}
/**
* App layout wrapper that handles banner rendering and viewport sizing
* Automatically adjusts child components to fit remaining space after banner
*/
export function AppLayout({ children }: AppLayoutProps) {
const { banner } = useBanner();
return (
<>
<style>{`
.h-screen {
height: 100% !important;
}
`}</style>
<div
style={{ height: "100vh", display: "flex", flexDirection: "column" }}
>
{banner}
<div style={{ flex: 1, minHeight: 0, height: 0 }}>{children}</div>
</div>
<NavigationWarningModal />
</>
);
}
@@ -0,0 +1,168 @@
import { ReactNode, useEffect } from "react";
import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvider";
import { FileContextProvider } from "@app/contexts/FileContext";
import { NavigationProvider } from "@app/contexts/NavigationContext";
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
import { FilesModalProvider } from "@app/contexts/FilesModalContext";
import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext";
import { HotkeyProvider } from "@app/contexts/HotkeyContext";
import { SidebarProvider } from "@app/contexts/SidebarContext";
import {
PreferencesProvider,
usePreferences,
} from "@app/contexts/PreferencesContext";
import {
AppConfigProvider,
AppConfigProviderProps,
AppConfigRetryOptions,
useAppConfig,
} from "@app/contexts/AppConfigContext";
import { WorkbenchBarProvider } from "@app/contexts/WorkbenchBarContext";
import { ViewerProvider } from "@app/contexts/ViewerContext";
import { SignatureProvider } from "@app/contexts/SignatureContext";
import { AnnotationProvider } from "@app/contexts/AnnotationContext";
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
import { BannerProvider } from "@app/contexts/BannerContext";
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
import { useScarfTracking } from "@app/hooks/useScarfTracking";
import { useAppInitialization } from "@app/hooks/useAppInitialization";
import { useLogoAssets } from "@app/hooks/useLogoAssets";
import AppConfigLoader from "@app/components/shared/AppConfigLoader";
import { RedactionProvider } from "@app/contexts/RedactionContext";
import { FormFillProvider } from "@app/tools/formFill/FormFillContext";
// Component to initialize scarf tracking (must be inside AppConfigProvider)
function ScarfTrackingInitializer() {
useScarfTracking();
return null;
}
// Component to run app-level initialization (must be inside AppProviders for context access)
function AppInitializer() {
useAppInitialization();
return null;
}
function BrandingAssetManager() {
const { favicon, logo192, manifestHref } = useLogoAssets();
useEffect(() => {
if (typeof document === "undefined") {
return;
}
const setLinkHref = (selector: string, href: string) => {
const link = document.querySelector<HTMLLinkElement>(selector);
if (link && link.getAttribute("href") !== href) {
link.setAttribute("href", href);
}
};
setLinkHref('link[rel="icon"]', favicon);
setLinkHref('link[rel="shortcut icon"]', favicon);
setLinkHref('link[rel="apple-touch-icon"]', logo192);
setLinkHref('link[rel="manifest"]', manifestHref);
}, [favicon, logo192, manifestHref]);
return null;
}
// Avoid requirement to have props which are required in app providers anyway
type AppConfigProviderOverrides = Omit<
AppConfigProviderProps,
"children" | "retryOptions"
>;
export interface AppProvidersProps {
children: ReactNode;
appConfigRetryOptions?: AppConfigRetryOptions;
appConfigProviderProps?: Partial<AppConfigProviderOverrides>;
}
// Component to sync server defaults to preferences when AppConfig loads
function ServerDefaultsSync() {
const { config } = useAppConfig();
const { updateServerDefaults } = usePreferences();
useEffect(() => {
if (config) {
const serverDefaults = {
hideUnavailableTools: config.defaultHideUnavailableTools ?? false,
hideUnavailableConversions:
config.defaultHideUnavailableConversions ?? false,
};
updateServerDefaults(serverDefaults);
}
}, [config, updateServerDefaults]);
return null;
}
/**
* Core application providers
* Contains all providers needed for the core
*/
export function AppProviders({
children,
appConfigRetryOptions,
appConfigProviderProps,
}: AppProvidersProps) {
return (
<PreferencesProvider>
<RainbowThemeProvider>
<ErrorBoundary>
<BannerProvider>
<AppConfigProvider
retryOptions={appConfigRetryOptions}
{...appConfigProviderProps}
>
<ScarfTrackingInitializer />
<AppConfigLoader />
<ServerDefaultsSync />
<FileContextProvider
enableUrlSync={true}
enablePersistence={true}
>
<AppInitializer />
<BrandingAssetManager />
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<PageEditorProvider>
<SignatureProvider>
<RedactionProvider>
<FormFillProvider>
<AnnotationProvider>
<WorkbenchBarProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</WorkbenchBarProvider>
</AnnotationProvider>
</FormFillProvider>
</RedactionProvider>
</SignatureProvider>
</PageEditorProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
</AppConfigProvider>
</BannerProvider>
</ErrorBoundary>
</RainbowThemeProvider>
</PreferencesProvider>
);
}
@@ -0,0 +1,249 @@
import React, { useState, useCallback, useEffect, useMemo } from "react";
import { Modal } from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
import { useFileManager } from "@app/hooks/useFileManager";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { Tool } from "@app/types/tool";
import MobileLayout from "@app/components/fileManager/MobileLayout";
import DesktopLayout from "@app/components/fileManager/DesktopLayout";
import DragOverlay from "@app/components/fileManager/DragOverlay";
import { FileManagerProvider } from "@app/contexts/FileManagerContext";
import { Z_INDEX_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
import {
isGoogleDriveConfigured,
extractGoogleDriveBackendConfig,
} from "@app/services/googleDrivePickerService";
import { loadScript } from "@app/utils/scriptLoader";
import { useAllFiles } from "@app/contexts/FileContext";
import { useFileActions } from "@app/contexts/file/fileHooks";
interface FileManagerProps {
selectedTool?: Tool | null;
}
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
const {
isFilesModalOpen,
closeFilesModal,
onFileUpload,
onRecentFileSelect,
maxSelectable,
} = useFilesModalContext();
const { config } = useAppConfig();
const [recentFiles, setRecentFiles] = useState<StirlingFileStub[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const { loadRecentFiles, handleRemoveFile, loading } = useFileManager();
const { actions: fileActions } = useFileActions();
// Get active file IDs from FileContext to show which files are already loaded
const { fileIds: activeFileIds } = useAllFiles();
// File management handlers
const isFileSupported = useCallback(
(fileName: string) => {
if (!selectedTool?.supportedFormats) return true;
const extension = fileName.split(".").pop()?.toLowerCase();
return selectedTool.supportedFormats.includes(extension || "");
},
[selectedTool?.supportedFormats],
);
const refreshRecentFiles = useCallback(async () => {
const files = await loadRecentFiles();
setRecentFiles(files);
}, [loadRecentFiles]);
const handleRecentFilesSelected = useCallback(
async (files: StirlingFileStub[]) => {
try {
// Use StirlingFileStubs directly - preserves all metadata!
onRecentFileSelect(files);
} catch (error) {
console.error("Failed to process selected files:", error);
}
},
[onRecentFileSelect],
);
const handleNewFileUpload = useCallback(
async (files: File[]) => {
if (files.length > 0) {
try {
// Files will get IDs assigned through onFilesSelect -> FileContext addFiles
onFileUpload(files);
await refreshRecentFiles();
} catch (error) {
console.error("Failed to process dropped files:", error);
}
}
},
[onFileUpload, refreshRecentFiles],
);
const handleRemoveFileByIndex = useCallback(
async (index: number) => {
await handleRemoveFile(
index,
recentFiles,
setRecentFiles,
(fileId) => {
fileActions.removeFiles([fileId], false);
},
refreshRecentFiles,
);
},
[handleRemoveFile, recentFiles, fileActions, refreshRecentFiles],
);
const handleBulkRemove = useCallback((fileIds: FileId[]) => {
const idSet = new Set(fileIds as string[]);
setRecentFiles((prev) => prev.filter((f) => !idSet.has(f.id as string)));
}, []);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 1030);
checkMobile();
window.addEventListener("resize", checkMobile);
return () => window.removeEventListener("resize", checkMobile);
}, []);
useEffect(() => {
if (isFilesModalOpen) {
refreshRecentFiles();
} else {
// Reset state when modal is closed
setIsDragging(false);
}
}, [isFilesModalOpen, refreshRecentFiles]);
// Cleanup any blob URLs when component unmounts
useEffect(() => {
return () => {
// StoredFileMetadata doesn't have blob URLs, so no cleanup needed
// Blob URLs are managed by FileContext and tool operations
console.log(
"FileManager unmounting - FileContext handles blob URL cleanup",
);
};
}, []);
// Preload Google Drive scripts if configured
// Use useMemo to only track Google Drive config changes, not all config updates
const googleDriveBackendConfig = useMemo(
() => extractGoogleDriveBackendConfig(config),
[
config?.googleDriveEnabled,
config?.googleDriveClientId,
config?.googleDriveApiKey,
config?.googleDriveAppId,
],
);
useEffect(() => {
if (isGoogleDriveConfigured(googleDriveBackendConfig)) {
// Load scripts in parallel without blocking
Promise.all([
loadScript({
src: "https://apis.google.com/js/api.js",
id: "gapi-script",
async: true,
defer: true,
}),
loadScript({
src: "https://accounts.google.com/gsi/client",
id: "gis-script",
async: true,
defer: true,
}),
]).catch((error) => {
console.warn("Failed to preload Google Drive scripts:", error);
});
}
}, [googleDriveBackendConfig]);
// Modal size constants for consistent scaling
const modalHeight = "80vh";
const modalWidth = isMobile ? "100%" : "80vw";
const modalMaxWidth = isMobile ? "100%" : "1200px";
const modalMaxHeight = "1200px";
const modalMinWidth = isMobile ? "320px" : "800px";
return (
<Modal
opened={isFilesModalOpen}
onClose={closeFilesModal}
size={isMobile ? "100%" : "auto"}
centered
radius="md"
className="overflow-hidden p-0"
withCloseButton={false}
zIndex={Z_INDEX_FILE_MANAGER_MODAL}
styles={{
content: {
position: "relative",
margin: isMobile ? "1rem" : "2rem",
},
body: { padding: 0 },
header: { display: "none" },
}}
>
<div
style={{
position: "relative",
height: modalHeight,
width: modalWidth,
maxWidth: modalMaxWidth,
maxHeight: modalMaxHeight,
minWidth: modalMinWidth,
margin: "0 auto",
overflow: "hidden",
}}
>
<Dropzone
onDrop={handleNewFileUpload}
onDragEnter={() => setIsDragging(true)}
onDragLeave={() => setIsDragging(false)}
multiple={true}
activateOnClick={false}
style={{
height: "100%",
width: "100%",
border: "none",
borderRadius: "var(--radius-md)",
backgroundColor: "var(--bg-file-manager)",
}}
styles={{
inner: { pointerEvents: "all" },
}}
>
<FileManagerProvider
recentFiles={recentFiles}
onRecentFilesSelected={handleRecentFilesSelected}
onNewFilesSelect={handleNewFileUpload}
onClose={closeFilesModal}
isFileSupported={isFileSupported}
isOpen={isFilesModalOpen}
maxSelectable={maxSelectable}
onFileRemove={handleRemoveFileByIndex}
onBulkRemove={handleBulkRemove}
modalHeight={modalHeight}
refreshRecentFiles={refreshRecentFiles}
isLoading={loading}
activeFileIds={activeFileIds}
>
{isMobile ? <MobileLayout /> : <DesktopLayout />}
</FileManagerProvider>
</Dropzone>
<DragOverlay isVisible={isDragging} />
</div>
</Modal>
);
};
export default FileManager;
@@ -0,0 +1,84 @@
import React from "react";
import { Card, Group, Text, Button, Progress } from "@mantine/core";
import { useTranslation } from "react-i18next";
import StorageIcon from "@mui/icons-material/Storage";
import DeleteIcon from "@mui/icons-material/Delete";
import { StorageStats } from "@app/services/fileStorage";
import { formatFileSize } from "@app/utils/fileUtils";
import { getStorageUsagePercent } from "@app/utils/storageUtils";
interface StorageStatsCardProps {
storageStats: StorageStats | null;
filesCount: number;
onClearAll: () => void;
onReloadFiles: () => void;
}
const StorageStatsCard: React.FC<StorageStatsCardProps> = ({
storageStats,
filesCount,
onClearAll,
onReloadFiles,
}) => {
const { t } = useTranslation();
if (!storageStats) return null;
const storageUsagePercent = getStorageUsagePercent(storageStats);
return (
<Card withBorder p="sm" mb="md" style={{ width: "90%", maxWidth: 600 }}>
<Group align="center" gap="md">
<StorageIcon />
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t("fileManager.storage", "Storage")}:{" "}
{formatFileSize(storageStats.used)}
{storageStats.quota && ` / ${formatFileSize(storageStats.quota)}`}
</Text>
{storageStats.quota && (
<Progress
value={storageUsagePercent}
color={
storageUsagePercent > 80
? "red"
: storageUsagePercent > 60
? "yellow"
: "blue"
}
size="sm"
mt={4}
/>
)}
<Text size="xs" c="dimmed">
{storageStats.fileCount}{" "}
{t("fileManager.filesStored", "files stored")}
</Text>
</div>
<Group gap="xs">
{filesCount > 0 && (
<Button
variant="light"
color="red"
size="xs"
onClick={onClearAll}
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
>
{t("fileManager.clearAll", "Clear All")}
</Button>
)}
<Button
variant="light"
color="blue"
size="xs"
onClick={onReloadFiles}
>
Reload Files
</Button>
</Group>
</Group>
</Card>
);
};
export default StorageStatsCard;
@@ -0,0 +1,95 @@
import React, { createContext, useContext, ReactNode } from "react";
interface PDFAnnotationContextValue {
// Drawing mode management
activateDrawMode: () => void;
deactivateDrawMode: () => void;
activateSignaturePlacementMode: () => void;
activateDeleteMode: () => void;
// Drawing settings
updateDrawSettings: (color: string, size: number) => void;
// History operations
undo: () => void;
redo: () => void;
// Image data management
storeImageData: (id: string, data: string) => void;
getImageData: (id: string) => string | undefined;
// Placement state
isPlacementMode: boolean;
// Signature configuration
signatureConfig: any | null;
setSignatureConfig: (config: any | null) => void;
}
const PDFAnnotationContext = createContext<
PDFAnnotationContextValue | undefined
>(undefined);
interface PDFAnnotationProviderProps {
children: ReactNode;
// These would come from the signature context
activateDrawMode: () => void;
deactivateDrawMode: () => void;
activateSignaturePlacementMode: () => void;
activateDeleteMode: () => void;
updateDrawSettings: (color: string, size: number) => void;
undo: () => void;
redo: () => void;
storeImageData: (id: string, data: string) => void;
getImageData: (id: string) => string | undefined;
isPlacementMode: boolean;
signatureConfig: any | null;
setSignatureConfig: (config: any | null) => void;
}
export const PDFAnnotationProvider: React.FC<PDFAnnotationProviderProps> = ({
children,
activateDrawMode,
deactivateDrawMode,
activateSignaturePlacementMode,
activateDeleteMode,
updateDrawSettings,
undo,
redo,
storeImageData,
getImageData,
isPlacementMode,
signatureConfig,
setSignatureConfig,
}) => {
const contextValue: PDFAnnotationContextValue = {
activateDrawMode,
deactivateDrawMode,
activateSignaturePlacementMode,
activateDeleteMode,
updateDrawSettings,
undo,
redo,
storeImageData,
getImageData,
isPlacementMode,
signatureConfig,
setSignatureConfig,
};
return (
<PDFAnnotationContext.Provider value={contextValue}>
{children}
</PDFAnnotationContext.Provider>
);
};
export const usePDFAnnotation = (): PDFAnnotationContextValue => {
const context = useContext(PDFAnnotationContext);
if (context === undefined) {
throw new Error(
"usePDFAnnotation must be used within a PDFAnnotationProvider",
);
}
return context;
};
@@ -0,0 +1,120 @@
import React, { useEffect, useState } from "react";
import { Stack, Alert, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { DrawingControls } from "@app/components/annotation/shared/DrawingControls";
import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
import { usePDFAnnotation } from "@app/components/annotation/providers/PDFAnnotationProvider";
import { useSignature } from "@app/contexts/SignatureContext";
export interface AnnotationToolConfig {
enableDrawing?: boolean;
enableImageUpload?: boolean;
enableTextInput?: boolean;
showPlaceButton?: boolean;
placeButtonText?: string;
}
interface BaseAnnotationToolProps {
config: AnnotationToolConfig;
children: React.ReactNode;
onSignatureDataChange?: (data: string | null) => void;
disabled?: boolean;
}
export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
config,
children,
onSignatureDataChange,
disabled = false,
}) => {
const { t } = useTranslation();
const { activateSignaturePlacementMode, undo, redo } = usePDFAnnotation();
const { historyApiRef } = useSignature();
const [selectedColor, setSelectedColor] = useState("#000000");
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [signatureData, setSignatureData] = useState<string | null>(null);
const [historyAvailability, setHistoryAvailability] = useState({
canUndo: false,
canRedo: false,
});
const historyApiInstance = historyApiRef.current;
useEffect(() => {
if (!historyApiInstance) {
setHistoryAvailability({ canUndo: false, canRedo: false });
return;
}
const updateAvailability = () => {
setHistoryAvailability({
canUndo: historyApiInstance.canUndo?.() ?? false,
canRedo: historyApiInstance.canRedo?.() ?? false,
});
};
const unsubscribe = historyApiInstance.subscribe?.(updateAvailability);
updateAvailability();
return () => {
unsubscribe?.();
};
}, [historyApiInstance]);
const handleSignatureDataChange = (data: string | null) => {
setSignatureData(data);
onSignatureDataChange?.(data);
};
const handlePlaceSignature = () => {
if (activateSignaturePlacementMode) {
activateSignaturePlacementMode();
}
};
return (
<Stack gap="md">
{/* Drawing Controls (Undo/Redo/Place) */}
<DrawingControls
onUndo={undo}
onRedo={redo}
canUndo={historyAvailability.canUndo}
canRedo={historyAvailability.canRedo}
onPlaceSignature={
config.showPlaceButton ? handlePlaceSignature : undefined
}
hasSignatureData={!!signatureData}
disabled={disabled}
showPlaceButton={config.showPlaceButton}
placeButtonText={config.placeButtonText}
/>
{/* Tool Content */}
{React.cloneElement(children as React.ReactElement<any>, {
selectedColor,
signatureData,
onSignatureDataChange: handleSignatureDataChange,
onColorSwatchClick: () => setIsColorPickerOpen(true),
disabled,
})}
{/* Instructions for placing signature */}
<Alert
color="blue"
title={t("sign.instructions.title", "How to add signature")}
>
<Text size="sm">
Click anywhere on the PDF to place your annotation.
</Text>
</Alert>
{/* Color Picker Modal */}
<ColorPicker
isOpen={isColorPickerOpen}
onClose={() => setIsColorPickerOpen(false)}
selectedColor={selectedColor}
onColorChange={setSelectedColor}
/>
</Stack>
);
};
@@ -0,0 +1,131 @@
import {
ActionIcon,
Tooltip,
Popover,
Stack,
ColorSwatch,
ColorPicker as MantineColorPicker,
Group,
} from "@mantine/core";
import { useState, useCallback, useEffect } from "react";
import ColorizeIcon from "@mui/icons-material/Colorize";
// safari and firefox do not support the eye dropper API, only edge, chrome and opera do.
// the button is hidden in the UI if the API is not supported.
const supportsEyeDropper =
typeof window !== "undefined" && "EyeDropper" in window;
interface EyeDropper {
open(): Promise<{ sRGBHex: string }>;
}
declare const EyeDropper: { new (): EyeDropper };
interface ColorControlProps {
value: string;
onChange: (color: string) => void;
label: string;
disabled?: boolean;
}
export function ColorControl({
value,
onChange,
label,
disabled = false,
}: ColorControlProps) {
const [opened, setOpened] = useState(false);
// Buffer the colour locally so the picker stays responsive during drag.
// Only propagate to the parent (which triggers expensive annotation updates)
// on onChangeEnd (mouse-up / swatch click), preventing infinite re-render loops.
const [localColor, setLocalColor] = useState(value);
useEffect(() => {
setLocalColor(value);
}, [value]);
const handleEyeDropper = useCallback(async () => {
if (!supportsEyeDropper) return;
try {
const eyeDropper = new EyeDropper();
const result = await eyeDropper.open();
onChange(result.sRGBHex);
} catch {
// User cancelled or browser error — no-op
}
}, [onChange]);
return (
<Popover
opened={opened}
onChange={setOpened}
position="bottom"
withArrow
withinPortal
>
<Popover.Target>
<Tooltip label={label}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
onClick={() => setOpened(!opened)}
disabled={disabled}
styles={{
root: {
flexShrink: 0,
backgroundColor: "var(--bg-raised)",
border: "1px solid var(--border-default)",
color: "var(--text-secondary)",
"&:hover": {
backgroundColor: "var(--hover-bg)",
borderColor: "var(--border-strong)",
color: "var(--text-primary)",
},
},
}}
>
<ColorSwatch color={localColor} size={18} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<MantineColorPicker
format="hex"
value={localColor}
onChange={setLocalColor}
onChangeEnd={onChange}
swatches={[
"#000000",
"#ffffff",
"#ff0000",
"#00ff00",
"#0000ff",
"#ffff00",
"#ff00ff",
"#00ffff",
"#ffa500",
"transparent",
]}
swatchesPerRow={5}
size="sm"
/>
{supportsEyeDropper && (
<Group justify="flex-end">
<Tooltip label="Pick colour from screen">
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={handleEyeDropper}
style={{ color: "var(--text-primary)" }}
>
<ColorizeIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
</Group>
)}
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,114 @@
import React from "react";
import {
Modal,
Stack,
ColorPicker as MantineColorPicker,
Group,
Button,
ColorSwatch,
Slider,
Text,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
interface ColorPickerProps {
isOpen: boolean;
onClose: () => void;
selectedColor: string;
onColorChange: (color: string) => void;
title?: string;
opacity?: number;
onOpacityChange?: (opacity: number) => void;
showOpacity?: boolean;
opacityLabel?: string;
}
export const ColorPicker: React.FC<ColorPickerProps> = ({
isOpen,
onClose,
selectedColor,
onColorChange,
title,
opacity,
onOpacityChange,
showOpacity = false,
opacityLabel,
}) => {
const { t } = useTranslation();
const resolvedTitle = title ?? t("colorPicker.title", "Choose colour");
const resolvedOpacityLabel =
opacityLabel ?? t("annotation.opacity", "Opacity");
return (
<Modal
opened={isOpen}
onClose={onClose}
title={resolvedTitle}
size="sm"
centered
>
<Stack gap="md">
<MantineColorPicker
format="hex"
value={selectedColor}
onChange={onColorChange}
swatches={[
"#000000",
"#0066cc",
"#cc0000",
"#cc6600",
"#009900",
"#6600cc",
]}
swatchesPerRow={6}
size="lg"
fullWidth
/>
{showOpacity && onOpacityChange && opacity !== undefined && (
<Stack gap="xs">
<Text size="sm" fw={500}>
{resolvedOpacityLabel}
</Text>
<Slider
min={10}
max={100}
value={opacity}
onChange={onOpacityChange}
marks={[
{ value: 25, label: "25%" },
{ value: 50, label: "50%" },
{ value: 75, label: "75%" },
{ value: 100, label: "100%" },
]}
/>
</Stack>
)}
<Group justify="flex-end">
<Button onClick={onClose}>{t("common.done", "Done")}</Button>
</Group>
</Stack>
</Modal>
);
};
interface ColorSwatchButtonProps {
color: string;
onClick: () => void;
size?: number;
}
export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({
color,
onClick,
size = 24,
}) => {
return (
<ColorSwatch
color={color}
size={size}
radius={0}
style={{ cursor: "pointer" }}
onClick={onClick}
/>
);
};
@@ -0,0 +1,345 @@
import React, { useEffect, useRef, useState } from "react";
import { Paper, Button, Modal, Stack, Text, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ColorSwatchButton } from "@app/components/annotation/shared/ColorPicker";
import PenSizeSelector from "@app/components/tools/sign/PenSizeSelector";
import SignaturePad from "signature_pad";
import { PrivateContent } from "@app/components/shared/PrivateContent";
interface DrawingCanvasProps {
selectedColor: string;
penSize: number;
penSizeInput: string;
onColorSwatchClick: () => void;
onPenSizeChange: (size: number) => void;
onPenSizeInputChange: (input: string) => void;
onSignatureDataChange: (data: string | null) => void;
onDrawingComplete?: () => void;
onModalClose?: () => void;
disabled?: boolean;
autoOpen?: boolean;
width?: number;
height?: number;
modalWidth?: number;
modalHeight?: number;
additionalButtons?: React.ReactNode;
initialSignatureData?: string;
}
export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
selectedColor,
penSize,
penSizeInput,
onColorSwatchClick,
onPenSizeChange,
onPenSizeInputChange,
onSignatureDataChange,
onDrawingComplete,
onModalClose,
disabled = false,
autoOpen = false,
width = 400,
height = 150,
initialSignatureData,
}) => {
const { t } = useTranslation();
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const modalCanvasRef = useRef<HTMLCanvasElement>(null);
const padRef = useRef<SignaturePad | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [savedSignatureData, setSavedSignatureData] = useState<string | null>(
null,
);
const initPad = (canvas: HTMLCanvasElement) => {
if (!padRef.current) {
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
padRef.current = new SignaturePad(canvas, {
penColor: selectedColor,
minWidth: penSize * 0.5,
maxWidth: penSize * 2.5,
throttle: 10,
minDistance: 5,
velocityFilterWeight: 0.7,
});
// Restore saved signature data if it exists
if (savedSignatureData) {
const img = new Image();
img.onload = () => {
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
}
};
img.src = savedSignatureData;
}
}
};
const openModal = () => {
// Clear pad ref so it reinitializes
if (padRef.current) {
padRef.current.off();
padRef.current = null;
}
setModalOpen(true);
};
useEffect(() => {
if (autoOpen) openModal();
}, [autoOpen]);
const trimCanvas = (canvas: HTMLCanvasElement): string => {
const ctx = canvas.getContext("2d");
if (!ctx) return canvas.toDataURL("image/png");
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data;
let minX = canvas.width,
minY = canvas.height,
maxX = 0,
maxY = 0;
// Find bounds of non-transparent pixels
for (let y = 0; y < canvas.height; y++) {
for (let x = 0; x < canvas.width; x++) {
const alpha = pixels[(y * canvas.width + x) * 4 + 3];
if (alpha > 0) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
const trimWidth = maxX - minX + 1;
const trimHeight = maxY - minY + 1;
// Create trimmed canvas
const trimmedCanvas = document.createElement("canvas");
trimmedCanvas.width = trimWidth;
trimmedCanvas.height = trimHeight;
const trimmedCtx = trimmedCanvas.getContext("2d");
if (trimmedCtx) {
trimmedCtx.drawImage(
canvas,
minX,
minY,
trimWidth,
trimHeight,
0,
0,
trimWidth,
trimHeight,
);
}
return trimmedCanvas.toDataURL("image/png");
};
const renderPreview = (dataUrl: string) => {
const canvas = previewCanvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const img = new Image();
img.onload = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const scale = Math.min(
canvas.width / img.width,
canvas.height / img.height,
);
const scaledWidth = img.width * scale;
const scaledHeight = img.height * scale;
const x = (canvas.width - scaledWidth) / 2;
const y = (canvas.height - scaledHeight) / 2;
ctx.drawImage(img, x, y, scaledWidth, scaledHeight);
};
img.src = dataUrl;
};
const closeModal = () => {
if (padRef.current && !padRef.current.isEmpty()) {
const canvas = modalCanvasRef.current;
if (canvas) {
const trimmedPng = trimCanvas(canvas);
const untrimmedPng = canvas.toDataURL("image/png");
setSavedSignatureData(untrimmedPng); // Save untrimmed for restoration
onSignatureDataChange(trimmedPng);
renderPreview(trimmedPng);
if (onDrawingComplete) {
onDrawingComplete();
}
}
}
if (padRef.current) {
padRef.current.off();
padRef.current = null;
}
setModalOpen(false);
onModalClose?.();
};
const clear = () => {
if (padRef.current) {
padRef.current.clear();
}
if (previewCanvasRef.current) {
const ctx = previewCanvasRef.current.getContext("2d");
if (ctx) {
ctx.clearRect(
0,
0,
previewCanvasRef.current.width,
previewCanvasRef.current.height,
);
}
}
setSavedSignatureData(null); // Clear saved signature
onSignatureDataChange(null);
};
const updatePenColor = (color: string) => {
if (padRef.current) {
padRef.current.penColor = color;
}
};
const updatePenSize = (size: number) => {
if (padRef.current) {
padRef.current.minWidth = size * 0.8;
padRef.current.maxWidth = size * 1.2;
}
};
useEffect(() => {
updatePenColor(selectedColor);
}, [selectedColor]);
useEffect(() => {
updatePenSize(penSize);
}, [penSize]);
useEffect(() => {
const canvas = previewCanvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
if (!initialSignatureData) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
setSavedSignatureData(null);
return;
}
renderPreview(initialSignatureData);
setSavedSignatureData(initialSignatureData);
}, [initialSignatureData]);
return (
<>
<Paper withBorder p="md">
<Stack gap="sm">
<PrivateContent>
<Text fw={500}>
{t("sign.canvas.heading", "Draw your signature")}
</Text>
<canvas
ref={previewCanvasRef}
width={width}
height={height}
style={{
border: "1px solid #ccc",
borderRadius: "4px",
cursor: disabled ? "default" : "pointer",
backgroundColor: "#ffffff",
width: "100%",
}}
onClick={disabled ? undefined : openModal}
/>
</PrivateContent>
<Text size="sm" c="dimmed" ta="center">
{t("sign.canvas.clickToOpen", "Click to open the drawing canvas")}
</Text>
</Stack>
</Paper>
<Modal
opened={modalOpen}
onClose={closeModal}
title={t("sign.canvas.modalTitle", "Draw your signature")}
size="auto"
centered
>
<Stack gap="md">
<Group gap="lg" align="flex-end" wrap="wrap">
<Stack gap={4} style={{ minWidth: 120 }}>
<Text size="sm" fw={500}>
{t("sign.canvas.colorLabel", "Colour")}
</Text>
<ColorSwatchButton
color={selectedColor}
onClick={onColorSwatchClick}
/>
</Stack>
<Stack gap={4} style={{ minWidth: 120 }}>
<Text size="sm" fw={500}>
{t("sign.canvas.penSizeLabel", "Pen size")}
</Text>
<PenSizeSelector
value={penSize}
inputValue={penSizeInput}
onValueChange={(size) => {
onPenSizeChange(size);
updatePenSize(size);
}}
onInputChange={onPenSizeInputChange}
placeholder={t("sign.canvas.penSizePlaceholder", "Size")}
size="compact-sm"
style={{ width: "80px" }}
/>
</Stack>
</Group>
<PrivateContent>
<canvas
ref={(el) => {
modalCanvasRef.current = el;
if (el) initPad(el);
}}
style={{
border: "1px solid #ccc",
borderRadius: "4px",
display: "block",
touchAction: "none",
backgroundColor: "white",
width: "100%",
maxWidth: "50rem",
height: "25rem",
cursor: "crosshair",
}}
/>
</PrivateContent>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<Button variant="subtle" color="red" onClick={clear}>
{t("sign.canvas.clear", "Clear canvas")}
</Button>
<Button onClick={closeModal}>{t("common.done", "Done")}</Button>
</div>
</Stack>
</Modal>
</>
);
};
export default DrawingCanvas;
@@ -0,0 +1,92 @@
import React from "react";
import { Group, Button, ActionIcon, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { LocalIcon } from "@app/components/shared/LocalIcon";
interface DrawingControlsProps {
onUndo?: () => void;
onRedo?: () => void;
onPlaceSignature?: () => void;
hasSignatureData?: boolean;
disabled?: boolean;
canUndo?: boolean;
canRedo?: boolean;
showPlaceButton?: boolean;
placeButtonText?: string;
additionalControls?: React.ReactNode;
}
export const DrawingControls: React.FC<DrawingControlsProps> = ({
onUndo,
onRedo,
onPlaceSignature,
hasSignatureData = false,
disabled = false,
canUndo = true,
canRedo = true,
showPlaceButton = true,
placeButtonText = "Update and Place",
additionalControls,
}) => {
const { t } = useTranslation();
const undoDisabled = disabled || !canUndo;
const redoDisabled = disabled || !canRedo;
return (
<Group gap="xs" wrap="nowrap" align="center">
{onUndo && (
<Tooltip label={t("sign.undo", "Undo")}>
<ActionIcon
variant="subtle"
size="lg"
aria-label={t("sign.undo", "Undo")}
onClick={onUndo}
disabled={undoDisabled}
color={undoDisabled ? "gray" : "blue"}
>
<LocalIcon
icon="undo"
width={20}
height={20}
style={{ color: "currentColor" }}
/>
</ActionIcon>
</Tooltip>
)}
{onRedo && (
<Tooltip label={t("sign.redo", "Redo")}>
<ActionIcon
variant="subtle"
size="lg"
aria-label={t("sign.redo", "Redo")}
onClick={onRedo}
disabled={redoDisabled}
color={redoDisabled ? "gray" : "blue"}
>
<LocalIcon
icon="redo"
width={20}
height={20}
style={{ color: "currentColor" }}
/>
</ActionIcon>
</Tooltip>
)}
{additionalControls}
{/* Place Signature Button */}
{showPlaceButton && onPlaceSignature && (
<Button
variant="filled"
color="blue"
onClick={onPlaceSignature}
disabled={disabled || !hasSignatureData}
ml="auto"
>
{placeButtonText}
</Button>
)}
</Group>
);
};
@@ -0,0 +1,301 @@
import React, { useState } from "react";
import { FileInput, Text, Stack, Checkbox } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import { removeWhiteBackground } from "@app/utils/imageTransparency";
import { alert } from "@app/components/toast";
interface ImageUploaderProps {
onImageChange: (file: File | null) => void;
disabled?: boolean;
label?: string;
placeholder?: string;
hint?: string;
allowBackgroundRemoval?: boolean;
onProcessedImageData?: (dataUrl: string | null) => void;
}
export const ImageUploader: React.FC<ImageUploaderProps> = ({
onImageChange,
disabled = false,
label,
placeholder,
hint,
allowBackgroundRemoval = false,
onProcessedImageData,
}) => {
const { t } = useTranslation();
const [removeBackground, setRemoveBackground] = useState(false);
const [currentFile, setCurrentFile] = useState<File | null>(null);
const [originalImageData, setOriginalImageData] = useState<string | null>(
null,
);
const [isProcessing, setIsProcessing] = useState(false);
const processImage = async (
imageSource: File | string,
shouldRemoveBackground: boolean,
): Promise<void> => {
if (shouldRemoveBackground && allowBackgroundRemoval) {
setIsProcessing(true);
try {
const transparentImageDataUrl = await removeWhiteBackground(
imageSource,
{
autoDetectCorner: true,
tolerance: 15,
},
);
onProcessedImageData?.(transparentImageDataUrl);
} catch (error) {
console.error("Error removing background:", error);
alert({
title: t(
"sign.image.backgroundRemovalFailedTitle",
"Background removal failed",
),
body: t(
"sign.image.backgroundRemovalFailedMessage",
"Could not remove the background from the image. Using original image instead.",
),
alertType: "error",
});
onProcessedImageData?.(null);
} finally {
setIsProcessing(false);
}
} else {
// When background removal is disabled, return the original image data
if (typeof imageSource === "string") {
onProcessedImageData?.(imageSource);
} else {
// Convert File to data URL if needed
const reader = new FileReader();
reader.onload = (e) => {
onProcessedImageData?.(e.target?.result as string);
};
reader.readAsDataURL(imageSource);
}
}
};
const handleImageChange = async (file: File | null) => {
if (file && !disabled) {
try {
// Validate that it's actually an image file or SVG
if (
!file.type.startsWith("image/") &&
!file.name.toLowerCase().endsWith(".svg")
) {
console.error("Selected file is not an image or SVG");
return;
}
setCurrentFile(file);
onImageChange(file);
let dataUrlToProcess: string;
// Check if file is SVG
const isSvg =
file.type === "image/svg+xml" ||
file.name.toLowerCase().endsWith(".svg");
if (isSvg) {
// For SVG, convert to PNG so it can be embedded in PDF
dataUrlToProcess = await convertSvgToPng(file);
} else {
// For other images, read as data URL directly
dataUrlToProcess = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target?.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
setOriginalImageData(dataUrlToProcess);
await processImage(dataUrlToProcess, removeBackground);
} catch (error) {
console.error("Error processing image file:", error);
}
} else if (!file) {
// Clear image data when no file is selected
setCurrentFile(null);
setOriginalImageData(null);
onImageChange(null);
onProcessedImageData?.(null);
}
};
// Helper function to convert SVG to PNG
const convertSvgToPng = async (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const svgText = e.target?.result as string;
// Parse SVG to get dimensions
const parser = new DOMParser();
const svgDoc = parser.parseFromString(svgText, "image/svg+xml");
const svgElement = svgDoc.documentElement;
// Get SVG dimensions
let width = 800; // Default width
let height = 600; // Default height
if (
svgElement.hasAttribute("width") &&
svgElement.hasAttribute("height")
) {
width = parseFloat(svgElement.getAttribute("width") || "800");
height = parseFloat(svgElement.getAttribute("height") || "600");
} else if (svgElement.hasAttribute("viewBox")) {
const viewBox = svgElement.getAttribute("viewBox")?.split(/\s+|,/);
if (viewBox && viewBox.length === 4) {
width = parseFloat(viewBox[2]);
height = parseFloat(viewBox[3]);
}
}
// Ensure reasonable dimensions
if (
width === 0 ||
height === 0 ||
!isFinite(width) ||
!isFinite(height)
) {
width = 800;
height = 600;
}
// Scale large SVGs down
const maxDimension = 2048;
if (width > maxDimension || height > maxDimension) {
const scale = Math.min(maxDimension / width, maxDimension / height);
width *= scale;
height *= scale;
}
console.log("Converting SVG to PNG:", { width, height });
// Create an image element to render SVG
const img = new Image();
const blob = new Blob([svgText], {
type: "image/svg+xml;charset=utf-8",
});
const url = URL.createObjectURL(blob);
img.onload = () => {
try {
// Use computed dimensions or image natural dimensions
const finalWidth = img.naturalWidth || img.width || width;
const finalHeight = img.naturalHeight || img.height || height;
console.log("Image loaded:", {
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight,
finalWidth,
finalHeight,
});
// Create canvas to convert to PNG
const canvas = document.createElement("canvas");
canvas.width = finalWidth;
canvas.height = finalHeight;
const ctx = canvas.getContext("2d");
if (!ctx) {
URL.revokeObjectURL(url);
reject(new Error("Failed to get canvas context"));
return;
}
// Fill with white background (optional, for transparency support)
ctx.fillStyle = "white";
ctx.fillRect(0, 0, finalWidth, finalHeight);
// Draw SVG
ctx.drawImage(img, 0, 0, finalWidth, finalHeight);
URL.revokeObjectURL(url);
// Convert canvas to PNG data URL
const pngDataUrl = canvas.toDataURL("image/png");
console.log("SVG converted to PNG successfully");
resolve(pngDataUrl);
} catch (error) {
URL.revokeObjectURL(url);
console.error("Error during canvas rendering:", error);
reject(error);
}
};
img.onerror = (error) => {
URL.revokeObjectURL(url);
console.error("Failed to load SVG image:", error);
reject(new Error("Failed to load SVG image"));
};
img.src = url;
} catch (error) {
console.error("Error parsing SVG:", error);
reject(error);
}
};
reader.onerror = () => {
console.error("Error reading file:", reader.error);
reject(reader.error);
};
reader.readAsText(file);
});
};
const handleBackgroundRemovalChange = async (checked: boolean) => {
if (isProcessing) return; // Prevent race conditions
setRemoveBackground(checked);
if (originalImageData) {
await processImage(originalImageData, checked);
}
};
return (
<Stack gap="sm">
<PrivateContent>
<FileInput
label={label}
placeholder={
placeholder || t("sign.image.placeholder", "Select image file")
}
accept="image/*,.svg"
onChange={handleImageChange}
disabled={disabled || isProcessing}
/>
</PrivateContent>
{allowBackgroundRemoval && (
<Checkbox
label={t(
"sign.image.removeBackground",
"Remove white background (make transparent)",
)}
checked={removeBackground}
onChange={(event) =>
handleBackgroundRemovalChange(event.currentTarget.checked)
}
disabled={disabled || !currentFile || isProcessing}
/>
)}
{hint && (
<Text size="sm" c="dimmed">
{hint}
</Text>
)}
{isProcessing && (
<Text size="sm" c="dimmed">
{t("sign.image.processing", "Processing image...")}
</Text>
)}
</Stack>
);
};
@@ -0,0 +1,71 @@
import {
ActionIcon,
Tooltip,
Popover,
Stack,
Slider,
Text,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import OpacityIcon from "@mui/icons-material/Opacity";
interface OpacityControlProps {
value: number; // 0-100
onChange: (value: number) => void;
disabled?: boolean;
}
export function OpacityControl({
value,
onChange,
disabled = false,
}: OpacityControlProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
return (
<Popover opened={opened} onChange={setOpened} position="top" withArrow>
<Popover.Target>
<Tooltip label={t("annotation.opacity", "Opacity")}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
onClick={() => setOpened(!opened)}
disabled={disabled}
styles={{
root: {
flexShrink: 0,
backgroundColor: "var(--bg-raised)",
border: "1px solid var(--border-default)",
color: "var(--text-secondary)",
"&:hover": {
backgroundColor: "var(--hover-bg)",
borderColor: "var(--border-strong)",
color: "var(--text-primary)",
},
},
}}
>
<OpacityIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs" style={{ minWidth: 150 }}>
<Text size="xs" fw={500}>
{t("annotation.opacity", "Opacity")}
</Text>
<Slider
value={value}
onChange={onChange}
min={10}
max={100}
label={(val) => `${val}%`}
/>
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,234 @@
import {
ActionIcon,
Tooltip,
Popover,
Stack,
Slider,
Text,
Group,
Button,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import type { TrackedAnnotation } from "@embedpdf/plugin-annotation";
import type { PdfAnnotationObject } from "@embedpdf/models";
import type { AnnotationPatch } from "@app/components/viewer/viewerTypes";
import TuneIcon from "@mui/icons-material/Tune";
import FormatAlignLeftIcon from "@mui/icons-material/FormatAlignLeft";
import FormatAlignCenterIcon from "@mui/icons-material/FormatAlignCenter";
import FormatAlignRightIcon from "@mui/icons-material/FormatAlignRight";
export type PropertiesAnnotationType = "text" | "note" | "shape";
interface PropertiesPopoverProps {
annotationType: PropertiesAnnotationType;
annotation: TrackedAnnotation<PdfAnnotationObject> | undefined;
onUpdate: (patch: AnnotationPatch) => void;
disabled?: boolean;
}
export function PropertiesPopover({
annotationType,
annotation,
onUpdate,
disabled = false,
}: PropertiesPopoverProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
interface AnnotationObjectProps {
fontSize?: number;
textAlign?: number | string;
opacity?: number;
borderWidth?: number;
strokeWidth?: number;
}
const obj = annotation?.object as
| (PdfAnnotationObject & AnnotationObjectProps)
| undefined;
// Get current values
const fontSize = obj?.fontSize ?? 14;
const textAlign = obj?.textAlign;
const currentAlign =
typeof textAlign === "number"
? textAlign === 1
? "center"
: textAlign === 2
? "right"
: "left"
: textAlign === "center"
? "center"
: textAlign === "right"
? "right"
: "left";
// For shapes
const opacity = Math.round((obj?.opacity ?? 1) * 100);
const strokeWidth = obj?.borderWidth ?? obj?.strokeWidth ?? 2;
const borderVisible = strokeWidth > 0;
const renderTextNoteControls = () => (
<Stack gap="md" style={{ minWidth: 280 }}>
{/* Font Size */}
<div>
<Text size="xs" fw={500} mb={4}>
{t("annotation.fontSize", "Font size")}
</Text>
<Slider
value={fontSize}
onChange={(val) => onUpdate({ fontSize: val })}
min={8}
max={32}
label={(val) => `${val}pt`}
/>
</div>
{/* Opacity */}
<div>
<Text size="xs" fw={500} mb={4}>
{t("annotation.opacity", "Opacity")}
</Text>
<Slider
value={Math.round((obj?.opacity ?? 1) * 100)}
onChange={(val) => onUpdate({ opacity: val / 100 })}
min={10}
max={100}
label={(val) => `${val}%`}
/>
</div>
{/* Text Alignment */}
<div>
<Text size="xs" fw={500} mb={4}>
{t("annotation.textAlignment", "Text Alignment")}
</Text>
<Group gap="xs">
<ActionIcon
variant={currentAlign === "left" ? "filled" : "default"}
onClick={() => onUpdate({ textAlign: 0 })}
size="md"
>
<FormatAlignLeftIcon style={{ fontSize: 18 }} />
</ActionIcon>
<ActionIcon
variant={currentAlign === "center" ? "filled" : "default"}
onClick={() => onUpdate({ textAlign: 1 })}
size="md"
>
<FormatAlignCenterIcon style={{ fontSize: 18 }} />
</ActionIcon>
<ActionIcon
variant={currentAlign === "right" ? "filled" : "default"}
onClick={() => onUpdate({ textAlign: 2 })}
size="md"
>
<FormatAlignRightIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Group>
</div>
</Stack>
);
const renderShapeControls = () => (
<Stack gap="md" style={{ minWidth: 250 }}>
{/* Opacity */}
<div>
<Text size="xs" fw={500} mb={4}>
{t("annotation.opacity", "Opacity")}
</Text>
<Slider
value={opacity}
onChange={(val) => {
const newOpacity = val / 100;
onUpdate({
opacity: newOpacity,
strokeOpacity: newOpacity,
fillOpacity: newOpacity,
});
}}
min={10}
max={100}
label={(val) => `${val}%`}
/>
</div>
{/* Stroke Width */}
<div>
<Group gap="xs" align="flex-end">
<div style={{ flex: 1 }}>
<Text size="xs" fw={500} mb={4}>
{t("annotation.strokeWidth", "Stroke")}
</Text>
<Slider
value={strokeWidth}
onChange={(val) => {
onUpdate({
borderWidth: val,
strokeWidth: val,
lineWidth: val,
});
}}
min={0}
max={12}
label={(val) => `${val}pt`}
/>
</div>
<Button
size="xs"
variant={!borderVisible ? "filled" : "light"}
onClick={() => {
const newValue = borderVisible ? 0 : 1;
onUpdate({
borderWidth: newValue,
strokeWidth: newValue,
lineWidth: newValue,
});
}}
>
{borderVisible
? t("annotation.borderOn", "Border: On")
: t("annotation.borderOff", "Border: Off")}
</Button>
</Group>
</div>
</Stack>
);
return (
<Popover opened={opened} onChange={setOpened} position="bottom" withArrow>
<Popover.Target>
<Tooltip label={t("annotation.properties", "Properties")}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
onClick={() => setOpened(!opened)}
disabled={disabled}
styles={{
root: {
flexShrink: 0,
backgroundColor: "var(--bg-raised)",
border: "1px solid var(--border-default)",
color: "var(--text-secondary)",
"&:hover": {
backgroundColor: "var(--hover-bg)",
borderColor: "var(--border-strong)",
color: "var(--text-primary)",
},
},
}}
>
<TuneIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
{(annotationType === "text" || annotationType === "note") &&
renderTextNoteControls()}
{annotationType === "shape" && renderShapeControls()}
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,272 @@
import React, { useState, useEffect } from "react";
import {
Stack,
TextInput,
Select,
Combobox,
useCombobox,
Group,
Box,
SegmentedControl,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
interface TextInputWithFontProps {
text: string;
onTextChange: (text: string) => void;
fontSize: number;
onFontSizeChange: (size: number) => void;
fontFamily: string;
onFontFamilyChange: (family: string) => void;
textColor?: string;
onTextColorChange?: (color: string) => void;
textAlign?: "left" | "center" | "right";
onTextAlignChange?: (align: "left" | "center" | "right") => void;
disabled?: boolean;
label: string;
placeholder: string;
fontLabel: string;
fontSizeLabel: string;
fontSizePlaceholder: string;
colorLabel?: string;
onAnyChange?: () => void;
}
export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
text,
onTextChange,
fontSize,
onFontSizeChange,
fontFamily,
onFontFamilyChange,
textColor = "#000000",
onTextColorChange,
textAlign = "left",
onTextAlignChange,
disabled = false,
label,
placeholder,
fontLabel,
fontSizeLabel,
fontSizePlaceholder,
colorLabel,
onAnyChange,
}) => {
const { t } = useTranslation();
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
const fontSizeCombobox = useCombobox();
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [colorInput, setColorInput] = useState(textColor);
// Sync font size input with prop changes
useEffect(() => {
setFontSizeInput(fontSize.toString());
}, [fontSize]);
// Sync color input with prop changes
useEffect(() => {
setColorInput(textColor);
}, [textColor]);
const fontOptions = [
{ value: "Helvetica", label: "Helvetica" },
{ value: "Times-Roman", label: "Times" },
{ value: "Courier", label: "Courier" },
{ value: "Arial", label: "Arial" },
{ value: "Georgia", label: "Georgia" },
];
const fontSizeOptions = [
"8",
"12",
"16",
"20",
"24",
"28",
"32",
"36",
"40",
"48",
"56",
"64",
"72",
"80",
"96",
"112",
"128",
"144",
"160",
"176",
"192",
"200",
];
// Validate hex color
const isValidHexColor = (color: string): boolean => {
return /^#[0-9A-Fa-f]{6}$/.test(color);
};
return (
<Stack gap="sm">
<TextInput
label={label}
placeholder={placeholder}
value={text}
onChange={(e) => {
onTextChange(e.target.value);
onAnyChange?.();
}}
disabled={disabled}
required
/>
{/* Font Selection */}
<Select
label={fontLabel}
value={fontFamily}
onChange={(value) => {
onFontFamilyChange(value || "Helvetica");
onAnyChange?.();
}}
data={fontOptions}
disabled={disabled}
searchable
allowDeselect={false}
/>
{/* Font Size and Color */}
<Group grow>
<Combobox
onOptionSubmit={(optionValue) => {
setFontSizeInput(optionValue);
const size = parseInt(optionValue);
if (!isNaN(size)) {
onFontSizeChange(size);
}
fontSizeCombobox.closeDropdown();
}}
store={fontSizeCombobox}
withinPortal={false}
>
<Combobox.Target>
<TextInput
label={fontSizeLabel}
placeholder={fontSizePlaceholder}
value={fontSizeInput}
onChange={(event) => {
const value = event.currentTarget.value;
setFontSizeInput(value);
// Parse and validate the typed value in real-time
const size = parseInt(value);
if (!isNaN(size) && size >= 8 && size <= 200) {
onFontSizeChange(size);
onAnyChange?.();
}
fontSizeCombobox.openDropdown();
fontSizeCombobox.updateSelectedOptionIndex();
}}
onClick={() => fontSizeCombobox.openDropdown()}
onFocus={() => fontSizeCombobox.openDropdown()}
onBlur={() => {
fontSizeCombobox.closeDropdown();
// Clean up invalid values on blur
const size = parseInt(fontSizeInput);
if (isNaN(size) || size < 8 || size > 200) {
setFontSizeInput(fontSize.toString());
} else {
onFontSizeChange(size);
}
}}
disabled={disabled}
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
{fontSizeOptions.map((size) => (
<Combobox.Option value={size} key={size}>
{size}px
</Combobox.Option>
))}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
{/* Text Color Picker */}
{onTextColorChange && (
<Box>
<TextInput
label={colorLabel}
value={colorInput}
placeholder="#000000"
disabled={disabled}
onChange={(e) => {
const value = e.currentTarget.value;
setColorInput(value);
// Update color if valid hex
if (isValidHexColor(value)) {
onTextColorChange(value);
onAnyChange?.();
}
}}
onBlur={() => {
// Revert to valid color on blur if invalid
if (!isValidHexColor(colorInput)) {
setColorInput(textColor);
}
}}
style={{ width: "100%" }}
rightSection={
<Box
onClick={() => !disabled && setIsColorPickerOpen(true)}
style={{
width: 24,
height: 24,
backgroundColor: textColor,
border: "1px solid #ccc",
borderRadius: 4,
cursor: disabled ? "default" : "pointer",
}}
/>
}
/>
</Box>
)}
</Group>
{/* Color Picker Modal */}
{onTextColorChange && (
<ColorPicker
isOpen={isColorPickerOpen}
onClose={() => setIsColorPickerOpen(false)}
selectedColor={textColor}
onColorChange={(color) => {
onTextColorChange(color);
onAnyChange?.();
}}
/>
)}
{/* Text Alignment */}
{onTextAlignChange && (
<SegmentedControl
value={textAlign}
onChange={(value: string) => {
onTextAlignChange(value as "left" | "center" | "right");
onAnyChange?.();
}}
disabled={disabled}
data={[
{ label: t("textAlign.left", "Left"), value: "left" },
{ label: t("textAlign.center", "Center"), value: "center" },
{ label: t("textAlign.right", "Right"), value: "right" },
]}
/>
)}
</Stack>
);
};
@@ -0,0 +1,75 @@
import {
ActionIcon,
Tooltip,
Popover,
Stack,
Slider,
Text,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import LineWeightIcon from "@mui/icons-material/LineWeight";
interface WidthControlProps {
value: number;
onChange: (value: number) => void;
min: number; // 1 for ink, 0 for shapes
max: number; // 12 for ink, 20 for highlighter
disabled?: boolean;
}
export function WidthControl({
value,
onChange,
min,
max,
disabled = false,
}: WidthControlProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
return (
<Popover opened={opened} onChange={setOpened} position="top" withArrow>
<Popover.Target>
<Tooltip label={t("annotation.width", "Width")}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
onClick={() => setOpened(!opened)}
disabled={disabled}
styles={{
root: {
flexShrink: 0,
backgroundColor: "var(--bg-raised)",
border: "1px solid var(--border-default)",
color: "var(--text-secondary)",
"&:hover": {
backgroundColor: "var(--hover-bg)",
borderColor: "var(--border-strong)",
color: "var(--text-primary)",
},
},
}}
>
<LineWeightIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs" style={{ minWidth: 150 }}>
<Text size="xs" fw={500}>
{t("annotation.width", "Width")}
</Text>
<Slider
value={value}
onChange={onChange}
min={min}
max={max}
label={(val) => `${val}pt`}
/>
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,45 @@
import React, { useState } from "react";
import { Stack } from "@mantine/core";
import { BaseAnnotationTool } from "@app/components/annotation/shared/BaseAnnotationTool";
import { DrawingCanvas } from "@app/components/annotation/shared/DrawingCanvas";
interface DrawingToolProps {
onDrawingChange?: (data: string | null) => void;
disabled?: boolean;
}
export const DrawingTool: React.FC<DrawingToolProps> = ({
onDrawingChange,
disabled = false,
}) => {
const [selectedColor] = useState("#000000");
const [penSize, setPenSize] = useState(2);
const [penSizeInput, setPenSizeInput] = useState("2");
const toolConfig = {
enableDrawing: true,
showPlaceButton: true,
placeButtonText: "Place Drawing",
};
return (
<BaseAnnotationTool
config={toolConfig}
onSignatureDataChange={onDrawingChange}
disabled={disabled}
>
<Stack gap="sm">
<DrawingCanvas
selectedColor={selectedColor}
penSize={penSize}
penSizeInput={penSizeInput}
onColorSwatchClick={() => {}} // Color picker handled by BaseAnnotationTool
onPenSizeChange={setPenSize}
onPenSizeInputChange={setPenSizeInput}
onSignatureDataChange={onDrawingChange || (() => {})}
disabled={disabled}
/>
</Stack>
</BaseAnnotationTool>
);
};
@@ -0,0 +1,67 @@
import React, { useState } from "react";
import { Stack } from "@mantine/core";
import { BaseAnnotationTool } from "@app/components/annotation/shared/BaseAnnotationTool";
import { ImageUploader } from "@app/components/annotation/shared/ImageUploader";
interface ImageToolProps {
onImageChange?: (data: string | null) => void;
disabled?: boolean;
}
export const ImageTool: React.FC<ImageToolProps> = ({
onImageChange,
disabled = false,
}) => {
const [, setImageData] = useState<string | null>(null);
const handleImageUpload = async (file: File | null) => {
if (file && !disabled) {
try {
const result = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
if (e.target?.result) {
resolve(e.target.result as string);
} else {
reject(new Error("Failed to read file"));
}
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
setImageData(result);
onImageChange?.(result);
} catch (error) {
console.error("Error reading file:", error);
}
} else if (!file) {
setImageData(null);
onImageChange?.(null);
}
};
const toolConfig = {
enableImageUpload: true,
showPlaceButton: true,
placeButtonText: "Place Image",
};
return (
<BaseAnnotationTool
config={toolConfig}
onSignatureDataChange={onImageChange}
disabled={disabled}
>
<Stack gap="sm">
<ImageUploader
onImageChange={handleImageUpload}
disabled={disabled}
label="Upload Image"
placeholder="Select image file"
hint="Upload a PNG, JPG, SVG, or other image file to place on the PDF. SVG files will be converted to PNG for compatibility."
/>
</Stack>
</BaseAnnotationTool>
);
};
@@ -0,0 +1,187 @@
import React, { useRef, useState } from "react";
import { Button, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import LocalIcon from "@app/components/shared/LocalIcon";
import { Wordmark } from "@app/components/shared/Wordmark";
import styles from "@app/components/fileEditor/FileEditor.module.css";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
import { openFilesFromDisk } from "@app/services/openFilesFromDisk";
interface AddFileCardProps {
onFileSelect: (files: File[]) => void;
accept?: string;
multiple?: boolean;
}
const AddFileCard = ({
onFileSelect,
accept,
multiple = true,
}: AddFileCardProps) => {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const { openFilesModal } = useFilesModalContext();
const [isUploadHover, setIsUploadHover] = useState(false);
const terminology = useFileActionTerminology();
const icons = useFileActionIcons();
const handleCardClick = () => {
openFilesModal();
};
const handleNativeUploadClick = async (e: React.MouseEvent) => {
e.stopPropagation();
const files = await openFilesFromDisk({
multiple,
onFallbackOpen: () => fileInputRef.current?.click(),
});
if (files.length > 0) {
onFileSelect(files);
}
};
const handleOpenFilesModal = (e: React.MouseEvent) => {
e.stopPropagation();
openFilesModal();
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
if (files.length > 0) {
onFileSelect(files);
}
// Reset input so same files can be selected again
event.target.value = "";
};
return (
<>
<input
ref={fileInputRef}
type="file"
accept={accept}
multiple={multiple}
onChange={handleFileChange}
style={{ display: "none" }}
/>
<div
className={`${styles.addFileCard} select-none flex flex-col transition-all relative cursor-pointer`}
tabIndex={0}
role="button"
aria-label={t("fileEditor.addFiles", "Add files")}
onClick={handleCardClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleCardClick();
}
}}
>
{/* Main content area */}
<div className={styles.addFileContent}>
{/* Stirling PDF Branding */}
<Group gap="xs" align="center">
<Wordmark
alt="Stirling PDF"
muted
style={{ height: "2.2rem", width: "auto" }}
/>
</Group>
{/* Add Files + Native Upload Buttons - styled like LandingPage */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.6rem",
width: "100%",
marginTop: "0.8rem",
marginBottom: "0.8rem",
}}
onMouseLeave={() => setIsUploadHover(false)}
>
<Button
style={{
backgroundColor: "var(--landing-button-bg)",
color: "var(--landing-button-color)",
border: "1px solid var(--landing-button-border)",
borderRadius: "2rem",
height: "38px",
paddingLeft: isUploadHover ? 0 : "1rem",
paddingRight: isUploadHover ? 0 : "1rem",
width: isUploadHover ? "58px" : "calc(100% - 58px - 0.6rem)",
minWidth: isUploadHover ? "58px" : undefined,
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "width .5s ease, padding .5s ease",
}}
onClick={handleOpenFilesModal}
onMouseEnter={() => setIsUploadHover(false)}
>
<LocalIcon
icon="add"
width="1.5rem"
height="1.5rem"
className="text-[var(--accent-interactive)]"
/>
{!isUploadHover && (
<span>{t("landing.addFiles", "Add Files")}</span>
)}
</Button>
<Button
aria-label="Upload"
style={{
backgroundColor: "var(--landing-button-bg)",
color: "var(--landing-button-color)",
border: "1px solid var(--landing-button-border)",
borderRadius: "1rem",
height: "38px",
width: isUploadHover ? "calc(100% - 58px - 0.6rem)" : "58px",
minWidth: "58px",
paddingLeft: isUploadHover ? "1rem" : 0,
paddingRight: isUploadHover ? "1rem" : 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "width .5s ease, padding .5s ease",
}}
onClick={handleNativeUploadClick}
onMouseEnter={() => setIsUploadHover(true)}
>
<LocalIcon
icon={icons.uploadIconName}
width="1.25rem"
height="1.25rem"
style={{ color: "var(--accent-interactive)" }}
/>
{isUploadHover && (
<span style={{ marginLeft: ".5rem" }}>
{terminology.uploadFromComputer}
</span>
)}
</Button>
</div>
{/* Instruction Text */}
<span
className="text-[var(--accent-interactive)]"
style={{
fontSize: ".8rem",
textAlign: "center",
marginTop: "0.5rem",
}}
>
{terminology.dropFilesHere}
</span>
</div>
</div>
</>
);
};
export default AddFileCard;
@@ -0,0 +1,395 @@
/* =========================
FileEditor Card UI Styles
========================= */
.card {
background: var(--file-card-bg);
border-radius: 0.0625rem;
cursor: pointer;
transition:
box-shadow 0.18s ease,
outline-color 0.18s ease,
transform 0.18s ease;
max-width: 100%;
max-height: 100%;
overflow: visible;
margin-left: 0.5rem;
margin-right: 0.5rem;
}
.card:hover {
box-shadow: var(--shadow-md);
}
.card[data-selected="true"] {
box-shadow: var(--shadow-sm);
}
/* While dragging */
.card.dragging,
.card:global(.dragging) {
outline: 1px solid var(--border-strong);
box-shadow: var(--shadow-md);
transform: none !important;
}
/* -------- Header -------- */
.header {
height: 2.25rem;
border-radius: 0.0625rem 0.0625rem 0 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 6px;
user-select: none;
background: var(--bg-toolbar);
color: var(--text-primary);
border-bottom: 1px solid var(--border-default);
}
.headerResting {
background: #3b4b6e; /* dark blue for unselected in light mode */
color: #ffffff;
border-bottom: 1px solid var(--border-default);
}
.headerSelected {
background: var(--header-selected-bg);
color: var(--header-selected-fg);
border-bottom: 1px solid var(--header-selected-bg);
}
/* Error highlight (transient) */
.headerError {
background: var(--color-red-200);
color: var(--text-primary);
border-bottom: 2px solid var(--color-red-500);
}
/* Unsupported (but not errored) header appearance */
.headerUnsupported {
background: var(--unsupported-bar-bg); /* neutral gray */
color: #ffffff;
border-bottom: 1px solid var(--unsupported-bar-border);
}
/* Selected border color in light mode */
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
outline-color: var(--card-selected-border);
}
/* Reserve space for checkbox instead of logo */
.logoMark {
margin-left: 8px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
}
.headerIndex {
position: absolute;
left: 50%;
transform: translateX(-50%);
text-align: center;
font-weight: 500;
font-size: 18px;
letter-spacing: 0.04em;
}
.headerActions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.headerIconButton {
color: #ffffff !important;
}
/* Menu dropdown */
.menuDropdown {
min-width: 210px;
}
/* -------- Title / Meta -------- */
.title {
line-height: 1.2;
color: var(--text-primary);
}
.meta {
margin-top: 2px;
color: var(--text-secondary);
}
/* -------- Preview area -------- */
.previewBox {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: var(--file-card-bg);
}
.previewPaper {
width: 100%;
height: calc(100% - 6px);
min-height: 9rem;
justify-content: center;
display: grid;
position: relative;
overflow: hidden;
background: var(--file-card-bg);
}
/* Thumbnail fallback */
.previewPaper[data-thumb-missing="true"]::after {
content: "No preview";
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--text-secondary);
font-weight: 600;
font-size: 12px;
}
/* Drag handle grip */
.dragHandle {
position: absolute;
bottom: 6px;
right: 6px;
color: var(--text-secondary);
z-index: 1;
cursor: grab;
display: inline-flex;
}
/* Actions Overlay */
.actionsOverlay {
position: absolute;
left: 0;
top: 44px; /* just below header */
background: var(--bg-toolbar);
border-bottom: 1px solid var(--border-default);
z-index: 20;
overflow: hidden;
animation: slideDown 140ms ease-out;
color: var(--text-primary);
}
@keyframes slideDown {
from {
transform: translateY(-8px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.actionRow {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: transparent;
border: none;
color: var(--text-primary);
cursor: pointer;
text-align: left;
}
.actionRow:hover {
background: var(--hover-bg);
}
.actionDanger {
color: var(--text-brand-accent);
}
.actionsDivider {
height: 1px;
background: var(--border-default);
margin: 4px 0;
}
/* Pin indicator */
.pinIndicator {
position: absolute;
bottom: 4px;
left: 4px;
z-index: 1;
color: rgba(0, 0, 0, 0.35);
}
.pinned {
color: #ffc107 !important;
}
/* Unsupported file indicator */
.unsupportedPill {
margin-left: 1.75rem;
background: #6b7280;
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 10px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
min-width: 80px;
height: 20px;
}
/* Error pill shown when a file failed processing */
.errorPill {
margin-left: 1.75rem;
background: var(--color-red-500);
color: #ffffff;
padding: 4px 8px;
border-radius: 12px;
font-size: 10px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
min-width: 56px;
height: 20px;
}
/* Animations */
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.pulse {
animation: pulse 1s infinite;
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.card,
.menuDropdown {
transition: none !important;
}
}
/* =========================
DARK MODE OVERRIDES
========================= */
:global([data-mantine-color-scheme="dark"]) .card {
outline-color: #3a4047; /* deselected stroke */
}
:global([data-mantine-color-scheme="dark"]) .card[data-selected="true"] {
outline-color: #4b525a; /* selected stroke (subtle grey) */
}
:global([data-mantine-color-scheme="dark"]) .headerResting {
background: #1f2329; /* requested default unselected color */
color: var(--tool-header-text); /* #D0D6DC */
border-bottom-color: var(--tool-header-border); /* #3A4047 */
}
:global([data-mantine-color-scheme="dark"]) .headerSelected {
background: var(--tool-header-border); /* #3A4047 */
color: var(--tool-header-text); /* #D0D6DC */
border-bottom-color: var(--tool-header-border);
}
:global([data-mantine-color-scheme="dark"]) .title {
color: #d0d6dc; /* title text */
}
:global([data-mantine-color-scheme="dark"]) .meta {
color: #6b7280; /* subtitle text */
}
/* Light mode selected header stroke override */
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
outline-color: #3b4b6e;
}
/* =========================
Add File Card Styles
========================= */
.addFileCard {
width: 260px;
max-width: 260px;
height: calc(310px - 0.5rem);
margin: 0.5rem auto 0;
background: var(--file-card-bg);
border: 1.5px solid var(--border-default);
border-radius: 12px;
box-shadow: var(--shadow-md);
cursor: pointer;
transition:
box-shadow 0.18s ease,
transform 0.2s ease,
border-color 0.18s ease;
overflow: hidden;
}
.addFileCard:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-1px);
border-color: var(--accent-interactive);
}
.addFileCard:focus-visible {
outline: 2px solid var(--accent-interactive);
outline-offset: 2px;
}
.addFileContent {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem 1rem;
gap: 0.5rem;
}
.addFileIcon {
display: flex;
align-items: center;
justify-content: center;
width: 5rem;
height: 5rem;
border-radius: 50%;
background: var(--bg-subtle);
transition: background-color 0.18s ease;
}
.addFileCard:hover .addFileIcon {
background: var(--color-blue-50);
}
.addFileText {
font-weight: 500;
transition: color 0.18s ease;
}
.addFileCard:hover .addFileText {
color: var(--text-primary);
}
.addFileSubtext {
font-size: 0.875rem;
opacity: 0.8;
}
@@ -0,0 +1,433 @@
import { useState, useCallback, useMemo, useEffect } from "react";
import { flushSync } from "react-dom";
import { Center, Box, LoadingOverlay } from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import {
useFileSelection,
useFileState,
useFileManagement,
useFileActions,
} from "@app/contexts/FileContext";
import { useNavigationActions } from "@app/contexts/NavigationContext";
import { useViewer } from "@app/contexts/ViewerContext";
import { zipFileService } from "@app/services/zipFileService";
import { detectFileExtension } from "@app/utils/fileUtils";
import FileEditorThumbnail from "@app/components/fileEditor/FileEditorThumbnail";
import AddFileCard from "@app/components/fileEditor/AddFileCard";
import FilePickerModal from "@app/components/shared/FilePickerModal";
import { FileId, StirlingFile } from "@app/types/fileContext";
import { alert } from "@app/components/toast";
import { downloadFile } from "@app/services/downloadService";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
interface FileEditorProps {
onOpenPageEditor?: () => void;
onMergeFiles?: (files: StirlingFile[]) => void;
toolMode?: boolean;
supportedExtensions?: string[];
}
const FileEditor = ({
toolMode = false,
supportedExtensions = ["pdf"],
}: FileEditorProps) => {
// Utility function to check if a file extension is supported
const isFileSupported = useCallback(
(fileName: string): boolean => {
const extension = detectFileExtension(fileName);
return extension ? supportedExtensions.includes(extension) : false;
},
[supportedExtensions],
);
// Use optimized FileContext hooks
const { state, selectors } = useFileState();
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
const { actions: fileActions } = useFileActions();
const { selectedFileIds, setSelectedFiles } = useFileSelection();
// Extract needed values from state (memoized to prevent infinite loops)
const activeStirlingFileStubs = useMemo(
() => selectors.getStirlingFileStubs(),
[state.files.byId, state.files.ids],
);
// Get navigation actions
const { actions: navActions } = useNavigationActions();
// Get viewer context for setting active file index and ID
const { setActiveFileIndex, setActiveFileId } = useViewer();
const [_status, _setStatus] = useState<string | null>(null);
const [_error, _setError] = useState<string | null>(null);
// Toast helpers
const showStatus = useCallback(
(
message: string,
type: "neutral" | "success" | "warning" | "error" = "neutral",
) => {
alert({
alertType: type,
title: message,
expandable: false,
durationMs: 4000,
});
},
[],
);
const showError = useCallback((message: string) => {
alert({
alertType: "error",
title: "Error",
body: message,
expandable: true,
});
}, []);
// Current tool (for enforcing maxFiles limits)
const { selectedTool } = useToolWorkflow();
// Compute effective max allowed files based on the active tool and mode
const maxAllowed = useMemo<number>(() => {
const rawMax = selectedTool?.maxFiles;
return !toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax;
}, [selectedTool?.maxFiles, toolMode]);
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
// Process uploaded files using context
// ZIP extraction is now handled automatically in FileContext based on user preferences
const handleFileUpload = useCallback(
async (uploadedFiles: File[]) => {
_setError(null);
try {
if (uploadedFiles.length > 0) {
// FileContext will automatically handle ZIP extraction based on user preferences
// - Respects autoUnzip setting
// - Respects autoUnzipFileLimit
// - HTML ZIPs stay intact
// - Non-ZIP files pass through unchanged
await addFiles(uploadedFiles, { selectFiles: true });
// After auto-selection, enforce maxAllowed if needed
if (Number.isFinite(maxAllowed)) {
const nowSelectedIds = selectors
.getSelectedStirlingFileStubs()
.map((r) => r.id);
if (nowSelectedIds.length > maxAllowed) {
setSelectedFiles(nowSelectedIds.slice(-maxAllowed));
}
}
showStatus(`Added ${uploadedFiles.length} file(s)`, "success");
}
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : "Failed to process files";
showError(errorMessage);
console.error("File processing error:", err);
}
},
[addFiles, showStatus, showError, selectors, maxAllowed, setSelectedFiles],
);
// Enforce maxAllowed when tool changes or when an external action sets too many selected files
useEffect(() => {
if (Number.isFinite(maxAllowed) && selectedFileIds.length > maxAllowed) {
setSelectedFiles(selectedFileIds.slice(-maxAllowed));
}
}, [maxAllowed, selectedFileIds, setSelectedFiles]);
// File reordering handler for drag and drop
const handleReorderFiles = useCallback(
(sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => {
const currentIds = activeStirlingFileStubs.map((r) => r.id);
// Find indices
const sourceIndex = currentIds.findIndex((id) => id === sourceFileId);
const targetIndex = currentIds.findIndex((id) => id === targetFileId);
if (sourceIndex === -1 || targetIndex === -1) {
console.warn("Could not find source or target file for reordering");
return;
}
// Handle multi-file selection reordering
const filesToMove =
selectedFileIds.length > 1
? selectedFileIds.filter((id) => currentIds.includes(id))
: [sourceFileId];
// Create new order
const newOrder = [...currentIds];
// Remove files to move from their current positions (in reverse order to maintain indices)
const sourceIndices = filesToMove
.map((id) => newOrder.findIndex((nId) => nId === id))
.sort((a, b) => b - a); // Sort descending
sourceIndices.forEach((index) => {
newOrder.splice(index, 1);
});
// Calculate insertion index after removals
let insertIndex = newOrder.findIndex((id) => id === targetFileId);
if (insertIndex !== -1) {
// Determine if moving forward or backward
const isMovingForward = sourceIndex < targetIndex;
if (isMovingForward) {
// Moving forward: insert after target
insertIndex += 1;
} else {
// Moving backward: insert before target (insertIndex already correct)
}
} else {
// Target was moved, insert at end
insertIndex = newOrder.length;
}
// Insert files at the calculated position
newOrder.splice(insertIndex, 0, ...filesToMove);
// Animate the reorder using the View Transitions API where available.
// Each FileEditorThumbnail carries a stable `view-transition-name`, so
// the browser snapshots each card before and after the DOM reorder and
// interpolates the positions automatically. `flushSync` forces React to
// apply the reorderFiles dispatch synchronously inside the transition
// callback so the BEFORE/AFTER snapshots capture the correct frames.
const applyReorder = () => reorderFiles(newOrder);
const docWithViewTransition = document as Document & {
startViewTransition?: (cb: () => void) => unknown;
};
if (typeof docWithViewTransition.startViewTransition === "function") {
docWithViewTransition.startViewTransition(() => {
flushSync(applyReorder);
});
} else {
applyReorder();
}
// Update status
const moveCount = filesToMove.length;
showStatus(`${moveCount > 1 ? `${moveCount} files` : "File"} reordered`);
},
[activeStirlingFileStubs, reorderFiles, _setStatus],
);
// File operations using context
const handleCloseFile = useCallback(
(fileId: FileId) => {
const record = activeStirlingFileStubs.find((r) => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
if (record && file) {
// Remove file from context but keep in storage (close, don't delete)
const contextFileId = record.id;
removeFiles([contextFileId], false);
// Remove from context selections
const currentSelected = selectedFileIds.filter(
(id) => id !== contextFileId,
);
setSelectedFiles(currentSelected);
}
},
[
activeStirlingFileStubs,
selectors,
removeFiles,
setSelectedFiles,
selectedFileIds,
],
);
const handleDownloadFile = useCallback(
async (fileId: FileId) => {
const record = activeStirlingFileStubs.find((r) => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
console.log("[FileEditor] handleDownloadFile called:", {
fileId,
hasRecord: !!record,
hasFile: !!file,
localFilePath: record?.localFilePath,
isDirty: record?.isDirty,
});
if (record && file) {
const result = await downloadFile({
data: file,
filename: file.name,
localPath: record.localFilePath,
});
console.log("[FileEditor] Download complete, checking dirty state:", {
localFilePath: record.localFilePath,
isDirty: record.isDirty,
savedPath: result.savedPath,
});
// Mark file as clean after successful save to disk
if (result.savedPath) {
console.log("[FileEditor] Marking file as clean:", fileId);
fileActions.updateStirlingFileStub(fileId, {
localFilePath: record.localFilePath ?? result.savedPath,
isDirty: false,
});
} else {
console.log("[FileEditor] Skipping clean mark:", {
savedPath: result.savedPath,
isDirty: record.isDirty,
});
}
}
},
[activeStirlingFileStubs, selectors, fileActions],
);
const handleUnzipFile = useCallback(
async (fileId: FileId) => {
const record = activeStirlingFileStubs.find((r) => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
if (record && file) {
try {
// Extract and store files using shared service method
const result = await zipFileService.extractAndStoreFilesWithHistory(
file,
record,
);
if (result.success && result.extractedStubs.length > 0) {
// Add extracted file stubs to FileContext
await fileActions.addStirlingFileStubs(result.extractedStubs);
// Remove the original ZIP file
removeFiles([fileId], false);
alert({
alertType: "success",
title: `Extracted ${result.extractedStubs.length} file(s) from ${file.name}`,
expandable: false,
durationMs: 3500,
});
} else {
alert({
alertType: "error",
title: `Failed to extract files from ${file.name}`,
body: result.errors.join("\n"),
expandable: true,
durationMs: 3500,
});
}
} catch (error) {
console.error("Failed to unzip file:", error);
alert({
alertType: "error",
title: `Error unzipping ${file.name}`,
expandable: false,
durationMs: 3500,
});
}
}
},
[activeStirlingFileStubs, selectors, fileActions, removeFiles],
);
const handleViewFile = useCallback(
(fileId: FileId) => {
const index = activeStirlingFileStubs.findIndex((r) => r.id === fileId);
if (index !== -1) {
setActiveFileId(fileId as string);
setActiveFileIndex(index);
navActions.setWorkbench("viewer");
}
},
[
activeStirlingFileStubs,
setActiveFileId,
setActiveFileIndex,
navActions.setWorkbench,
],
);
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
if (selectedFiles.length === 0) return;
try {
// Use FileContext to handle loading stored files
// The files are already in FileContext, just need to add them to active files
showStatus(`Loaded ${selectedFiles.length} files from storage`);
} catch (err) {
console.error("Error loading files from storage:", err);
showError("Failed to load some files from storage");
}
}, []);
return (
<Dropzone
onDrop={handleFileUpload}
multiple={true}
maxSize={2 * 1024 * 1024 * 1024}
style={{
border: "none",
borderRadius: 0,
backgroundColor: "transparent",
}}
activateOnClick={false}
activateOnDrag={true}
>
<Box pos="relative" style={{ overflow: "auto" }}>
<LoadingOverlay visible={state.ui.isProcessing} />
<Box p="md">
{activeStirlingFileStubs.length === 0 ? (
<Center h="60vh">
<AddFileCard onFileSelect={handleFileUpload} />
</Center>
) : (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))",
rowGap: "1.5rem",
padding: "1rem",
pointerEvents: "auto",
}}
>
{/* Add File Card - only show when files exist */}
{activeStirlingFileStubs.length > 0 && (
<AddFileCard
key="add-file-card"
onFileSelect={handleFileUpload}
/>
)}
{activeStirlingFileStubs.map((record, index) => {
return (
<FileEditorThumbnail
key={record.id}
file={record}
index={index}
totalFiles={activeStirlingFileStubs.length}
onCloseFile={handleCloseFile}
onViewFile={handleViewFile}
onReorderFiles={handleReorderFiles}
onDownloadFile={handleDownloadFile}
onUnzipFile={handleUnzipFile}
toolMode={toolMode}
isSupported={isFileSupported(record.name)}
/>
);
})}
</div>
)}
</Box>
{/* File Picker Modal */}
<FilePickerModal
opened={showFilePickerModal}
onClose={() => setShowFilePickerModal(false)}
storedFiles={[]} // FileEditor doesn't have access to stored files, needs to be passed from parent
onSelectFiles={handleLoadFromStorage}
/>
</Box>
</Dropzone>
);
};
export default FileEditor;
@@ -0,0 +1,53 @@
import React from "react";
import { describe, expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import FileEditorFileName from "@app/components/fileEditor/FileEditorFileName";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
const buildFileStub = (
overrides: Partial<StirlingFileStub> = {},
): StirlingFileStub => ({
id: "file-1" as FileId,
name: "report.pdf",
type: "application/pdf",
size: 1024,
lastModified: 0,
isLeaf: true,
originalFileId: "file-1",
versionNumber: 1,
...overrides,
});
const renderName = (file: StirlingFileStub) =>
render(
<MantineProvider>
<FileEditorFileName file={file} />
</MantineProvider>,
);
describe("FileEditorFileName (core / web)", () => {
test.each([
{ name: "not-saved", file: buildFileStub() },
{
name: "dirty",
file: buildFileStub({ localFilePath: "/tmp/report.pdf", isDirty: true }),
},
{
name: "saved",
file: buildFileStub({ localFilePath: "/tmp/report.pdf", isDirty: false }),
},
])("does not render a save indicator ($name)", ({ file }) => {
renderName(file);
expect(screen.queryByLabelText("fileNotSavedToDisk")).toBeNull();
expect(screen.queryByLabelText("unsavedChanges")).toBeNull();
expect(screen.queryByLabelText("fileSavedToDisk")).toBeNull();
});
test("renders the filename", () => {
renderName(buildFileStub({ name: "invoice.pdf" }));
expect(screen.getByText("invoice.pdf")).toBeInTheDocument();
});
});
@@ -0,0 +1,18 @@
import React from "react";
import { StirlingFileStub } from "@app/types/fileContext";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import { truncateCenter } from "@app/utils/textUtils";
interface FileEditorFileNameProps {
file: StirlingFileStub;
maxLength?: number;
}
const FileEditorFileName = ({
file,
maxLength = 40,
}: FileEditorFileNameProps) => (
<PrivateContent>{truncateCenter(file.name, maxLength)}</PrivateContent>
);
export default FileEditorFileName;
@@ -0,0 +1,9 @@
import { StirlingFileStub } from "@app/types/fileContext";
interface FileEditorStatusDotProps {
file: StirlingFileStub;
}
export function FileEditorStatusDot(_props: FileEditorStatusDotProps) {
return null;
}
@@ -0,0 +1,266 @@
.card {
width: 260px;
max-width: 260px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
padding: 10px 10px 12px;
position: relative;
--file-text-height: 58px;
--file-text-bottom: 12px;
--thumb-text-gap: 6px;
padding-bottom: calc(
var(--file-text-bottom) + var(--file-text-height) + var(--thumb-text-gap)
);
user-select: none;
cursor: pointer;
}
/* Inner wrapper — centers [toolChain + thumbnail + hover menu] as a unit */
.thumbInner {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
width: 100%;
max-width: 100%;
}
/* Groups toolchain bar + thumbnail so they center together inside thumbWrap */
.thumbUnit {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-height: 100%;
}
/* ToolChain bar sits directly above the thumbnail card — always rendered for consistent sizing */
.toolChainBar {
flex-shrink: 0;
width: 100%;
height: 22px;
text-align: center;
overflow: hidden;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
line-height: 22px;
margin-bottom: 4px;
}
.card:hover .toolChainBar,
.card:focus-within .toolChainBar {
opacity: 1;
}
.thumbWrap {
width: 100%;
position: relative;
height: 310px;
display: flex;
align-items: center;
justify-content: center;
}
.thumbContainer {
aspect-ratio: var(--thumb-aspect, 8.5 / 11);
width: 100%;
max-width: 100%;
max-height: 100%;
border-radius: 12px;
box-shadow: var(--shadow-md);
overflow: hidden;
position: relative;
background: var(--bg-surface);
transition:
box-shadow 0.15s ease,
transform 0.2s ease;
}
.thumbContainer:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-lg);
}
.thumbImage {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 12px;
background: var(--bg-surface);
display: block;
position: relative;
z-index: 1;
}
/* Text block pinned below thumbnail */
.fileText {
position: absolute;
left: 10px;
right: 10px;
bottom: var(--file-text-bottom);
height: var(--file-text-height);
text-align: center;
pointer-events: none;
--file-name-line-height: 18px;
--file-name-lines: 2;
--file-meta-line-height: 14px;
--file-meta-gap: 4px;
}
.fileName {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: var(--file-name-line-height);
height: calc(var(--file-name-lines) * var(--file-name-line-height));
margin: 0;
position: absolute;
left: 0;
right: 0;
bottom: calc(var(--file-meta-line-height) + var(--file-meta-gap));
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
overflow-wrap: break-word;
word-break: normal;
}
.fileMeta {
font-size: 11px;
color: var(--text-secondary);
line-height: var(--file-meta-line-height);
height: var(--file-meta-line-height);
overflow: hidden;
margin: 0;
position: absolute;
left: 0;
right: 0;
bottom: 0;
white-space: nowrap;
text-overflow: ellipsis;
}
/* CSS-hover driven menu visibility */
.card [data-hover-action-menu-mode="cssHover"][data-force-visible="false"] {
opacity: 0;
pointer-events: none;
}
.card:hover
[data-hover-action-menu-mode="cssHover"][data-force-visible="false"],
.card:focus-within
[data-hover-action-menu-mode="cssHover"][data-force-visible="false"] {
opacity: 1;
pointer-events: auto;
}
/* Error overlay */
.errorOverlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(220, 38, 38, 0.12);
border-radius: 12px;
z-index: 2;
pointer-events: none;
}
.errorPill {
background: var(--mantine-color-red-6);
color: white;
border-radius: 12px;
padding: 4px 12px;
font-size: 12px;
font-weight: 600;
}
/* Badges row pinned to top-left of thumbnail - always visible */
.thumbBadges {
position: absolute;
top: 6px;
left: 6px;
display: flex;
gap: 4px;
align-items: center;
z-index: 3;
pointer-events: none;
}
.thumbBadgesRight {
position: absolute;
top: 6px;
right: 6px;
display: flex;
gap: 4px;
align-items: center;
z-index: 3;
pointer-events: auto;
}
.statusDot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
box-shadow: 0 0 0 1.5px rgba(0, 0, 0, 0.15);
}
.ownershipBadge {
background: var(--mantine-color-blue-6);
color: white;
border-radius: 6px;
padding: 2px 7px;
font-size: 10px;
font-weight: 600;
white-space: nowrap;
}
.versionBadgeThumb {
background: var(--mantine-color-blue-6);
border-radius: 4px;
padding: 1px 5px;
font-size: 10px;
font-weight: 700;
color: white;
line-height: 16px;
white-space: nowrap;
}
.pinnedBadge {
background: var(--mantine-color-yellow-6);
color: white;
border-radius: 4px;
padding: 2px 4px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.dragHandle {
position: absolute;
bottom: 6px;
right: 6px;
cursor: grab;
color: var(--text-secondary);
opacity: 0;
transition: opacity 0.15s ease;
pointer-events: none;
}
.card:hover .dragHandle,
.card:focus-within .dragHandle {
opacity: 0.5;
pointer-events: auto;
}
@@ -0,0 +1,677 @@
import React, { useState, useCallback, useRef, useMemo } from "react";
import {
Text,
Modal,
Button,
Group,
Stack,
ActionIcon,
Tooltip,
} from "@mantine/core";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { alert } from "@app/components/toast";
import { useTranslation } from "react-i18next";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
import CloseIcon from "@mui/icons-material/Close";
import VisibilityIcon from "@mui/icons-material/Visibility";
import UnarchiveIcon from "@mui/icons-material/Unarchive";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import LinkIcon from "@mui/icons-material/Link";
import PushPinIcon from "@mui/icons-material/PushPin";
import LockOpenIcon from "@mui/icons-material/LockOpen";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { StirlingFileStub } from "@app/types/fileContext";
import { zipFileService } from "@app/services/zipFileService";
import styles from "@app/components/fileEditor/FileEditorThumbnail.module.css";
import { useFileContext } from "@app/contexts/FileContext";
import { useFileState } from "@app/contexts/file/fileHooks";
import { FileId } from "@app/types/file";
import ToolChain from "@app/components/shared/ToolChain";
import HoverActionMenu, {
HoverAction,
} from "@app/components/shared/HoverActionMenu";
import { downloadFile } from "@app/services/downloadService";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import UploadToServerModal from "@app/components/shared/UploadToServerModal";
import ShareFileModal from "@app/components/shared/ShareFileModal";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useFileThumbnail } from "@app/hooks/useFileThumbnail";
import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail";
import { truncateCenter } from "@app/utils/textUtils";
import { FileEditorStatusDot } from "@app/components/fileEditor/FileEditorStatusDot";
interface FileEditorThumbnailProps {
file: StirlingFileStub;
index: number;
totalFiles: number;
onCloseFile: (fileId: FileId) => void;
onViewFile: (fileId: FileId) => void;
onReorderFiles?: (
sourceFileId: FileId,
targetFileId: FileId,
selectedFileIds: FileId[],
) => void;
onDownloadFile: (fileId: FileId) => void;
onUnzipFile?: (fileId: FileId) => void;
toolMode?: boolean;
isSupported?: boolean;
}
const FileEditorThumbnail = ({
file,
onCloseFile,
onViewFile,
onReorderFiles,
onDownloadFile,
onUnzipFile,
isSupported = true,
}: FileEditorThumbnailProps) => {
const { t } = useTranslation();
const { config } = useAppConfig();
const terminology = useFileActionTerminology();
const icons = useFileActionIcons();
const DownloadOutlinedIcon = icons.download;
const {
pinFile,
unpinFile,
isFilePinned,
activeFiles,
actions: fileActions,
openEncryptedUnlockPrompt,
} = useFileContext();
const { state, selectors } = useFileState();
const isMobile = useIsMobile();
const actualFile = useMemo(
() => activeFiles.find((f) => f.fileId === file.id),
[activeFiles, file.id],
);
const isPinned = actualFile ? isFilePinned(actualFile) : false;
const isZipFile = zipFileService.isZipFileStub(file);
const hasError = state.ui.errorFileIds.includes(file.id);
const pageCount = file.processedFile?.totalPages || 0;
const {
isEncrypted,
thumbnail: displayThumbnail,
isGenerating: isThumbGenerating,
} = useFileThumbnail(file);
// Aspect ratio from page dimensions, falling back to letter size
const firstPage = file.processedFile?.pages?.[0];
const firstPageRotation = firstPage?.rotation ?? 0;
const isLandscape = firstPageRotation === 90 || firstPageRotation === 270;
const thumbAspect = (() => {
const w = firstPage?.width;
const h = firstPage?.height;
if (w && h && w > 0 && h > 0) {
// width/height are effective (post-rotation) dims from PDFium, so use
// them directly — no swapping needed.
return `${w} / ${h}`;
}
return isLandscape ? "11 / 8.5" : "8.5 / 11";
})();
const handleRef = useRef<HTMLSpanElement | null>(null);
const dragElementRef = useRef<HTMLDivElement | null>(null);
const extUpper = useMemo(() => {
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? "");
return (m?.[1] || "").toUpperCase();
}, [file.name]);
const extLower = useMemo(() => {
const m = /\.([a-z0-9]+)$/i.exec(file.name ?? "");
return (m?.[1] || "").toLowerCase();
}, [file.name]);
const isCBZ = extLower === "cbz";
const isCBR = extLower === "cbr";
const uploadEnabled = config?.storageEnabled === true;
const sharingEnabled =
uploadEnabled && config?.storageSharingEnabled === true;
const shareLinksEnabled =
sharingEnabled && config?.storageShareLinksEnabled === true;
const isOwnedOrLocal = file.remoteOwnedByCurrentUser !== false;
const isSharedFile =
file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink;
const localUpdatedAt = file.createdAt ?? file.lastModified ?? 0;
const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0;
const isUploaded = Boolean(file.remoteStorageId);
const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt;
const canUpload =
uploadEnabled &&
isOwnedOrLocal &&
file.isLeaf &&
(!isUploaded || !isUpToDate);
const canShare = shareLinksEnabled && isOwnedOrLocal && file.isLeaf;
const pageLabel = useMemo(
() =>
pageCount > 0 ? `${pageCount} ${pageCount === 1 ? "Page" : "Pages"}` : "",
[pageCount],
);
const dateLabel = useMemo(() => {
const d = new Date(file.lastModified);
if (Number.isNaN(d.getTime())) return "";
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "2-digit",
year: "numeric",
}).format(d);
}, [file.lastModified]);
const [isDragging, setIsDragging] = useState(false);
const [showCloseModal, setShowCloseModal] = useState(false);
const [showUploadModal, setShowUploadModal] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const [showSharedEditNotice, setShowSharedEditNotice] = useState(false);
const sharedEditNoticeShownRef = useRef(false);
const fileElementRef = useCallback(
(element: HTMLDivElement | null) => {
if (!element) return;
dragElementRef.current = element;
const dragCleanup = draggable({
element,
getInitialData: () => ({
type: "file",
fileId: file.id,
fileName: file.name,
selectedFiles: [file.id],
}),
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
});
const dropCleanup = dropTargetForElements({
element,
getData: () => ({
type: "file",
fileId: file.id,
}),
canDrop: ({ source }) => {
const sourceData = source.data;
return sourceData.type === "file" && sourceData.fileId !== file.id;
},
onDrop: ({ source }) => {
const sourceData = source.data;
if (sourceData.type === "file" && onReorderFiles) {
const sourceFileId = sourceData.fileId as FileId;
const selectedFileIds = sourceData.selectedFiles as FileId[];
onReorderFiles(sourceFileId, file.id, selectedFileIds);
}
},
});
return () => {
dragCleanup();
dropCleanup();
};
},
[file.id, file.name, onReorderFiles],
);
const handleCloseWithConfirmation = useCallback(
() => setShowCloseModal(true),
[],
);
const handleCancelClose = useCallback(() => setShowCloseModal(false), []);
const handleConfirmClose = useCallback(() => {
onCloseFile(file.id);
alert({
alertType: "neutral",
title: `Closed ${file.name}`,
expandable: false,
durationMs: 3500,
});
setShowCloseModal(false);
}, [file.id, file.name, onCloseFile]);
const handleSaveAndClose = useCallback(async () => {
const fileToSave = selectors.getFile(file.id);
if (fileToSave) {
try {
const result = await downloadFile({
data: fileToSave,
filename: file.name,
localPath: file.localFilePath,
});
if (!result.cancelled && result.savedPath) {
fileActions.updateStirlingFileStub(file.id, {
localFilePath: file.localFilePath ?? result.savedPath,
isDirty: false,
});
} else if (result.cancelled) {
setShowCloseModal(false);
return;
}
} catch (error) {
console.error(`Failed to save ${file.name}:`, error);
alert({
alertType: "error",
title: "Save failed",
body: `Could not save ${file.name}`,
expandable: true,
});
setShowCloseModal(false);
return;
}
}
onCloseFile(file.id);
alert({
alertType: "success",
title: `Saved and closed ${file.name}`,
expandable: false,
durationMs: 3500,
});
setShowCloseModal(false);
}, [
file.id,
file.name,
file.localFilePath,
onCloseFile,
selectors,
fileActions,
]);
const hoverActions = useMemo<HoverAction[]>(
() => [
{
id: "view",
icon: <VisibilityIcon style={{ fontSize: 20 }} />,
label: t("openInViewer", "Open in Viewer"),
onClick: (e) => {
e.stopPropagation();
onViewFile(file.id);
},
},
{
id: "pin",
icon: <PushPinIcon style={{ fontSize: 20 }} />,
label: isPinned
? t("unpin", "Unpin File (replace after tool run)")
: t("pin", "Pin File (keep active after tool run)"),
dataTour: "file-card-pin",
onClick: (e) => {
e.stopPropagation();
if (actualFile) {
if (isPinned) {
unpinFile(actualFile);
alert({
alertType: "neutral",
title: `Unpinned ${file.name}`,
expandable: false,
durationMs: 3000,
});
} else {
pinFile(actualFile);
alert({
alertType: "success",
title: `Pinned ${file.name}`,
expandable: false,
durationMs: 3000,
});
}
}
},
},
{
id: "download",
icon: <DownloadOutlinedIcon style={{ fontSize: 20 }} />,
label: terminology.download,
onClick: (e) => {
e.stopPropagation();
onDownloadFile(file.id);
},
},
...(canUpload
? [
{
id: "upload",
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
label: isUploaded
? t("fileManager.updateOnServer", "Update on Server")
: t("fileManager.uploadToServer", "Upload to Server"),
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setShowUploadModal(true);
},
},
]
: []),
...(canShare
? [
{
id: "share",
icon: <LinkIcon style={{ fontSize: 20 }} />,
label: t("fileManager.share", "Share"),
onClick: (e: React.MouseEvent) => {
e.stopPropagation();
setShowShareModal(true);
},
},
]
: []),
{
id: "unzip",
icon: <UnarchiveIcon style={{ fontSize: 20 }} />,
label: t("fileManager.unzip", "Unzip"),
onClick: (e) => {
e.stopPropagation();
if (onUnzipFile) {
onUnzipFile(file.id);
alert({
alertType: "success",
title: `Unzipping ${file.name}`,
expandable: false,
durationMs: 2500,
});
}
},
hidden: !isZipFile || !onUnzipFile || isCBZ || isCBR,
},
{
id: "close",
icon: <CloseIcon style={{ fontSize: 20 }} />,
label: t("close", "Close"),
onClick: (e) => {
e.stopPropagation();
handleCloseWithConfirmation();
},
color: "red",
},
],
[
t,
file.id,
file.name,
isZipFile,
isCBZ,
isCBR,
isPinned,
actualFile,
terminology,
DownloadOutlinedIcon,
onViewFile,
onDownloadFile,
onUnzipFile,
handleCloseWithConfirmation,
canUpload,
canShare,
isUploaded,
pinFile,
unpinFile,
],
);
const handleCardClick = () => {
if (!isSupported) return;
if (hasError) {
try {
fileActions.clearFileError(file.id);
} catch (_e) {
void _e;
}
}
if (isSharedFile && !sharedEditNoticeShownRef.current) {
sharedEditNoticeShownRef.current = true;
setShowSharedEditNotice(true);
}
};
const handleCardDoubleClick = () => {
if (!isSupported) return;
onViewFile(file.id);
};
const metaLine = [dateLabel, extUpper ? `${extUpper} file` : "", pageLabel]
.filter(Boolean)
.join(" - ");
return (
<div
ref={fileElementRef}
data-file-id={file.id}
data-testid="file-thumbnail"
data-tour="file-card-checkbox"
data-supported={isSupported}
className={`${styles.card} select-none`}
style={{ opacity: isDragging ? 0.9 : 1 }}
tabIndex={0}
role="listitem"
onClick={handleCardClick}
onDoubleClick={handleCardDoubleClick}
>
<div className={styles.thumbInner}>
{/* Thumbnail area */}
<div className={styles.thumbWrap}>
{/* thumbUnit groups toolchain + card so they center together, keeping text tight above the card */}
<div className={styles.thumbUnit}>
{/* Tool chain bar — always rendered for consistent height, content only when history exists */}
<div className={styles.toolChainBar}>
{file.toolHistory && file.toolHistory.length > 0 && (
<ToolChain
toolChain={file.toolHistory}
displayStyle="text"
size="xs"
maxWidth="100%"
color="var(--mantine-color-gray-7)"
/>
)}
</div>
<div
className={styles.thumbContainer}
data-supported={isSupported}
style={{ "--thumb-aspect": thumbAspect } as React.CSSProperties}
>
{/* Error overlay */}
{hasError && (
<div className={styles.errorOverlay}>
<span className={styles.errorPill}>
{t("error._value", "Error")}
</span>
</div>
)}
{/* Thumbnail image or loading state */}
<DocumentThumbnail
file={file}
thumbnail={displayThumbnail || undefined}
isEncrypted={isEncrypted}
isLoading={
!isEncrypted &&
!displayThumbnail &&
(isThumbGenerating ||
file.type?.startsWith("application/pdf") ||
file.type?.startsWith("image/"))
}
iconSize="6rem"
imgClassName={styles.thumbImage}
onImageError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
{/* Badges — top-left: version, pin, ownership, encrypted */}
<div className={styles.thumbBadges}>
<span className={styles.versionBadgeThumb}>
v{file.versionNumber}
</span>
{isPinned && (
<span className={styles.pinnedBadge}>
<PushPinIcon style={{ fontSize: 12 }} />
</span>
)}
{isSharedFile && !isOwnedOrLocal && (
<span className={styles.ownershipBadge}>
{t("fileManager.sharedWithYou", "Shared")}
</span>
)}
{isEncrypted && (
<Tooltip
label={t(
"encryptedPdfUnlock.unlockPrompt",
"Unlock PDF to continue",
)}
>
<ActionIcon
size="xs"
variant="filled"
color="yellow"
onClick={(e) => {
e.stopPropagation();
openEncryptedUnlockPrompt(file.id);
}}
style={{ pointerEvents: "auto" }}
>
<LockOpenIcon style={{ fontSize: 12 }} />
</ActionIcon>
</Tooltip>
)}
</div>
<FileEditorStatusDot file={file} />
</div>
</div>
{/* end thumbUnit */}
{/* Drag handle */}
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
<DragIndicatorIcon fontSize="small" />
</span>
</div>
{/* Hover action menu — visibility driven by CSS on desktop, always shown on mobile */}
<HoverActionMenu
show={isMobile}
actions={hoverActions}
position="outside"
visibility="cssHover"
/>
</div>
{/* File name + meta */}
<div className={styles.fileText}>
<p className={styles.fileName}>
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
</p>
<p className={styles.fileMeta}>{metaLine}</p>
</div>
{/* Close Confirmation Modal */}
<Modal
opened={showCloseModal}
onClose={handleCancelClose}
title={t("confirmClose", "Confirm Close")}
centered
size="auto"
>
<Stack gap="md">
{file.isDirty && file.localFilePath ? (
<>
<Text size="md">
{t("confirmCloseUnsaved", "This file has unsaved changes.")}
</Text>
<Text size="sm" c="dimmed" fw={500}>
<PrivateContent>{file.name}</PrivateContent>
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" onClick={handleCancelClose}>
{t("confirmCloseCancel", "Cancel")}
</Button>
<Button
variant="filled"
color="red"
onClick={handleConfirmClose}
>
{t("confirmCloseDiscard", "Discard changes and close")}
</Button>
<Button variant="filled" onClick={handleSaveAndClose}>
{t("confirmCloseSave", "Save and close")}
</Button>
</Group>
</>
) : (
<>
<Text size="md">
{t(
"confirmCloseMessage",
"Are you sure you want to close this file?",
)}
</Text>
<Text size="sm" c="dimmed" fw={500}>
<PrivateContent>{file.name}</PrivateContent>
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" onClick={handleCancelClose}>
{t("confirmCloseCancel", "Cancel")}
</Button>
<Button
variant="filled"
color="red"
onClick={handleConfirmClose}
>
{t("confirmCloseConfirm", "Close File")}
</Button>
</Group>
</>
)}
</Stack>
</Modal>
{/* Shared edit notice modal */}
<Modal
opened={showSharedEditNotice}
onClose={() => setShowSharedEditNotice(false)}
title={t("fileManager.sharedEditNoticeTitle", "Read-only server copy")}
centered
size="auto"
>
<Stack gap="md">
<Text size="sm">
{t(
"fileManager.sharedEditNoticeBody",
"You do not have edit rights to the server version of this file. Any edits you make will be saved as a local copy.",
)}
</Text>
<Group justify="flex-end" gap="sm">
<Button onClick={() => setShowSharedEditNotice(false)}>
{t("fileManager.sharedEditNoticeConfirm", "Got it")}
</Button>
</Group>
</Stack>
</Modal>
{canUpload && (
<UploadToServerModal
opened={showUploadModal}
onClose={() => setShowUploadModal(false)}
file={file}
/>
)}
{canShare && (
<ShareFileModal
opened={showShareModal}
onClose={() => setShowShareModal(false)}
file={file}
/>
)}
</div>
);
};
export default React.memo(FileEditorThumbnail);
@@ -0,0 +1,174 @@
import React from "react";
import { Stack, Box, Text, Button, ActionIcon, Center } from "@mantine/core";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import { useTranslation } from "react-i18next";
import { getFileSize } from "@app/utils/fileUtils";
import { StirlingFileStub } from "@app/types/fileContext";
import { PrivateContent } from "@app/components/shared/PrivateContent";
interface CompactFileDetailsProps {
currentFile: StirlingFileStub | null;
thumbnail: string | null;
selectedFiles: StirlingFileStub[];
currentFileIndex: number;
numberOfFiles: number;
isAnimating: boolean;
onPrevious: () => void;
onNext: () => void;
onOpenFiles: () => void;
canCloseAll?: boolean;
}
const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
currentFile,
thumbnail,
selectedFiles,
currentFileIndex,
numberOfFiles,
isAnimating,
onPrevious,
onNext,
onOpenFiles,
canCloseAll = false,
}) => {
const { t } = useTranslation();
const hasSelection = selectedFiles.length > 0;
const hasMultipleFiles = numberOfFiles > 1;
const showOwner = Boolean(
currentFile &&
(currentFile.remoteOwnedByCurrentUser === false ||
currentFile.remoteSharedViaLink),
);
const ownerLabel = currentFile
? currentFile.remoteOwnerUsername ||
t("fileManager.ownerUnknown", "Unknown")
: "";
return (
<Stack gap="xs" style={{ height: "100%" }}>
{/* Compact mobile layout */}
<Box style={{ display: "flex", gap: "0.75rem", alignItems: "center" }}>
{/* Small preview */}
<Box
style={{
width: "7.5rem",
height: "9.375rem",
flexShrink: 0,
position: "relative",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{currentFile && thumbnail ? (
<PrivateContent>
<img
src={thumbnail}
alt={currentFile.name}
style={{
maxWidth: "100%",
maxHeight: "100%",
objectFit: "contain",
borderRadius: "0.25rem",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.1)",
}}
/>
</PrivateContent>
) : currentFile ? (
<Center
style={{
width: "100%",
height: "100%",
backgroundColor: "var(--mantine-color-gray-1)",
borderRadius: 4,
}}
>
<PictureAsPdfIcon
style={{ fontSize: 20, color: "var(--mantine-color-gray-6)" }}
/>
</Center>
) : null}
</Box>
{/* File info */}
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
<PrivateContent>
{currentFile ? currentFile.name : "No file loaded"}
</PrivateContent>
</Text>
<Text size="xs" c="dimmed">
{currentFile ? getFileSize(currentFile) : ""}
{selectedFiles.length > 1 && `${selectedFiles.length} files`}
{currentFile && ` • v${currentFile.versionNumber || 1}`}
</Text>
{hasMultipleFiles && (
<Text size="xs" c="blue">
{currentFileIndex + 1} of {selectedFiles.length}
</Text>
)}
{/* Compact tool chain for mobile */}
{currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
<Text size="xs" c="dimmed">
{currentFile.toolHistory
.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId))
.join(" → ")}
</Text>
)}
{currentFile && showOwner && (
<Text size="xs" c="dimmed">
{t("fileManager.owner", "Owner")}: {ownerLabel}
</Text>
)}
</Box>
{/* Navigation arrows for multiple files */}
{hasMultipleFiles && (
<Box style={{ display: "flex", gap: "0.25rem" }}>
<ActionIcon
variant="subtle"
size="sm"
onClick={onPrevious}
disabled={isAnimating}
>
<ChevronLeftIcon style={{ fontSize: 16 }} />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={onNext}
disabled={isAnimating}
>
<ChevronRightIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Box>
)}
</Box>
{/* Action Button */}
<Button
size="sm"
onClick={onOpenFiles}
disabled={!hasSelection && !canCloseAll}
fullWidth
style={{
backgroundColor:
hasSelection || canCloseAll
? "var(--btn-open-file)"
: "var(--mantine-color-gray-4)",
color: "white",
}}
>
{canCloseAll
? t("fileManager.closeAllFiles", "Close all files")
: selectedFiles.length > 1
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
: t("fileManager.openFile", "Open File")}
</Button>
</Stack>
);
};
export default CompactFileDetails;
@@ -0,0 +1,124 @@
import React from "react";
import { Grid } from "@mantine/core";
import FileSourceButtons from "@app/components/fileManager/FileSourceButtons";
import FileDetails from "@app/components/fileManager/FileDetails";
import SearchInput from "@app/components/fileManager/SearchInput";
import FileListArea from "@app/components/fileManager/FileListArea";
import FileActions from "@app/components/fileManager/FileActions";
import HiddenFileInput from "@app/components/fileManager/HiddenFileInput";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
const DesktopLayout: React.FC = () => {
const { activeSource, recentFiles, modalHeight } = useFileManagerContext();
return (
<Grid
gutter="xs"
h="100%"
grow={false}
style={{ flexWrap: "nowrap", minWidth: 0 }}
>
{/* Column 1: File Sources */}
<Grid.Col
span="content"
p="lg"
style={{
minWidth: "13.625rem",
width: "13.625rem",
flexShrink: 0,
height: "100%",
}}
data-tour="file-sources"
>
<FileSourceButtons />
</Grid.Col>
{/* Column 2: File List */}
<Grid.Col
span="auto"
style={{
display: "flex",
flexDirection: "column",
height: "100%",
minHeight: 0,
minWidth: 0,
flex: "1 1 0px",
}}
>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
backgroundColor: "var(--bg-file-list)",
border: "1px solid var(--mantine-color-gray-2)",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)",
overflow: "hidden",
}}
>
{activeSource === "recent" && (
<>
<div
style={{
flexShrink: 0,
borderBottom: "1px solid var(--mantine-color-gray-3)",
}}
>
<SearchInput />
</div>
<div
style={{
flexShrink: 0,
borderBottom: "1px solid var(--mantine-color-gray-3)",
}}
>
<FileActions />
</div>
</>
)}
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
<FileListArea
scrollAreaHeight={
activeSource === "recent" && recentFiles.length > 0
? `calc(${modalHeight} - 7rem)`
: "100%"
}
scrollAreaStyle={{
height:
activeSource === "recent" && recentFiles.length > 0
? `calc(${modalHeight} - 7rem)`
: "100%",
backgroundColor: "transparent",
border: "none",
borderRadius: 0,
}}
/>
</div>
</div>
</Grid.Col>
{/* Column 3: File Details */}
<Grid.Col
p="xl"
span="content"
style={{
minWidth: "25rem",
width: "25rem",
flexShrink: 0,
height: "100%",
maxWidth: "18rem",
}}
>
<div style={{ height: "100%", overflow: "hidden" }}>
<FileDetails />
</div>
</Grid.Col>
{/* Hidden file input for local file selection */}
<HiddenFileInput />
</Grid>
);
};
export default DesktopLayout;
@@ -0,0 +1,46 @@
import React from "react";
import { Stack, Text, useMantineTheme, alpha } from "@mantine/core";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import { useTranslation } from "react-i18next";
interface DragOverlayProps {
isVisible: boolean;
}
const DragOverlay: React.FC<DragOverlayProps> = ({ isVisible }) => {
const { t } = useTranslation();
const theme = useMantineTheme();
if (!isVisible) return null;
return (
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: alpha(theme.colors.blue[6], 0.1),
border: `0.125rem dashed ${theme.colors.blue[6]}`,
borderRadius: "1.875rem",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
pointerEvents: "none",
}}
>
<Stack align="center" gap="md">
<UploadFileIcon
style={{ fontSize: "4rem", color: theme.colors.blue[6] }}
/>
<Text size="xl" fw={500} c="blue.6">
{t("fileManager.dropFilesHere", "Drop files here to upload")}
</Text>
</Stack>
</div>
);
};
export default DragOverlay;
@@ -0,0 +1,126 @@
import React, { useState } from "react";
import { Button, Group, Text, Stack } from "@mantine/core";
import HistoryIcon from "@mui/icons-material/History";
import { useTranslation } from "react-i18next";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
import LocalIcon from "@app/components/shared/LocalIcon";
import { Wordmark } from "@app/components/shared/Wordmark";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
const EmptyFilesState: React.FC = () => {
const { t } = useTranslation();
const { onLocalFileClick } = useFileManagerContext();
const [isUploadHover, setIsUploadHover] = useState(false);
const terminology = useFileActionTerminology();
const icons = useFileActionIcons();
const handleUploadClick = () => {
onLocalFileClick();
};
return (
<div
style={{
height: "100%",
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "2rem",
}}
>
{/* Container */}
<div
style={{
backgroundColor: "transparent",
padding: "3rem 2rem",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "1.5rem",
minWidth: "20rem",
maxWidth: "28rem",
width: "100%",
}}
>
{/* No Recent Files Message */}
<Stack align="center" gap="sm">
<HistoryIcon
style={{ fontSize: "3rem", color: "var(--mantine-color-gray-5)" }}
/>
<Text c="dimmed" ta="center" size="lg">
{t("fileManager.noRecentFiles", "No recent files")}
</Text>
</Stack>
{/* Stirling PDF Logo */}
<Group gap="xs" align="center">
<Wordmark
alt="Stirling PDF"
muted
style={{ height: "2.2rem", width: "auto" }}
/>
</Group>
{/* Upload Button */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
marginTop: "0.5rem",
marginBottom: "0.5rem",
}}
onMouseLeave={() => setIsUploadHover(false)}
>
<Button
aria-label="Upload"
style={{
backgroundColor: "var(--bg-file-manager)",
color: "var(--landing-button-color)",
border: "1px solid var(--landing-button-border)",
borderRadius: isUploadHover ? "2rem" : "1rem",
height: "38px",
width: isUploadHover ? "100%" : "58px",
minWidth: "58px",
paddingLeft: isUploadHover ? "1rem" : 0,
paddingRight: isUploadHover ? "1rem" : 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
transition:
"width .5s ease, padding .5s ease, border-radius .5s ease",
}}
onClick={handleUploadClick}
onMouseEnter={() => setIsUploadHover(true)}
>
<LocalIcon
icon={icons.uploadIconName}
width="1.25rem"
height="1.25rem"
style={{ color: "var(--accent-interactive)" }}
/>
{isUploadHover && (
<span style={{ marginLeft: ".5rem" }}>
{terminology.uploadFromComputer}
</span>
)}
</Button>
</div>
{/* Instruction Text */}
<span
className="text-[var(--accent-interactive)]"
style={{ fontSize: ".8rem", textAlign: "center" }}
>
{terminology.dropFilesHere}
</span>
</div>
</div>
);
};
export default EmptyFilesState;
@@ -0,0 +1,252 @@
import React, { useEffect } from "react";
import {
Group,
Text,
ActionIcon,
Tooltip,
SegmentedControl,
} from "@mantine/core";
import SelectAllIcon from "@mui/icons-material/SelectAll";
import DeleteIcon from "@mui/icons-material/Delete";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import LinkIcon from "@mui/icons-material/Link";
import { useTranslation } from "react-i18next";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal";
import BulkShareModal from "@app/components/shared/BulkShareModal";
const FileActions: React.FC = () => {
const { t } = useTranslation();
const terminology = useFileActionTerminology();
const icons = useFileActionIcons();
const DownloadIcon = icons.download;
const { config } = useAppConfig();
const [showBulkUploadModal, setShowBulkUploadModal] = React.useState(false);
const [showBulkShareModal, setShowBulkShareModal] = React.useState(false);
const {
recentFiles,
selectedFileIds,
selectedFiles,
filteredFiles,
onSelectAll,
onDeleteSelected,
onDownloadSelected,
refreshRecentFiles,
storageFilter,
onStorageFilterChange,
} = useFileManagerContext();
const uploadEnabled = config?.storageEnabled === true;
const sharingEnabled =
uploadEnabled && config?.storageSharingEnabled === true;
const shareLinksEnabled =
sharingEnabled && config?.storageShareLinksEnabled === true;
const showStorageFilter = uploadEnabled;
const storageFilterOptions = sharingEnabled
? [
{ value: "all", label: t("fileManager.filterAll", "All") },
{ value: "local", label: t("fileManager.filterLocal", "Local") },
{
value: "sharedWithMe",
label: t("fileManager.filterSharedWithMe", "Shared with me"),
},
{
value: "sharedByMe",
label: t("fileManager.filterSharedByMe", "Shared by me"),
},
]
: [
{ value: "all", label: t("fileManager.filterAll", "All") },
{ value: "local", label: t("fileManager.filterLocal", "Local") },
];
useEffect(() => {
if (
!sharingEnabled &&
(storageFilter === "sharedWithMe" || storageFilter === "sharedByMe")
) {
onStorageFilterChange("all");
}
}, [sharingEnabled, storageFilter, onStorageFilterChange]);
const hasSelection = selectedFileIds.length > 0;
const hasOnlyOwnedSelection = selectedFiles.every(
(file) => file.remoteOwnedByCurrentUser !== false,
);
const hasDownloadAccess = selectedFiles.every((file) => {
const role = (
file.remoteOwnedByCurrentUser !== false
? "editor"
: (file.remoteAccessRole ?? "viewer")
).toLowerCase();
return role === "editor" || role === "commenter" || role === "viewer";
});
const canBulkUpload = uploadEnabled && hasSelection && hasOnlyOwnedSelection;
const canBulkShare =
shareLinksEnabled && hasSelection && hasOnlyOwnedSelection;
const handleSelectAll = () => {
onSelectAll();
};
const handleDeleteSelected = () => {
if (selectedFileIds.length > 0) {
onDeleteSelected();
}
};
const handleDownloadSelected = () => {
if (selectedFileIds.length > 0) {
onDownloadSelected();
}
};
// Only show actions if there are files
if (recentFiles.length === 0) {
return null;
}
const allFilesSelected =
filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length;
return (
<div
style={{
padding: "0.75rem 1rem",
backgroundColor: "var(--mantine-color-gray-1)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
minHeight: "3rem",
position: "relative",
}}
>
{/* Left: Select All + Filter */}
<Group gap="xs">
<Tooltip
label={
allFilesSelected
? t("fileManager.deselectAll", "Deselect All")
: t("fileManager.selectAll", "Select All")
}
>
<ActionIcon
variant="light"
size="sm"
color="dimmed"
onClick={handleSelectAll}
disabled={filteredFiles.length === 0}
radius="sm"
>
<SelectAllIcon style={{ fontSize: "1rem" }} />
</ActionIcon>
</Tooltip>
{showStorageFilter && (
<SegmentedControl
size="xs"
value={storageFilter}
onChange={(value) =>
onStorageFilterChange(
value as "all" | "local" | "sharedWithMe" | "sharedByMe",
)
}
data={storageFilterOptions}
/>
)}
</Group>
{/* Center: Selected count */}
<div
style={{
position: "absolute",
left: "50%",
transform: "translateX(-50%)",
}}
>
{hasSelection && (
<Text size="sm" c="dimmed" fw={500}>
{t("fileManager.selectedCount", "{{count}} selected", {
count: selectedFileIds.length,
})}
</Text>
)}
</div>
{/* Right: Delete and Download */}
<Group gap="xs">
{uploadEnabled && (
<Tooltip label={t("fileManager.uploadSelected", "Upload Selected")}>
<ActionIcon
variant="light"
size="sm"
color="dimmed"
onClick={() => setShowBulkUploadModal(true)}
disabled={!canBulkUpload}
radius="sm"
>
<CloudUploadIcon style={{ fontSize: "1rem" }} />
</ActionIcon>
</Tooltip>
)}
{shareLinksEnabled && (
<Tooltip label={t("fileManager.shareSelected", "Share Selected")}>
<ActionIcon
variant="light"
size="sm"
color="dimmed"
onClick={() => setShowBulkShareModal(true)}
disabled={!canBulkShare}
radius="sm"
>
<LinkIcon style={{ fontSize: "1rem" }} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label={t("fileManager.deleteSelected", "Delete Selected")}>
<ActionIcon
variant="light"
size="sm"
color="dimmed"
onClick={handleDeleteSelected}
disabled={!hasSelection}
radius="sm"
>
<DeleteIcon style={{ fontSize: "1rem" }} />
</ActionIcon>
</Tooltip>
<Tooltip label={terminology.downloadSelected}>
<ActionIcon
variant="light"
size="sm"
color="dimmed"
onClick={handleDownloadSelected}
disabled={!hasSelection || !hasDownloadAccess}
radius="sm"
>
<DownloadIcon style={{ fontSize: "1rem" }} />
</ActionIcon>
</Tooltip>
</Group>
{uploadEnabled && (
<BulkUploadToServerModal
opened={showBulkUploadModal}
onClose={() => setShowBulkUploadModal(false)}
files={selectedFiles}
onUploaded={refreshRecentFiles}
/>
)}
{shareLinksEnabled && (
<BulkShareModal
opened={showBulkShareModal}
onClose={() => setShowBulkShareModal(false)}
files={selectedFiles}
onShared={refreshRecentFiles}
/>
)}
</div>
);
};
export default FileActions;
@@ -0,0 +1,132 @@
import React, { useEffect, useState } from "react";
import { Stack, Button, Box } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useIndexedDBThumbnail } from "@app/hooks/useIndexedDBThumbnail";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
import FilePreview from "@app/components/shared/FilePreview";
import FileInfoCard from "@app/components/fileManager/FileInfoCard";
import CompactFileDetails from "@app/components/fileManager/CompactFileDetails";
interface FileDetailsProps {
compact?: boolean;
}
const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
const { selectedFiles, onOpenFiles, modalHeight, activeFileIds } =
useFileManagerContext();
const { t } = useTranslation();
const [currentFileIndex, setCurrentFileIndex] = useState(0);
const [isAnimating, setIsAnimating] = useState(false);
// Get the currently displayed file
const currentFile =
selectedFiles.length > 0 ? selectedFiles[currentFileIndex] : null;
const hasSelection = selectedFiles.length > 0;
const hasActiveFiles = activeFileIds.length > 0;
// Enable "Close all files" when nothing is checked but files are open in workbench
const canCloseAll = !hasSelection && hasActiveFiles;
// Use IndexedDB hook for the current file
const { thumbnail: currentThumbnail } = useIndexedDBThumbnail(currentFile);
// Get thumbnail for current file
const getCurrentThumbnail = () => {
return currentThumbnail;
};
const handlePrevious = () => {
if (isAnimating) return;
setIsAnimating(true);
setTimeout(() => {
setCurrentFileIndex((prev) =>
prev > 0 ? prev - 1 : selectedFiles.length - 1,
);
setIsAnimating(false);
}, 150);
};
const handleNext = () => {
if (isAnimating) return;
setIsAnimating(true);
setTimeout(() => {
setCurrentFileIndex((prev) =>
prev < selectedFiles.length - 1 ? prev + 1 : 0,
);
setIsAnimating(false);
}, 150);
};
// Reset index when selection changes
useEffect(() => {
if (currentFileIndex >= selectedFiles.length) {
setCurrentFileIndex(0);
}
}, [selectedFiles.length, currentFileIndex]);
if (compact) {
return (
<CompactFileDetails
currentFile={currentFile}
thumbnail={getCurrentThumbnail()}
selectedFiles={selectedFiles}
currentFileIndex={currentFileIndex}
numberOfFiles={selectedFiles.length}
isAnimating={isAnimating}
onPrevious={handlePrevious}
onNext={handleNext}
onOpenFiles={onOpenFiles}
canCloseAll={canCloseAll}
/>
);
}
return (
<Stack gap="lg" h={`calc(${modalHeight} - 2rem)`} justify="flex-start">
{/* Section 1: Thumbnail Preview */}
<Box
style={{
width: "100%",
height: "min(35vh, 280px)",
textAlign: "center",
flexShrink: 0,
}}
>
<FilePreview
file={currentFile}
thumbnail={getCurrentThumbnail()}
showStacking={true}
showNavigation={true}
totalFiles={selectedFiles.length}
isAnimating={isAnimating}
onPrevious={handlePrevious}
onNext={handleNext}
/>
</Box>
{/* Section 2: File Details */}
<FileInfoCard currentFile={currentFile} modalHeight={modalHeight} />
<Button
size="md"
onClick={onOpenFiles}
disabled={!hasSelection && !canCloseAll}
fullWidth
style={{
backgroundColor:
hasSelection || canCloseAll
? "var(--btn-open-file)"
: "var(--mantine-color-gray-4)",
color: "white",
}}
>
{canCloseAll
? t("fileManager.closeAllFiles", "Close all files")
: selectedFiles.length > 1
? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`)
: t("fileManager.openFile", "Open File")}
</Button>
</Stack>
);
};
export default FileDetails;
@@ -0,0 +1,65 @@
import React from "react";
import { Box, Text, Collapse, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { StirlingFileStub } from "@app/types/fileContext";
import FileListItem from "@app/components/fileManager/FileListItem";
interface FileHistoryGroupProps {
leafFile: StirlingFileStub;
historyFiles: StirlingFileStub[];
isExpanded: boolean;
onDownloadSingle: (file: StirlingFileStub) => void;
onFileDoubleClick: (file: StirlingFileStub) => void;
onHistoryFileRemove: (file: StirlingFileStub) => void;
}
const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
leafFile,
historyFiles,
isExpanded,
onDownloadSingle,
onFileDoubleClick,
onHistoryFileRemove,
}) => {
const { t } = useTranslation();
// Sort history files by version number (oldest first, excluding the current leaf file)
const sortedHistory = historyFiles
.filter((file) => file.id !== leafFile.id) // Exclude the leaf file itself
.sort((a, b) => (b.versionNumber || 1) - (a.versionNumber || 1));
if (!isExpanded || sortedHistory.length === 0) {
return null;
}
return (
<Collapse in={isExpanded}>
<Box ml="md" mt="xs" mb="sm">
<Group align="center" mb="sm">
<Text size="xs" fw={600} c="dimmed">
{t("fileManager.fileHistory", "File History")} (
{sortedHistory.length})
</Text>
</Group>
<Box ml="md">
{sortedHistory.map((historyFile) => (
<FileListItem
key={`history-${historyFile.id}-${historyFile.versionNumber || 1}`}
file={historyFile}
isSelected={false} // History files are not selectable
onSelect={() => {}} // No selection for history files
onRemove={() => onHistoryFileRemove(historyFile)} // Remove specific history file
onDownload={() => onDownloadSingle(historyFile)}
onDoubleClick={() => onFileDoubleClick(historyFile)}
isHistoryFile={true} // This enables "Add to Recents" in menu
isLatestVersion={false} // History files are never latest
/>
))}
</Box>
</Box>
</Collapse>
);
};
export default FileHistoryGroup;
@@ -0,0 +1,304 @@
import React, { useMemo, useState } from "react";
import {
Stack,
Card,
Box,
Text,
Badge,
Group,
Divider,
ScrollArea,
Button,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { detectFileExtension, getFileSize } from "@app/utils/fileUtils";
import { StirlingFileStub } from "@app/types/fileContext";
import ToolChain from "@app/components/shared/ToolChain";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
import ShareManagementModal from "@app/components/shared/ShareManagementModal";
import { useAppConfig } from "@app/contexts/AppConfigContext";
interface FileInfoCardProps {
currentFile: StirlingFileStub | null;
modalHeight: string;
}
const FileInfoCard: React.FC<FileInfoCardProps> = ({
currentFile,
modalHeight,
}) => {
const { t } = useTranslation();
const { config } = useAppConfig();
const { onMakeCopy } = useFileManagerContext();
const [showShareManageModal, setShowShareManageModal] = useState(false);
const isSharedWithYou = useMemo(() => {
if (!currentFile) return false;
return (
currentFile.remoteOwnedByCurrentUser === false ||
currentFile.remoteSharedViaLink
);
}, [currentFile]);
const isOwnedRemote = useMemo(() => {
if (!currentFile) return false;
return (
Boolean(currentFile.remoteStorageId) &&
currentFile.remoteOwnedByCurrentUser !== false
);
}, [currentFile]);
const localUpdatedAt =
currentFile?.createdAt ?? currentFile?.lastModified ?? 0;
const remoteUpdatedAt = currentFile?.remoteStorageUpdatedAt ?? 0;
const isUploaded = Boolean(currentFile?.remoteStorageId);
const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt;
const isOutOfSync = isUploaded && !isUpToDate && isOwnedRemote;
const isLocalOnly =
!currentFile?.remoteStorageId && !currentFile?.remoteSharedViaLink;
const isSharedByYou = useMemo(() => {
if (!currentFile) return false;
return isOwnedRemote && Boolean(currentFile.remoteHasShareLinks);
}, [currentFile, isOwnedRemote]);
const uploadEnabled = config?.storageEnabled === true;
const sharingEnabled =
uploadEnabled && config?.storageSharingEnabled === true;
const ownerLabel = useMemo(() => {
if (!currentFile) return "";
if (currentFile.remoteOwnerUsername) {
return currentFile.remoteOwnerUsername;
}
return t("fileManager.ownerUnknown", "Unknown");
}, [currentFile, t]);
const lastSyncedLabel = useMemo(() => {
if (!currentFile?.remoteStorageUpdatedAt) return "";
return new Date(currentFile.remoteStorageUpdatedAt).toLocaleString();
}, [currentFile?.remoteStorageUpdatedAt]);
return (
<Card
withBorder
p={0}
mah={`calc(${modalHeight} * 0.45)`}
style={{
overflow: "hidden",
flexShrink: 1,
display: "flex",
flexDirection: "column",
}}
>
<Box
bg="gray.4"
p="sm"
style={{
borderTopLeftRadius: "var(--mantine-radius-md)",
borderTopRightRadius: "var(--mantine-radius-md)",
flexShrink: 0,
}}
>
<Text size="sm" fw={500} ta="center" c="white">
{t("fileManager.details", "File Details")}
</Text>
</Box>
<ScrollArea style={{ flex: 1, minHeight: 0 }} p="md">
<Stack gap="sm">
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">
<PrivateContent>
{t("fileManager.fileName", "Name")}
</PrivateContent>
</Text>
<Text
size="sm"
fw={500}
style={{ maxWidth: "60%", textAlign: "right" }}
truncate
>
{currentFile ? currentFile.name : ""}
</Text>
</Group>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">
{t("fileManager.fileFormat", "Format")}
</Text>
{currentFile ? (
<Badge size="sm" variant="light">
{detectFileExtension(currentFile.name).toUpperCase()}
</Badge>
) : (
<Text size="sm" fw={500}></Text>
)}
</Group>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">
{t("fileManager.fileSize", "Size")}
</Text>
<Text size="sm" fw={500}>
{currentFile ? getFileSize(currentFile) : ""}
</Text>
</Group>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">
{t("fileManager.lastModified", "Last modified")}
</Text>
<Text size="sm" fw={500}>
{currentFile
? new Date(currentFile.lastModified).toLocaleDateString()
: ""}
</Text>
</Group>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">
{t("fileManager.fileVersion", "Version")}
</Text>
{currentFile && (
<Badge
size="sm"
variant="light"
color={currentFile?.versionNumber ? "blue" : "gray"}
>
v{currentFile ? currentFile.versionNumber || 1 : ""}
</Badge>
)}
</Group>
{sharingEnabled && isSharedWithYou && (
<>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">
{t("fileManager.owner", "Owner")}
</Text>
<Group gap="xs">
<Text size="sm" fw={500}>
{ownerLabel}
</Text>
<Badge size="xs" variant="light" color="grape">
{t("fileManager.sharedWithYou", "Shared with you")}
</Badge>
</Group>
</Group>
</>
)}
{/* Tool Chain Display */}
{currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
<>
<Divider />
<Box py="xs">
<Text size="xs" c="dimmed" mb="xs">
{t("fileManager.toolChain", "Tools Applied")}
</Text>
<ToolChain
toolChain={currentFile.toolHistory}
displayStyle="badges"
size="xs"
/>
</Box>
</>
)}
{currentFile && isSharedWithYou && (
<>
<Divider />
<Button
size="sm"
variant="light"
onClick={() => onMakeCopy(currentFile)}
fullWidth
>
{t("fileManager.makeCopy", "Make a copy")}
</Button>
</>
)}
{currentFile && isOwnedRemote && (
<>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">
{t("fileManager.cloudFile", "Cloud file")}
</Text>
{uploadEnabled && isOutOfSync ? (
<Badge size="xs" variant="light" color="yellow">
{t(
"fileManager.changesNotUploaded",
"Changes not uploaded",
)}
</Badge>
) : uploadEnabled ? (
<Badge size="xs" variant="light" color="teal">
{t("fileManager.synced", "Synced")}
</Badge>
) : null}
</Group>
{lastSyncedLabel && (
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">
{t("fileManager.lastSynced", "Last synced")}
</Text>
<Text size="sm" fw={500}>
{lastSyncedLabel}
</Text>
</Group>
)}
{isSharedByYou && sharingEnabled && (
<>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">
{t("fileManager.sharing", "Sharing")}
</Text>
<Badge size="xs" variant="light" color="blue">
{t("fileManager.sharedByYou", "Shared by you")}
</Badge>
</Group>
<Button
size="sm"
variant="light"
onClick={() => setShowShareManageModal(true)}
fullWidth
>
{t("storageShare.manage", "Manage sharing")}
</Button>
</>
)}
</>
)}
{currentFile && isLocalOnly && (
<>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">
{t("fileManager.storageState", "Storage")}
</Text>
<Badge
size="xs"
variant="default"
c="dimmed"
style={{ opacity: 0.75 }}
>
{t("fileManager.localOnly", "Local only")}
</Badge>
</Group>
</>
)}
</Stack>
</ScrollArea>
{currentFile && isOwnedRemote && isSharedByYou && sharingEnabled && (
<ShareManagementModal
opened={showShareManageModal}
onClose={() => setShowShareManageModal(false)}
file={currentFile}
/>
)}
</Card>
);
};
export default FileInfoCard;
@@ -0,0 +1,111 @@
import React from "react";
import { Center, ScrollArea, Text, Stack } from "@mantine/core";
import CloudIcon from "@mui/icons-material/Cloud";
import { useTranslation } from "react-i18next";
import FileListItem from "@app/components/fileManager/FileListItem";
import FileHistoryGroup from "@app/components/fileManager/FileHistoryGroup";
import EmptyFilesState from "@app/components/fileManager/EmptyFilesState";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
interface FileListAreaProps {
scrollAreaHeight: string;
scrollAreaStyle?: React.CSSProperties;
}
const FileListArea: React.FC<FileListAreaProps> = ({
scrollAreaHeight,
scrollAreaStyle = {},
}) => {
const {
activeSource,
recentFiles,
filteredFiles,
selectedFilesSet,
expandedFileIds,
loadedHistoryFiles,
onFileSelect,
onFileRemove,
onHistoryFileRemove,
onFileDoubleClick,
onDownloadSingle,
isLoading,
activeFileIds,
} = useFileManagerContext();
const { t } = useTranslation();
if (activeSource === "recent") {
return (
<ScrollArea
h={scrollAreaHeight}
style={{
...scrollAreaStyle,
}}
type="always"
scrollbarSize={8}
>
<Stack gap={0}>
{recentFiles.length === 0 && !isLoading ? (
<EmptyFilesState />
) : recentFiles.length === 0 && isLoading ? (
<Center style={{ height: "12.5rem" }}>
<Text c="dimmed" ta="center">
{t("fileManager.loadingFiles", "Loading files...")}
</Text>
</Center>
) : (
filteredFiles.map((file, index) => {
// All files in filteredFiles are now leaf files only
const historyFiles = loadedHistoryFiles.get(file.id) || [];
const isExpanded = expandedFileIds.has(file.id);
const isActive = activeFileIds.includes(file.id);
return (
<React.Fragment key={file.id}>
<FileListItem
file={file}
isSelected={selectedFilesSet.has(file.id)}
onSelect={(shiftKey) => onFileSelect(file, index, shiftKey)}
onRemove={() => onFileRemove(index)}
onDownload={() => onDownloadSingle(file)}
onDoubleClick={() => onFileDoubleClick(file)}
isHistoryFile={false} // All files here are leaf files
isLatestVersion={true} // All files here are the latest versions
isActive={isActive}
/>
<FileHistoryGroup
leafFile={file}
historyFiles={historyFiles}
isExpanded={isExpanded}
onDownloadSingle={onDownloadSingle}
onFileDoubleClick={onFileDoubleClick}
onHistoryFileRemove={onHistoryFileRemove}
/>
</React.Fragment>
);
})
)}
</Stack>
</ScrollArea>
);
}
// Google Drive placeholder
return (
<Center style={{ height: "12.5rem" }}>
<Stack align="center" gap="sm">
<CloudIcon
style={{ fontSize: "3rem", color: "var(--mantine-color-gray-5)" }}
/>
<Text c="dimmed" ta="center">
{t(
"fileManager.googleDriveNotAvailable",
"Google Drive integration coming soon",
)}
</Text>
</Stack>
</Center>
);
};
export default FileListArea;
@@ -0,0 +1,510 @@
import React, { useCallback, useMemo, useState } from "react";
import {
Group,
Box,
Text,
ActionIcon,
Checkbox,
Divider,
Menu,
Badge,
} from "@mantine/core";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import DeleteIcon from "@mui/icons-material/Delete";
import DownloadIcon from "@mui/icons-material/Download";
import HistoryIcon from "@mui/icons-material/History";
import RestoreIcon from "@mui/icons-material/Restore";
import UnarchiveIcon from "@mui/icons-material/Unarchive";
import CloseIcon from "@mui/icons-material/Close";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import CloudDoneIcon from "@mui/icons-material/CloudDone";
import LinkIcon from "@mui/icons-material/Link";
import { useTranslation } from "react-i18next";
import { getFileSize, getFileDate } from "@app/utils/fileUtils";
import { FileId, StirlingFileStub } from "@app/types/fileContext";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
import { zipFileService } from "@app/services/zipFileService";
import ToolChain from "@app/components/shared/ToolChain";
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import { useFileManagement } from "@app/contexts/FileContext";
import UploadToServerModal from "@app/components/shared/UploadToServerModal";
import ShareFileModal from "@app/components/shared/ShareFileModal";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import ShareManagementModal from "@app/components/shared/ShareManagementModal";
import apiClient from "@app/services/apiClient";
import { absoluteWithBasePath } from "@app/constants/app";
import { alert } from "@app/components/toast";
interface FileListItemProps {
file: StirlingFileStub;
isSelected: boolean;
onSelect: (shiftKey?: boolean) => void;
onRemove: () => void;
onDownload?: () => void;
onDoubleClick?: () => void;
isLast?: boolean;
isHistoryFile?: boolean; // Whether this is a history file (indented)
isLatestVersion?: boolean; // Whether this is the latest version (shows chevron)
isActive?: boolean; // Whether this file is currently loaded in FileContext
}
const FileListItem: React.FC<FileListItemProps> = ({
file,
isSelected,
onSelect,
onRemove,
onDownload,
onDoubleClick,
isHistoryFile = false,
isLatestVersion = false,
isActive = false,
}) => {
const [isHovered, setIsHovered] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [showUploadModal, setShowUploadModal] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const [showShareManageModal, setShowShareManageModal] = useState(false);
const { t } = useTranslation();
const { config } = useAppConfig();
const {
expandedFileIds,
onToggleExpansion,
onUnzipFile,
refreshRecentFiles,
} = useFileManagerContext();
const { removeFiles } = useFileManagement();
// Check if this is a ZIP file
const isZipFile = zipFileService.isZipFileStub(file);
// Check file extension
const extLower = (
file.name?.match(/\.([a-z0-9]+)$/i)?.[1] || ""
).toLowerCase();
const isCBZ = extLower === "cbz";
const isCBR = extLower === "cbr";
// Keep item in hovered state if menu is open
const shouldShowHovered = isHovered || isMenuOpen;
// Get version information for this file
const leafFileId = (
isLatestVersion ? file.id : file.originalFileId || file.id
) as FileId;
const hasVersionHistory = (file.versionNumber || 1) > 1; // Show history for any processed file (v2+)
const currentVersion = file.versionNumber || 1; // Display original files as v1
const isExpanded = expandedFileIds.has(leafFileId);
const uploadEnabled = config?.storageEnabled === true;
const sharingEnabled =
uploadEnabled && config?.storageSharingEnabled === true;
const shareLinksEnabled =
sharingEnabled && config?.storageShareLinksEnabled === true;
const isOwnedOrLocal = file.remoteOwnedByCurrentUser !== false;
const isSharedWithYou =
sharingEnabled &&
(file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink);
const localUpdatedAt = file.createdAt ?? file.lastModified ?? 0;
const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0;
const isUploaded = Boolean(file.remoteStorageId);
const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt;
const isOutOfSync = isUploaded && !isUpToDate && isOwnedOrLocal;
const isLocalOnly = !file.remoteStorageId && !file.remoteSharedViaLink;
const accessRole = (
isOwnedOrLocal ? "editor" : (file.remoteAccessRole ?? "viewer")
).toLowerCase();
const hasReadAccess =
isOwnedOrLocal ||
accessRole === "editor" ||
accessRole === "commenter" ||
accessRole === "viewer";
const canUpload =
uploadEnabled &&
isOwnedOrLocal &&
isLatestVersion &&
(!isUploaded || !isUpToDate);
const canShare = shareLinksEnabled && isOwnedOrLocal && isLatestVersion;
const canManageShare =
sharingEnabled && isOwnedOrLocal && Boolean(file.remoteStorageId);
const canCopyShareLink =
shareLinksEnabled &&
Boolean(file.remoteHasShareLinks) &&
Boolean(file.remoteStorageId);
const canDownloadFile = Boolean(onDownload) && hasReadAccess;
const shareBaseUrl = useMemo(() => {
const frontendUrl = (config?.frontendUrl || "").trim();
if (frontendUrl) {
const normalized = frontendUrl.endsWith("/")
? frontendUrl.slice(0, -1)
: frontendUrl;
return `${normalized}/share/`;
}
return absoluteWithBasePath("/share/");
}, [config?.frontendUrl]);
const handleCopyShareLink = useCallback(async () => {
if (!file.remoteStorageId) return;
try {
const response = await apiClient.get<{
shareLinks?: Array<{ token?: string }>;
}>(`/api/v1/storage/files/${file.remoteStorageId}`, {
suppressErrorToast: true,
} as any);
const links = response.data?.shareLinks ?? [];
const token = links[links.length - 1]?.token;
if (!token) {
alert({
alertType: "warning",
title: t("storageShare.noLinks", "No active share links yet."),
expandable: false,
durationMs: 2500,
});
return;
}
await navigator.clipboard.writeText(`${shareBaseUrl}${token}`);
alert({
alertType: "success",
title: t("storageShare.copied", "Link copied to clipboard"),
expandable: false,
durationMs: 2000,
});
} catch (error) {
console.error("Failed to copy share link:", error);
alert({
alertType: "warning",
title: t("storageShare.copyFailed", "Copy failed"),
expandable: false,
durationMs: 2500,
});
}
}, [file.remoteStorageId, shareBaseUrl, t]);
return (
<>
<Box
p="sm"
style={{
cursor: isHistoryFile ? "default" : "pointer",
backgroundColor:
isSelected || shouldShowHovered
? "var(--mantine-color-gray-1)"
: "var(--bg-file-list)",
opacity: 1,
transition: "background-color 0.15s ease",
userSelect: "none",
WebkitUserSelect: "none",
MozUserSelect: "none",
msUserSelect: "none",
paddingLeft: isHistoryFile ? "2rem" : "0.75rem", // Indent history files
borderLeft: isHistoryFile
? "3px solid var(--mantine-color-blue-4)"
: "none", // Visual indicator for history
}}
onClick={isHistoryFile ? undefined : (e) => onSelect(e.shiftKey)}
onDoubleClick={onDoubleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Group gap="sm">
{!isHistoryFile && (
<Box>
{/* Checkbox for regular files only */}
<Checkbox
checked={isSelected}
onChange={() => {}} // Handled by parent onClick
size="sm"
pl="sm"
pr="xs"
styles={{
input: {
cursor: "pointer",
},
}}
/>
</Box>
)}
<Box style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" align="center">
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>
<PrivateContent>{file.name}</PrivateContent>
</Text>
{isActive && (
<Badge
size="xs"
variant="light"
style={{
backgroundColor: "var(--file-active-badge-bg)",
color: "var(--file-active-badge-fg)",
border: "1px solid var(--file-active-badge-border)",
}}
>
{t("fileManager.active", "Active")}
</Badge>
)}
<Badge size="xs" variant="light" color={"blue"}>
v{currentVersion}
</Badge>
{sharingEnabled && isSharedWithYou ? (
<Badge size="xs" variant="light" color="grape">
{t("fileManager.sharedWithYou", "Shared with you")}
</Badge>
) : null}
{sharingEnabled &&
isSharedWithYou &&
accessRole &&
accessRole !== "editor" ? (
<Badge size="xs" variant="light" color="gray">
{accessRole === "commenter"
? t("storageShare.roleCommenter", "Commenter")
: t("storageShare.roleViewer", "Viewer")}
</Badge>
) : isLocalOnly ? (
<Badge
size="xs"
variant="default"
c="dimmed"
style={{ opacity: 0.75 }}
>
{t("fileManager.localOnly", "Local only")}
</Badge>
) : uploadEnabled && isOutOfSync ? (
<Badge
size="xs"
variant="light"
color="yellow"
leftSection={<CloudUploadIcon style={{ fontSize: 12 }} />}
>
{t("fileManager.changesNotUploaded", "Changes not uploaded")}
</Badge>
) : uploadEnabled && isUploaded ? (
<Badge
size="xs"
variant="light"
color="teal"
leftSection={<CloudDoneIcon style={{ fontSize: 12 }} />}
>
{t("fileManager.synced", "Synced")}
</Badge>
) : null}
{sharingEnabled &&
file.remoteOwnedByCurrentUser !== false &&
file.remoteHasShareLinks && (
<Badge size="xs" variant="light" color="blue">
{t("fileManager.sharedByYou", "Shared by you")}
</Badge>
)}
</Group>
<Group gap="xs" align="center">
<Text size="xs" c="dimmed">
{getFileSize(file)} {getFileDate(file)}
</Text>
{/* Tool chain for processed files */}
{file.toolHistory && file.toolHistory.length > 0 && (
<ToolChain
toolChain={file.toolHistory}
maxWidth={"150px"}
displayStyle="text"
size="xs"
/>
)}
</Group>
</Box>
{/* Three dots menu - fades in/out on hover */}
<Menu
position="bottom-end"
withinPortal
onOpen={() => setIsMenuOpen(true)}
onClose={() => setIsMenuOpen(false)}
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
>
<Menu.Target>
<ActionIcon
variant="subtle"
c="dimmed"
size="md"
onClick={(e) => e.stopPropagation()}
style={{
opacity: shouldShowHovered ? 1 : 0,
transform: shouldShowHovered ? "scale(1)" : "scale(0.8)",
transition: "opacity 0.3s ease, transform 0.3s ease",
pointerEvents: shouldShowHovered ? "auto" : "none",
}}
>
<MoreVertIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{/* Close file option for active files */}
{isActive && (
<>
<Menu.Item
leftSection={<CloseIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
removeFiles([file.id]);
}}
>
{t("fileManager.closeFile", "Close File")}
</Menu.Item>
<Menu.Divider />
</>
)}
{canDownloadFile && (
<Menu.Item
leftSection={<DownloadIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onDownload?.();
}}
>
{t("fileManager.download", "Download")}
</Menu.Item>
)}
{canUpload && (
<Menu.Item
leftSection={<CloudUploadIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
setShowUploadModal(true);
}}
>
{isUploaded
? t("fileManager.updateOnServer", "Update on Server")
: t("fileManager.uploadToServer", "Upload to Server")}
</Menu.Item>
)}
{canShare && (
<Menu.Item
leftSection={<LinkIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
setShowShareModal(true);
}}
>
{t("fileManager.share", "Share")}
</Menu.Item>
)}
{canCopyShareLink && (
<Menu.Item
leftSection={<LinkIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
void handleCopyShareLink();
}}
>
{t("storageShare.copyLink", "Copy share link")}
</Menu.Item>
)}
{canManageShare && (
<Menu.Item
leftSection={<LinkIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
setShowShareManageModal(true);
}}
>
{t("storageShare.manage", "Manage sharing")}
</Menu.Item>
)}
{/* Show/Hide History option for latest version files */}
{isLatestVersion && hasVersionHistory && (
<>
<Menu.Item
leftSection={<HistoryIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onToggleExpansion(leafFileId);
}}
>
{isExpanded
? t("fileManager.hideHistory", "Hide History")
: t("fileManager.showHistory", "Show History")}
</Menu.Item>
<Menu.Divider />
</>
)}
{/* Restore option for history files */}
{isHistoryFile && (
<>
<Menu.Item
leftSection={<RestoreIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
}}
>
{t("fileManager.restore", "Restore")}
</Menu.Item>
<Menu.Divider />
</>
)}
{/* Unzip option for ZIP files */}
{isZipFile && !isHistoryFile && !isCBZ && !isCBR && (
<>
<Menu.Item
leftSection={<UnarchiveIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onUnzipFile(file);
}}
>
{t("fileManager.unzip", "Unzip")}
</Menu.Item>
<Menu.Divider />
</>
)}
<Menu.Item
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
{t("fileManager.delete", "Delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Box>
{<Divider color="var(--mantine-color-gray-3)" />}
{canUpload && (
<UploadToServerModal
opened={showUploadModal}
onClose={() => setShowUploadModal(false)}
file={file}
onUploaded={refreshRecentFiles}
/>
)}
{canShare && (
<ShareFileModal
opened={showShareModal}
onClose={() => setShowShareModal(false)}
file={file}
onUploaded={refreshRecentFiles}
/>
)}
{canManageShare && (
<ShareManagementModal
opened={showShareManageModal}
onClose={() => setShowShareManageModal(false)}
file={file}
/>
)}
</>
);
};
export default FileListItem;
@@ -0,0 +1,240 @@
import React, { useState } from "react";
import { Stack, Text, Button, Group } from "@mantine/core";
import HistoryIcon from "@mui/icons-material/History";
import PhonelinkIcon from "@mui/icons-material/Phonelink";
import { useTranslation } from "react-i18next";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
import { useGoogleDrivePicker } from "@app/hooks/useGoogleDrivePicker";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useIsMobile } from "@app/hooks/useIsMobile";
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons";
interface FileSourceButtonsProps {
horizontal?: boolean;
}
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
horizontal = false,
}) => {
const {
activeSource,
onSourceChange,
onLocalFileClick,
onGoogleDriveSelect,
onNewFilesSelect,
} = useFileManagerContext();
const { t } = useTranslation();
const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } =
useGoogleDrivePicker();
const terminology = useFileActionTerminology();
const icons = useFileActionIcons();
const UploadIcon = icons.upload;
const [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false);
const { config } = useAppConfig();
const isMobile = useIsMobile();
const isMobileUploadEnabled = config?.enableMobileScanner && !isMobile;
const handleGoogleDriveClick = async () => {
try {
const files = await openGoogleDrivePicker({ multiple: true });
if (files.length > 0) {
onGoogleDriveSelect(files);
}
} catch (error) {
console.error("Failed to pick files from Google Drive:", error);
}
};
const handleMobileUploadClick = () => {
setMobileUploadModalOpen(true);
};
const handleFilesReceivedFromMobile = (files: File[]) => {
if (files.length > 0) {
onNewFilesSelect(files);
}
};
// Determine visibility of Google Drive button
const shouldHideGoogleDrive =
!isGoogleDriveEnabled && config?.hideDisabledToolsGoogleDrive;
// Determine visibility of Mobile QR Scanner button
const shouldHideMobileQR =
!isMobileUploadEnabled && config?.hideDisabledToolsMobileQRScanner;
const buttonProps = {
variant: (source: string) =>
activeSource === source ? "filled" : "subtle",
getColor: (source: string) =>
activeSource === source ? "var(--mantine-color-gray-2)" : undefined,
getStyles: (source: string) => ({
root: {
backgroundColor: activeSource === source ? undefined : "transparent",
color:
activeSource === source
? "var(--mantine-color-gray-9)"
: "var(--mantine-color-gray-6)",
border: "none",
"&:hover": {
backgroundColor:
activeSource === source ? undefined : "var(--mantine-color-gray-0)",
},
},
}),
};
const buttons = (
<>
<Button
leftSection={<HistoryIcon />}
justify={horizontal ? "center" : "flex-start"}
onClick={() => onSourceChange("recent")}
fullWidth={!horizontal}
size={horizontal ? "xs" : "sm"}
color={buttonProps.getColor("recent")}
styles={buttonProps.getStyles("recent")}
>
{horizontal
? t("fileManager.recent", "Recent")
: t("fileManager.recent", "Recent")}
</Button>
<Button
variant="subtle"
color="var(--mantine-color-gray-6)"
leftSection={<UploadIcon />}
justify={horizontal ? "center" : "flex-start"}
onClick={onLocalFileClick}
fullWidth={!horizontal}
size={horizontal ? "xs" : "sm"}
styles={{
root: {
backgroundColor: "transparent",
border: "none",
"&:hover": {
backgroundColor: "var(--mantine-color-gray-0)",
},
},
}}
>
{horizontal ? terminology.upload : terminology.uploadFiles}
</Button>
{!shouldHideGoogleDrive && (
<Button
variant="subtle"
color="var(--mantine-color-gray-6)"
leftSection={<GoogleDriveIcon colored={isGoogleDriveEnabled} />}
justify={horizontal ? "center" : "flex-start"}
onClick={handleGoogleDriveClick}
fullWidth={!horizontal}
size={horizontal ? "xs" : "sm"}
disabled={!isGoogleDriveEnabled}
styles={{
root: {
backgroundColor: "transparent",
border: "none",
"&:hover": {
backgroundColor: isGoogleDriveEnabled
? "var(--mantine-color-gray-0)"
: "transparent",
},
},
}}
title={
!isGoogleDriveEnabled
? t(
"fileManager.googleDriveNotAvailable",
"Google Drive integration not available",
)
: undefined
}
>
{horizontal
? t("fileManager.googleDriveShort", "Drive")
: t("fileManager.googleDrive", "Google Drive")}
</Button>
)}
{!shouldHideMobileQR && (
<Button
variant="subtle"
color="var(--mantine-color-gray-6)"
leftSection={<PhonelinkIcon />}
justify={horizontal ? "center" : "flex-start"}
onClick={handleMobileUploadClick}
fullWidth={!horizontal}
size={horizontal ? "xs" : "sm"}
disabled={!isMobileUploadEnabled}
styles={{
root: {
backgroundColor: "transparent",
border: "none",
"&:hover": {
backgroundColor: isMobileUploadEnabled
? "var(--mantine-color-gray-0)"
: "transparent",
},
},
}}
title={
!isMobileUploadEnabled
? t(
"fileManager.mobileUploadNotAvailable",
"Mobile upload not available",
)
: undefined
}
>
{horizontal
? t("fileManager.mobileShort", "Mobile")
: t("fileManager.mobileUpload", "Mobile Upload")}
</Button>
)}
</>
);
if (horizontal) {
return (
<>
<Group gap="xs" justify="center" style={{ width: "100%" }}>
{buttons}
</Group>
<MobileUploadModal
opened={mobileUploadModalOpen}
onClose={() => setMobileUploadModalOpen(false)}
onFilesReceived={handleFilesReceivedFromMobile}
/>
</>
);
}
return (
<>
<Stack gap="xs" style={{ height: "100%" }}>
<Text
size="sm"
pt="sm"
fw={500}
c="dimmed"
mb="xs"
style={{ paddingLeft: "1rem" }}
>
{t("fileManager.myFiles", "My Files")}
</Text>
{buttons}
</Stack>
<MobileUploadModal
opened={mobileUploadModalOpen}
onClose={() => setMobileUploadModalOpen(false)}
onFilesReceived={handleFilesReceivedFromMobile}
/>
</>
);
};
export default FileSourceButtons;
@@ -0,0 +1,19 @@
import React from "react";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
const HiddenFileInput: React.FC = () => {
const { fileInputRef, onFileInputChange } = useFileManagerContext();
return (
<input
ref={fileInputRef}
type="file"
multiple={true}
onChange={onFileInputChange}
style={{ display: "none" }}
data-testid="file-input"
/>
);
};
export default HiddenFileInput;
@@ -0,0 +1,99 @@
import React from "react";
import { Box } from "@mantine/core";
import FileSourceButtons from "@app/components/fileManager/FileSourceButtons";
import FileDetails from "@app/components/fileManager/FileDetails";
import SearchInput from "@app/components/fileManager/SearchInput";
import FileListArea from "@app/components/fileManager/FileListArea";
import FileActions from "@app/components/fileManager/FileActions";
import HiddenFileInput from "@app/components/fileManager/HiddenFileInput";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
const MobileLayout: React.FC = () => {
const { activeSource, selectedFiles, modalHeight } = useFileManagerContext();
// Calculate the height more accurately based on actual content
const calculateFileListHeight = () => {
// Base modal height minus padding and gaps
const baseHeight = `calc(${modalHeight} - 2rem)`; // Account for Stack padding
// Estimate heights of fixed components
const fileSourceHeight = "3rem"; // FileSourceButtons height
const fileDetailsHeight = selectedFiles.length > 0 ? "10rem" : "8rem"; // FileDetails compact height
const fileActionsHeight = activeSource === "recent" ? "3rem" : "0rem"; // FileActions height (now at bottom)
const searchHeight = activeSource === "recent" ? "3rem" : "0rem"; // SearchInput height
const gapHeight = activeSource === "recent" ? "3.75rem" : "2rem"; // Stack gaps
return `calc(${baseHeight} - ${fileSourceHeight} - ${fileDetailsHeight} - ${fileActionsHeight} - ${searchHeight} - ${gapHeight})`;
};
return (
<Box
h="100%"
p="sm"
style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}
>
{/* Section 1: File Sources - Fixed at top */}
<Box style={{ flexShrink: 0 }}>
<FileSourceButtons horizontal={true} />
</Box>
<Box style={{ flexShrink: 0 }}>
<FileDetails compact={true} />
</Box>
{/* Section 3 & 4: Search Bar + File List - Unified background extending to modal edge */}
<Box
style={{
flex: 1,
display: "flex",
flexDirection: "column",
backgroundColor: "var(--bg-file-list)",
borderRadius: "0.5rem",
border: "1px solid var(--mantine-color-gray-2)",
overflow: "hidden",
minHeight: 0,
}}
>
{activeSource === "recent" && (
<>
<Box
style={{
flexShrink: 0,
borderBottom: "1px solid var(--mantine-color-gray-2)",
}}
>
<SearchInput />
</Box>
<Box
style={{
flexShrink: 0,
borderBottom: "1px solid var(--mantine-color-gray-2)",
}}
>
<FileActions />
</Box>
</>
)}
<Box style={{ flex: 1, minHeight: 0 }}>
<FileListArea
scrollAreaHeight={calculateFileListHeight()}
scrollAreaStyle={{
height: calculateFileListHeight(),
maxHeight: "60vh",
minHeight: "9.375rem",
backgroundColor: "transparent",
border: "none",
borderRadius: 0,
}}
/>
</Box>
</Box>
{/* Hidden file input for local file selection */}
<HiddenFileInput />
</Box>
);
};
export default MobileLayout;
@@ -0,0 +1,32 @@
import React from "react";
import { TextInput } from "@mantine/core";
import SearchIcon from "@mui/icons-material/Search";
import { useTranslation } from "react-i18next";
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
interface SearchInputProps {
style?: React.CSSProperties;
}
const SearchInput: React.FC<SearchInputProps> = ({ style }) => {
const { t } = useTranslation();
const { searchTerm, onSearchChange } = useFileManagerContext();
return (
<TextInput
placeholder={t("fileManager.searchFiles", "Search files...")}
leftSection={<SearchIcon />}
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
style={{ padding: "0.5rem", ...style }}
styles={{
input: {
border: "none",
backgroundColor: "transparent",
},
}}
/>
);
};
export default SearchInput;
@@ -0,0 +1,7 @@
/**
* Core stub for HomePage extensions.
*/
export function HomePageExtensions() {
return null;
}
@@ -0,0 +1,67 @@
import React from "react";
import { HotkeyBinding } from "@app/utils/hotkeys";
import { useHotkeys } from "@app/contexts/HotkeyContext";
interface HotkeyDisplayProps {
binding: HotkeyBinding | null | undefined;
size?: "sm" | "md";
muted?: boolean;
}
const baseKeyStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "0.375rem",
background: "var(--mantine-color-gray-1)",
border: "1px solid var(--mantine-color-gray-3)",
padding: "0.125rem 0.35rem",
fontSize: "0.75rem",
lineHeight: 1,
fontFamily: "var(--mantine-font-family-monospace, monospace)",
minWidth: "1.35rem",
color: "var(--mantine-color-text)",
};
export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({
binding,
size = "sm",
muted = false,
}) => {
const { getDisplayParts } = useHotkeys();
const parts = getDisplayParts(binding);
if (!binding || parts.length === 0) {
return null;
}
const keyStyle =
size === "md"
? { ...baseKeyStyle, fontSize: "0.85rem", padding: "0.2rem 0.5rem" }
: baseKeyStyle;
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.25rem",
color: muted ? "var(--mantine-color-dimmed)" : "inherit",
fontWeight: muted ? 500 : 600,
}}
>
{parts.map((part, index) => (
<React.Fragment key={`${part}-${index}`}>
<kbd style={keyStyle}>{part}</kbd>
{index < parts.length - 1 && (
<span aria-hidden style={{ fontWeight: 400 }}>
+
</span>
)}
</React.Fragment>
))}
</span>
);
};
export default HotkeyDisplay;
@@ -0,0 +1,42 @@
/* WorkbenchBar slide-in/out animation using CSS grid trick */
.workbenchBarWrapper {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 280ms ease;
}
.workbenchBarWrapper[data-hidden="true"] {
grid-template-rows: 0fr;
}
.workbenchBarWrapper[data-no-transition="true"] {
transition: none;
}
/* Direct child must have min-height: 0 so the row can collapse below content size */
.workbenchBarInner {
min-height: 0;
overflow: hidden;
}
.workbenchScrollable {
overflow-y: auto !important;
overflow-x: hidden !important;
}
.workbenchScrollable::-webkit-scrollbar {
width: 0.375rem;
}
.workbenchScrollable::-webkit-scrollbar-track {
background: transparent;
}
.workbenchScrollable::-webkit-scrollbar-thumb {
background-color: var(--mantine-color-gray-4);
border-radius: 0.1875rem;
}
.workbenchScrollable::-webkit-scrollbar-thumb:hover {
background-color: var(--mantine-color-gray-5);
}
@@ -0,0 +1,240 @@
import { useEffect, useState, Suspense, lazy } from "react";
import { Box, Loader, Center } from "@mantine/core";
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useFileHandler } from "@app/hooks/useFileHandler";
import { useFileState } from "@app/contexts/FileContext";
import {
useNavigationState,
useNavigationActions,
} from "@app/contexts/NavigationContext";
import { isBaseWorkbench } from "@app/types/workbench";
import { VIEWER_SUPPORTED_EXTENSIONS } from "@app/utils/fileUtils";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import styles from "@app/components/layout/Workbench.module.css";
import WorkbenchBar from "@app/components/shared/WorkbenchBar";
import LandingPage from "@app/components/shared/LandingPage";
import Footer from "@app/components/shared/Footer";
import DismissAllErrorsButton from "@app/components/shared/DismissAllErrorsButton";
// Workbench panels are loaded on demand. Viewer pulls in pdfjs-dist and the
// full @embedpdf plugin set; FileEditor/PageEditor are only needed once a file
// is open. Lazy-loading keeps all of that out of the initial bundle.
const FileEditor = lazy(() => import("@app/components/fileEditor/FileEditor"));
const PageEditor = lazy(() => import("@app/components/pageEditor/PageEditor"));
const PageEditorControls = lazy(
() => import("@app/components/pageEditor/PageEditorControls"),
);
const Viewer = lazy(() => import("@app/components/viewer/Viewer"));
// No props needed - component uses contexts directly
export default function Workbench() {
const { isRainbowMode } = useRainbowThemeContext();
const { config } = useAppConfig();
// Use context-based hooks to eliminate all prop drilling
const { selectors } = useFileState();
const { workbench: currentView } = useNavigationState();
const { actions: navActions } = useNavigationActions();
const setCurrentView = navActions.setWorkbench;
const activeFiles = selectors.getFiles();
const {
previewFile,
pageEditorFunctions,
sidebarsVisible,
setPreviewFile,
setPageEditorFunctions,
setSidebarsVisible,
customWorkbenchViews,
} = useToolWorkflow();
const { handleToolSelect } = useToolWorkflow();
// Get navigation state - this is the source of truth
const { selectedTool: selectedToolId } = useNavigationState();
// Get tool registry from context (instead of direct hook call)
const { toolRegistry } = useToolWorkflow();
const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null;
const { addFiles } = useFileHandler();
const hasFiles = activeFiles.length > 0;
// Enable bar transitions after first paint so the initial hidden state shows
// without animating (landing page on load shouldn't animate the bar up).
const [barTransitionEnabled, setBarTransitionEnabled] = useState(false);
useEffect(() => {
const raf = requestAnimationFrame(() => setBarTransitionEnabled(true));
return () => cancelAnimationFrame(raf);
}, []);
const handlePreviewClose = () => {
setPreviewFile(null);
const previousMode = sessionStorage.getItem("previousMode");
if (previousMode === "split") {
// Use context's handleToolSelect which coordinates tool selection and view changes
handleToolSelect("split");
sessionStorage.removeItem("previousMode");
} else if (previousMode === "compress") {
handleToolSelect("compress");
sessionStorage.removeItem("previousMode");
} else if (previousMode === "convert") {
handleToolSelect("convert");
sessionStorage.removeItem("previousMode");
} else {
setCurrentView("fileEditor");
}
};
const renderMainContent = () => {
// Check if we're showing a custom workbench first
// Custom workbenches may not require files in FileContext (e.g., sign request workbench)
if (!isBaseWorkbench(currentView)) {
const customView = customWorkbenchViews.find(
(view) => view.workbenchId === currentView && view.data != null,
);
if (customView) {
const CustomComponent = customView.component;
return <CustomComponent data={customView.data} />;
}
}
if (activeFiles.length === 0) {
return <LandingPage />;
}
switch (currentView) {
case "fileEditor":
return (
<FileEditor
toolMode={!!selectedToolId}
supportedExtensions={
selectedTool?.supportedFormats || VIEWER_SUPPORTED_EXTENSIONS
}
{...(!selectedToolId && {
onOpenPageEditor: () => {
setCurrentView("pageEditor");
},
onMergeFiles: (filesToMerge) => {
addFiles(filesToMerge);
setCurrentView("viewer");
},
})}
/>
);
case "viewer":
return (
<Viewer
sidebarsVisible={sidebarsVisible}
setSidebarsVisible={setSidebarsVisible}
previewFile={previewFile}
onClose={handlePreviewClose}
/>
);
case "pageEditor":
return (
<div style={{ position: "relative", flex: "1 1 0", height: 0 }}>
<PageEditor onFunctionsReady={setPageEditorFunctions} />
{pageEditorFunctions && (
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
zIndex: 100,
}}
>
<PageEditorControls
onClosePdf={pageEditorFunctions.closePdf}
onUndo={pageEditorFunctions.handleUndo}
onRedo={pageEditorFunctions.handleRedo}
canUndo={pageEditorFunctions.canUndo}
canRedo={pageEditorFunctions.canRedo}
onRotate={pageEditorFunctions.handleRotate}
onDelete={pageEditorFunctions.handleDelete}
onSplit={pageEditorFunctions.handleSplit}
onSplitAll={pageEditorFunctions.handleSplitAll}
onPageBreak={pageEditorFunctions.handlePageBreak}
onPageBreakAll={pageEditorFunctions.handlePageBreakAll}
onExportAll={pageEditorFunctions.onExportAll}
exportLoading={pageEditorFunctions.exportLoading}
selectionMode={pageEditorFunctions.selectionMode}
selectedPageIds={pageEditorFunctions.selectedPageIds}
displayDocument={pageEditorFunctions.displayDocument}
splitPositions={pageEditorFunctions.splitPositions}
totalPages={pageEditorFunctions.totalPages}
/>
</div>
)}
</div>
);
default:
return null;
}
};
return (
<Box
className="flex-1 h-full min-w-0 relative flex flex-col"
data-tour="workbench"
style={
isRainbowMode
? {} // No background color in rainbow mode
: { backgroundColor: "var(--bg-background)" }
}
>
{/* Workbench Bar - animates in/out based on file presence */}
{!customWorkbenchViews.find((v) => v.workbenchId === currentView)
?.hideTopControls && (
<div
className={styles.workbenchBarWrapper}
data-hidden={String(!hasFiles)}
data-no-transition={String(!barTransitionEnabled)}
>
<div className={styles.workbenchBarInner}>
<WorkbenchBar
currentView={currentView}
setCurrentView={setCurrentView}
hasFiles={hasFiles}
/>
</div>
</div>
)}
{/* Dismiss All Errors Button */}
<DismissAllErrorsButton />
{/* Main content area */}
<Box
className={`flex-1 min-h-0 z-10 ${currentView === "pageEditor" ? "relative flex flex-col" : `relative ${styles.workbenchScrollable}`}`}
style={{
transition: "opacity 0.15s ease-in-out",
...(currentView === "pageEditor" && { height: 0 }),
}}
>
<Suspense
fallback={
<Center style={{ height: "100%" }}>
<Loader />
</Center>
}
>
{renderMainContent()}
</Suspense>
</Box>
<Footer
analyticsEnabled={config?.enableAnalytics === true}
termsAndConditions={config?.termsAndConditions}
privacyPolicy={config?.privacyPolicy}
cookiePolicy={config?.cookiePolicy}
impressum={config?.impressum}
accessibilityStatement={config?.accessibilityStatement}
/>
</Box>
);
}
@@ -0,0 +1,350 @@
.heroWrapper {
position: relative;
width: 100%;
height: 220px;
overflow: hidden;
}
.heroLogo {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: heroLogoEnter 0.25s ease forwards;
z-index: 20;
}
.heroLogoCircle {
width: 96px;
height: 96px;
border-radius: 50%;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.18);
animation: heroLogoScale 0.25s ease forwards;
}
.heroLogoCircle img {
width: 60px;
height: 60px;
animation: heroLogoRotate 0.25s ease forwards;
transform-origin: center;
}
.standaloneIcon {
width: 96px;
height: 96px;
object-fit: contain;
animation: heroLogoScale 0.25s ease forwards;
}
.securitySlideContent {
display: flex;
justify-content: center;
margin-top: 12px;
}
.securityCard {
width: 100%;
max-width: 480px;
background: var(--bg-surface, #ffffff);
border-radius: 16px;
padding: 20px;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
display: flex;
flex-direction: column;
gap: 16px;
}
.securityAlertRow {
display: flex;
align-items: flex-start;
gap: 10px;
font-weight: 500;
font-size: 15px;
line-height: 1.5;
color: var(--onboarding-body, #1f2937);
}
.mfaSlideContent {
display: flex;
justify-content: center;
margin-top: 12px;
}
.mfaCard {
width: 100%;
max-width: 560px;
background: var(--bg-surface, #ffffff);
border-radius: 16px;
padding: 20px;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
}
.mfaSetupGrid {
display: grid;
grid-template-columns: 180px 1fr;
gap: 16px;
align-items: center;
}
.mfaQrCard {
padding: 12px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 8px 16px rgba(15, 23, 42, 0.12);
display: flex;
justify-content: center;
}
.mfaSteps {
margin: 0;
padding-left: 18px;
color: var(--onboarding-body, #1f2937);
font-size: 14px;
line-height: 1.5;
}
.mfaForm {
width: 100%;
}
@media (max-width: 640px) {
.mfaSetupGrid {
grid-template-columns: 1fr;
justify-items: center;
}
}
.heroIconsContainer {
display: flex;
gap: 32px;
align-items: flex-start;
justify-content: center;
animation: heroLogoEnter 0.25s ease forwards;
position: relative;
top: 1rem;
}
.iconWrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.iconButton {
background: none;
border: none;
padding: 0;
cursor: pointer;
transition:
transform 0.2s ease,
opacity 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
position: relative;
border-radius: 12px;
padding: 4px;
outline: none;
}
.iconButton:focus {
outline: none;
}
.iconButton:focus-visible {
outline: none;
}
.iconButton:hover {
transform: scale(1.05);
opacity: 0.9;
}
.iconButton:active {
transform: scale(0.95);
}
.iconButtonSelected {
background: rgba(255, 255, 255, 0.1);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.4);
}
.downloadIcon {
width: 96px;
height: 96px;
object-fit: contain;
animation: heroLogoScale 0.25s ease forwards;
}
.iconLabel {
font-family:
"Inter",
system-ui,
-apple-system,
"Segoe UI",
Roboto,
Arial,
sans-serif;
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
text-align: center;
animation: heroLogoEnter 0.25s ease forwards;
}
.title {
text-align: center;
opacity: 0;
transform: translateX(24px);
animation: bodySlideIn 0.25s ease forwards;
}
.bodyCopy {
opacity: 0;
transform: translateX(24px);
animation: bodySlideIn 0.25s ease forwards;
}
@keyframes heroLogoEnter {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes heroLogoScale {
from {
transform: scale(0.6);
}
to {
transform: scale(1);
}
}
@keyframes heroLogoRotate {
from {
transform: rotate(-90deg) scale(0.9);
}
to {
transform: rotate(0deg) scale(1);
}
}
@keyframes bodySlideIn {
from {
opacity: 0;
transform: translateX(24px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Dev overlay styles */
.devOverlay {
position: absolute;
bottom: 8px;
left: 10px;
display: flex;
gap: 6px;
z-index: 10;
}
.devButton {
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
cursor: pointer;
opacity: 0.8;
border: 1px solid rgba(255, 255, 255, 0.4);
background: rgba(0, 0, 0, 0.35);
color: #fff;
box-shadow: none;
}
.devButtonActive {
opacity: 1;
border: 1px solid rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.9);
color: #1f2933;
box-shadow: 0 0 8px rgba(255, 255, 255, 0.7);
}
/* Modal content styles */
.modalContent {
background: var(--bg-surface);
position: relative;
}
.modalBody {
padding: 24px;
}
/* Title styles */
.titleText {
font-family:
"Inter",
system-ui,
-apple-system,
"Segoe UI",
Roboto,
Arial,
sans-serif;
font-weight: 600;
font-size: 22px;
color: var(--onboarding-title);
}
/* Body text styles */
.bodyText {
font-family:
"Inter",
system-ui,
-apple-system,
"Segoe UI",
Roboto,
Arial,
sans-serif;
font-size: 16px;
color: var(--onboarding-body);
line-height: 1.5;
}
.bodyCopyInner {
color: inherit;
}
/* Button margin */
.buttonContainer {
margin-top: 8px;
}
/* Welcome slide V2 badge */
.welcomeTitleContainer {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
}
.v2Badge {
background: #dbefff;
color: #2a4bff;
padding: 4px 12px;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
}
/* Icon styles */
.heroIcon {
color: #000000;
}
@@ -0,0 +1,127 @@
import React from "react";
import { Button, Group, ActionIcon } from "@mantine/core";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import { useTranslation } from "react-i18next";
import {
ButtonDefinition,
type FlowState,
} from "@app/components/onboarding/onboardingFlowConfig";
import type { LicenseNotice } from "@app/types/types";
import type { ButtonAction } from "@app/components/onboarding/onboardingFlowConfig";
interface SlideButtonsProps {
slideDefinition: {
buttons: ButtonDefinition[];
id: string;
};
licenseNotice: LicenseNotice;
flowState: FlowState;
onAction: (action: ButtonAction) => void;
}
export function SlideButtons({
slideDefinition,
licenseNotice,
flowState,
onAction,
}: SlideButtonsProps) {
const { t } = useTranslation();
const leftButtons = slideDefinition.buttons.filter(
(btn) => btn.group === "left",
);
const rightButtons = slideDefinition.buttons.filter(
(btn) => btn.group === "right",
);
const buttonStyles = (variant: ButtonDefinition["variant"]) =>
variant === "primary"
? {
root: {
background: "var(--onboarding-primary-button-bg)",
color: "var(--onboarding-primary-button-text)",
},
}
: {
root: {
background: "var(--onboarding-secondary-button-bg)",
border: "1px solid var(--onboarding-secondary-button-border)",
color: "var(--onboarding-secondary-button-text)",
},
};
const resolveButtonLabel = (button: ButtonDefinition) => {
// Special case: override "See Plans" with "Upgrade now" when over limit
if (
button.type === "button" &&
slideDefinition.id === "server-license" &&
button.action === "see-plans" &&
licenseNotice.isOverLimit
) {
return t("onboarding.serverLicense.upgrade", "Upgrade now →");
}
// Translate the label (it's a translation key)
const label = button.label ?? "";
if (!label) return "";
// Extract fallback text from translation key (e.g., 'onboarding.buttons.next' -> 'Next')
const fallback = label.split(".").pop() || label;
return t(label, fallback);
};
const renderButton = (button: ButtonDefinition) => {
const disabled = button.disabledWhen?.(flowState) ?? false;
if (button.type === "icon") {
return (
<ActionIcon
key={button.key}
onClick={() => onAction(button.action)}
radius="md"
size={40}
disabled={disabled}
styles={{
root: {
background: "var(--onboarding-secondary-button-bg)",
border: "1px solid var(--onboarding-secondary-button-border)",
color: "var(--onboarding-secondary-button-text)",
},
}}
>
{button.icon === "chevron-left" && (
<ChevronLeftIcon fontSize="small" />
)}
</ActionIcon>
);
}
const variant = button.variant ?? "secondary";
const label = resolveButtonLabel(button);
return (
<Button
key={button.key}
onClick={() => onAction(button.action)}
disabled={disabled}
styles={buttonStyles(variant)}
>
{label}
</Button>
);
};
if (leftButtons.length === 0) {
return <Group justify="flex-end">{rightButtons.map(renderButton)}</Group>;
}
if (rightButtons.length === 0) {
return <Group justify="flex-start">{leftButtons.map(renderButton)}</Group>;
}
return (
<Group justify="space-between">
<Group gap={12}>{leftButtons.map(renderButton)}</Group>
<Group gap={12}>{rightButtons.map(renderButton)}</Group>
</Group>
);
}
@@ -0,0 +1,614 @@
import { useEffect, useMemo, useCallback, useState } from "react";
import { type StepType } from "@reactour/tour";
import { useTranslation } from "react-i18next";
import { useNavigate, useLocation } from "react-router-dom";
import { isAuthRoute } from "@app/constants/routes";
import { dispatchTourState } from "@app/constants/events";
import { useOnboardingOrchestrator } from "@app/components/onboarding/orchestrator/useOnboardingOrchestrator";
import { useBypassOnboarding } from "@app/components/onboarding/useBypassOnboarding";
import OnboardingTour, {
type AdvanceArgs,
type CloseArgs,
} from "@app/components/onboarding/OnboardingTour";
import OnboardingModalSlide from "@app/components/onboarding/OnboardingModalSlide";
import {
useServerLicenseRequest,
useTourRequest,
} from "@app/components/onboarding/useOnboardingEffects";
import { useOnboardingDownload } from "@app/components/onboarding/useOnboardingDownload";
import {
SLIDE_DEFINITIONS,
type SlideId,
type ButtonAction,
} from "@app/components/onboarding/onboardingFlowConfig";
import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt";
import { useTourOrchestration } from "@app/contexts/TourOrchestrationContext";
import { useAdminTourOrchestration } from "@app/contexts/AdminTourOrchestrationContext";
import { createUserStepsConfig } from "@app/components/onboarding/userStepsConfig";
import { createAdminStepsConfig } from "@app/components/onboarding/adminStepsConfig";
import { createWhatsNewStepsConfig } from "@app/components/onboarding/whatsNewStepsConfig";
import { removeAllGlows } from "@app/components/onboarding/tourGlow";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import { useServerExperience } from "@app/hooks/useServerExperience";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import apiClient from "@app/services/apiClient";
import "@app/components/onboarding/OnboardingTour.css";
import { useAccountLogout } from "@app/extensions/accountLogout";
import { useAuth } from "@app/auth/UseSession";
export default function Onboarding() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const bypassOnboarding = useBypassOnboarding();
const { state, actions } = useOnboardingOrchestrator();
const serverExperience = useServerExperience();
const onAuthRoute = isAuthRoute(location.pathname);
const { currentStep, isActive, isLoading, runtimeState, activeFlow } = state;
const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } =
useOnboardingDownload();
const {
showLicenseSlide,
licenseNotice: externalLicenseNotice,
closeLicenseSlide,
} = useServerLicenseRequest();
const {
tourRequested: externalTourRequested,
requestedTourType,
clearTourRequest,
} = useTourRequest();
const { config, refetch: refetchConfig } = useAppConfig();
const [analyticsError, setAnalyticsError] = useState<string | null>(null);
const [analyticsLoading, setAnalyticsLoading] = useState(false);
const [showAnalyticsModal, setShowAnalyticsModal] = useState(false);
const [analyticsModalDismissed, setAnalyticsModalDismissed] = useState(false);
const [firstLoginModalOpen, setFirstLoginModalOpen] = useState(false);
const [mfaModalOpen, setMfaModalOpen] = useState(false);
const accountLogout = useAccountLogout();
const { signOut } = useAuth();
const handleRoleSelect = useCallback(
(role: "admin" | "user" | null) => {
actions.updateRuntimeState({ selectedRole: role });
serverExperience.setSelfReportedAdmin(role === "admin");
},
[actions, serverExperience],
);
const redirectToLogin = useCallback(() => {
window.location.assign("/login");
}, []);
const handlePasswordChanged = useCallback(async () => {
actions.updateRuntimeState({ requiresPasswordChange: false });
// delete session and redirect to login page
await accountLogout({ signOut, redirectToLogin });
}, [actions, accountLogout, redirectToLogin, signOut]);
const handleMfaSetupComplete = useCallback(() => {
actions.updateRuntimeState({ requiresMfaSetup: false });
setMfaModalOpen(false);
actions.complete();
}, [actions]);
// Check if we should show analytics modal before onboarding
useEffect(() => {
if (
!isLoading &&
!analyticsModalDismissed &&
serverExperience.effectiveIsAdmin &&
config?.enableAnalytics == null
) {
setShowAnalyticsModal(true);
}
}, [
isLoading,
analyticsModalDismissed,
serverExperience.effectiveIsAdmin,
config?.enableAnalytics,
]);
const handleAnalyticsChoice = useCallback(
async (enableAnalytics: boolean) => {
if (analyticsLoading) return;
setAnalyticsLoading(true);
setAnalyticsError(null);
const formData = new FormData();
formData.append("enabled", enableAnalytics.toString());
try {
await apiClient.post(
"/api/v1/settings/update-enable-analytics",
formData,
);
await refetchConfig();
setShowAnalyticsModal(false);
setAnalyticsModalDismissed(true);
} catch (error) {
setAnalyticsError(
error instanceof Error ? error.message : "Unknown error",
);
} finally {
setAnalyticsLoading(false);
}
},
[analyticsLoading, refetchConfig],
);
const handleButtonAction = useCallback(
async (action: ButtonAction) => {
switch (action) {
case "next":
case "complete-close":
actions.complete();
break;
case "prev":
actions.prev();
break;
case "close":
actions.skip();
break;
case "download-selected":
handleDownloadSelected();
actions.complete();
break;
case "security-next":
if (!runtimeState.selectedRole) return;
if (runtimeState.selectedRole !== "admin") {
actions.updateRuntimeState({ tourType: "whatsnew" });
setIsTourOpen(true);
}
actions.complete();
break;
case "launch-admin":
actions.updateRuntimeState({ tourType: "admin" });
setIsTourOpen(true);
break;
case "launch-tools":
actions.updateRuntimeState({ tourType: "whatsnew" });
setIsTourOpen(true);
break;
case "launch-auto": {
const tourType =
serverExperience.effectiveIsAdmin ||
runtimeState.selectedRole === "admin"
? "admin"
: "whatsnew";
actions.updateRuntimeState({ tourType });
setIsTourOpen(true);
break;
}
case "skip-to-license":
actions.complete();
break;
case "skip-tour":
actions.complete();
break;
case "see-plans":
actions.complete();
navigate("/settings/adminPlan");
break;
case "enable-analytics":
await handleAnalyticsChoice(true);
break;
case "disable-analytics":
await handleAnalyticsChoice(false);
break;
}
},
[
actions,
handleAnalyticsChoice,
handleDownloadSelected,
navigate,
runtimeState.selectedRole,
serverExperience.effectiveIsAdmin,
],
);
const isRTL =
typeof document !== "undefined"
? document.documentElement.dir === "rtl"
: false;
const [isTourOpen, setIsTourOpen] = useState(false);
useEffect(() => dispatchTourState(isTourOpen), [isTourOpen]);
const { openFilesModal, closeFilesModal } = useFilesModalContext();
const tourOrch = useTourOrchestration();
const adminTourOrch = useAdminTourOrchestration();
const userStepsConfig = useMemo(
() =>
createUserStepsConfig({
t,
actions: {
saveWorkbenchState: tourOrch.saveWorkbenchState,
closeFilesModal,
backToAllTools: tourOrch.backToAllTools,
selectCropTool: tourOrch.selectCropTool,
loadSampleFile: tourOrch.loadSampleFile,
switchToActiveFiles: tourOrch.switchToActiveFiles,
pinFile: tourOrch.pinFile,
revealFileCardHoverMenu: tourOrch.revealFileCardHoverMenu,
modifyCropSettings: tourOrch.modifyCropSettings,
executeTool: tourOrch.executeTool,
openFilesModal,
openSettingsHelpSection: () =>
adminTourOrch.navigateToSection("help"),
},
}),
[t, tourOrch, adminTourOrch, closeFilesModal, openFilesModal],
);
const whatsNewStepsConfig = useMemo(
() =>
createWhatsNewStepsConfig({
t,
actions: {
saveWorkbenchState: tourOrch.saveWorkbenchState,
closeFilesModal,
backToAllTools: tourOrch.backToAllTools,
openFilesModal,
loadSampleFile: tourOrch.loadSampleFile,
switchToViewer: tourOrch.switchToViewer,
switchToPageEditor: tourOrch.switchToPageEditor,
switchToActiveFiles: tourOrch.switchToActiveFiles,
},
}),
[t, tourOrch, closeFilesModal, openFilesModal],
);
const adminStepsConfig = useMemo(
() =>
createAdminStepsConfig({
t,
actions: {
saveAdminState: adminTourOrch.saveAdminState,
openConfigModal: adminTourOrch.openConfigModal,
navigateToSection: adminTourOrch.navigateToSection,
scrollNavToSection: adminTourOrch.scrollNavToSection,
},
}),
[t, adminTourOrch],
);
const tourSteps = useMemo<StepType[]>(() => {
switch (runtimeState.tourType) {
case "admin":
return Object.values(adminStepsConfig);
case "whatsnew":
return Object.values(whatsNewStepsConfig);
default:
return Object.values(userStepsConfig);
}
}, [
adminStepsConfig,
runtimeState.tourType,
userStepsConfig,
whatsNewStepsConfig,
]);
useEffect(() => {
if (externalTourRequested) {
actions.updateRuntimeState({ tourType: requestedTourType });
setIsTourOpen(true);
clearTourRequest();
}
}, [externalTourRequested, requestedTourType, actions, clearTourRequest]);
useEffect(() => {
if (!isTourOpen) removeAllGlows();
return () => removeAllGlows();
}, [isTourOpen]);
// Handle first-login password change modal
useEffect(() => {
if (runtimeState.requiresPasswordChange === true) {
console.log("[Onboarding] User requires password change on first login.");
setFirstLoginModalOpen(true);
} else {
setFirstLoginModalOpen(false);
}
}, [runtimeState.requiresPasswordChange]);
// Handle MFA setup modal
useEffect(() => {
if (runtimeState.requiresMfaSetup === true) {
console.log("[Onboarding] User requires MFA setup.");
setMfaModalOpen(true);
} else {
console.log("[Onboarding] User does not require MFA setup.");
setMfaModalOpen(false);
}
}, [runtimeState.requiresMfaSetup]);
const finishTour = useCallback(() => {
setIsTourOpen(false);
if (runtimeState.tourType === "admin") {
adminTourOrch.restoreAdminState();
} else {
tourOrch.restoreWorkbenchState();
}
// Advance to next onboarding step after tour completes
actions.complete();
}, [actions, adminTourOrch, runtimeState.tourType, tourOrch]);
const handleAdvanceTour = useCallback(
(args: AdvanceArgs) => {
const {
setCurrentStep,
currentStep: tourCurrentStep,
steps,
setIsOpen,
} = args;
if (steps && tourCurrentStep === steps.length - 1) {
setIsOpen(false);
finishTour();
} else if (steps) {
setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1));
}
},
[finishTour],
);
const handleCloseTour = useCallback(
(args: CloseArgs) => {
args.setIsOpen(false);
finishTour();
},
[finishTour],
);
const currentSlideDefinition = useMemo(() => {
if (
!currentStep ||
currentStep.type !== "modal-slide" ||
!currentStep.slideId
) {
return null;
}
return SLIDE_DEFINITIONS[currentStep.slideId as SlideId];
}, [currentStep]);
const currentSlideContent = useMemo(() => {
if (!currentSlideDefinition) return null;
return currentSlideDefinition.createSlide({
osLabel: osInfo.label,
osUrl: osInfo.url,
osOptions,
onDownloadUrlChange: setSelectedDownloadUrl,
selectedRole: runtimeState.selectedRole,
onRoleSelect: handleRoleSelect,
licenseNotice: runtimeState.licenseNotice,
loginEnabled: serverExperience.loginEnabled,
firstLoginUsername: runtimeState.firstLoginUsername,
onPasswordChanged: handlePasswordChanged,
usingDefaultCredentials: runtimeState.usingDefaultCredentials,
analyticsError,
analyticsLoading,
onMfaSetupComplete: handleMfaSetupComplete,
});
}, [
analyticsError,
analyticsLoading,
currentSlideDefinition,
osInfo,
osOptions,
runtimeState.selectedRole,
runtimeState.licenseNotice,
handleRoleSelect,
serverExperience.loginEnabled,
setSelectedDownloadUrl,
runtimeState.firstLoginUsername,
handlePasswordChanged,
handleMfaSetupComplete,
]);
const modalSlideCount = useMemo(() => {
return activeFlow.filter((step) => step.type === "modal-slide").length;
}, [activeFlow]);
const currentModalSlideIndex = useMemo(() => {
if (!currentStep || currentStep.type !== "modal-slide") return 0;
const modalSlides = activeFlow.filter(
(step) => step.type === "modal-slide",
);
return modalSlides.findIndex((step) => step.id === currentStep.id);
}, [activeFlow, currentStep]);
if (bypassOnboarding) {
return null;
}
if (onAuthRoute) {
return null;
}
// Show analytics modal before onboarding if needed
if (showAnalyticsModal) {
const slideDefinition = SLIDE_DEFINITIONS["analytics-choice"];
const slideContent = slideDefinition.createSlide({
osLabel: "",
osUrl: "",
selectedRole: null,
onRoleSelect: () => {},
analyticsError,
analyticsLoading,
});
return (
<OnboardingModalSlide
slideDefinition={slideDefinition}
slideContent={slideContent}
runtimeState={runtimeState}
modalSlideCount={1}
currentModalSlideIndex={0}
onSkip={() => {}} // No skip allowed
onAction={async (action) => {
if (action === "enable-analytics") {
await handleAnalyticsChoice(true);
} else if (action === "disable-analytics") {
await handleAnalyticsChoice(false);
}
}}
allowDismiss={false}
/>
);
}
if (firstLoginModalOpen) {
const baseSlideDefinition = SLIDE_DEFINITIONS["first-login"];
const slideContent = baseSlideDefinition.createSlide({
osLabel: "",
osUrl: "",
selectedRole: null,
onRoleSelect: () => {},
firstLoginUsername: runtimeState.firstLoginUsername,
onPasswordChanged: handlePasswordChanged,
usingDefaultCredentials: runtimeState.usingDefaultCredentials,
});
return (
<OnboardingModalSlide
slideDefinition={baseSlideDefinition}
slideContent={slideContent}
runtimeState={runtimeState}
modalSlideCount={1}
currentModalSlideIndex={0}
onSkip={() => {}}
onAction={async (action) => {
if (action === "complete-close") {
handlePasswordChanged();
}
}}
allowDismiss={false}
/>
);
}
if (mfaModalOpen) {
console.log("[Onboarding] Rendering MFA setup modal slide.");
const baseSlideDefinition = SLIDE_DEFINITIONS["mfa-setup"];
const slideContent = baseSlideDefinition.createSlide({
osLabel: "",
osUrl: "",
selectedRole: null,
onRoleSelect: () => {},
onMfaSetupComplete: handleMfaSetupComplete,
});
return (
<OnboardingModalSlide
slideDefinition={baseSlideDefinition}
slideContent={slideContent}
runtimeState={runtimeState}
modalSlideCount={1}
currentModalSlideIndex={0}
onSkip={() => {}}
onAction={async (action) => {
if (action === "complete-close") {
handleMfaSetupComplete();
}
}}
allowDismiss={false}
/>
);
}
if (showLicenseSlide) {
const baseSlideDefinition = SLIDE_DEFINITIONS["server-license"];
// Remove back button for external license notice
const slideDefinition = {
...baseSlideDefinition,
buttons: baseSlideDefinition.buttons.filter(
(btn) => btn.key !== "license-back",
),
};
const effectiveLicenseNotice =
externalLicenseNotice || runtimeState.licenseNotice;
const slideContent = slideDefinition.createSlide({
osLabel: "",
osUrl: "",
osOptions: [],
onDownloadUrlChange: () => {},
selectedRole: null,
onRoleSelect: () => {},
licenseNotice: effectiveLicenseNotice,
loginEnabled: serverExperience.loginEnabled,
});
return (
<OnboardingModalSlide
slideDefinition={slideDefinition}
slideContent={slideContent}
runtimeState={{
...runtimeState,
licenseNotice: effectiveLicenseNotice,
}}
modalSlideCount={1}
currentModalSlideIndex={0}
onSkip={closeLicenseSlide}
onAction={(action) => {
if (action === "see-plans") {
closeLicenseSlide();
navigate("/settings/adminPlan");
} else {
closeLicenseSlide();
}
}}
/>
);
}
// Always render the tour component (it controls its own visibility with isOpen)
const tourComponent = (
<OnboardingTour
isOpen={isTourOpen}
tourSteps={tourSteps}
tourType={runtimeState.tourType}
isRTL={isRTL}
t={t}
onAdvance={handleAdvanceTour}
onClose={handleCloseTour}
/>
);
// If no active onboarding, just show the tour (which may or may not be open)
if (isLoading || !isActive || !currentStep) {
return tourComponent;
}
// If tour is open, hide the onboarding modal and just show the tour
if (isTourOpen) {
return tourComponent;
}
// Render the current onboarding step
switch (currentStep.type) {
case "tool-prompt":
return (
<ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />
);
case "modal-slide":
if (!currentSlideDefinition || !currentSlideContent) return null;
return (
<OnboardingModalSlide
slideDefinition={currentSlideDefinition}
slideContent={currentSlideContent}
runtimeState={runtimeState}
modalSlideCount={modalSlideCount}
currentModalSlideIndex={currentModalSlideIndex}
onSkip={actions.skip}
onAction={handleButtonAction}
allowDismiss={currentStep.allowDismiss}
/>
);
default:
return null;
}
}
@@ -0,0 +1,211 @@
/**
* OnboardingModalSlide Component
*
* Renders a single modal slide in the onboarding flow.
* Handles the hero image, content, stepper, and button actions.
*/
import React from "react";
import { Modal, Stack, ActionIcon } from "@mantine/core";
import DiamondOutlinedIcon from "@mui/icons-material/DiamondOutlined";
import CloseIcon from "@mui/icons-material/Close";
import type {
SlideDefinition,
ButtonAction,
} from "@app/components/onboarding/onboardingFlowConfig";
import type { OnboardingRuntimeState } from "@app/components/onboarding/orchestrator/onboardingConfig";
import type { SlideConfig } from "@app/types/types";
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
import OnboardingStepper from "@app/components/onboarding/OnboardingStepper";
import { SlideButtons } from "@app/components/onboarding/InitialOnboardingModal/renderButtons";
import LocalIcon from "@app/components/shared/LocalIcon";
import { BASE_PATH } from "@app/constants/app";
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex";
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
interface OnboardingModalSlideProps {
slideDefinition: SlideDefinition;
slideContent: SlideConfig;
runtimeState: OnboardingRuntimeState;
modalSlideCount: number;
currentModalSlideIndex: number;
onSkip: () => void;
onAction: (action: ButtonAction) => void;
allowDismiss?: boolean;
}
export default function OnboardingModalSlide({
slideDefinition,
slideContent,
runtimeState,
modalSlideCount,
currentModalSlideIndex,
onSkip,
onAction,
allowDismiss = true,
}: OnboardingModalSlideProps) {
const renderHero = () => {
if (slideDefinition.hero.type === "dual-icon") {
return (
<div className={styles.heroIconsContainer}>
<div className={styles.iconWrapper}>
<img
src={`${BASE_PATH}/modern-logo/logo512.png`}
alt="Stirling icon"
className={styles.downloadIcon}
/>
</div>
</div>
);
}
return (
<div className={styles.heroLogoCircle}>
{slideDefinition.hero.type === "rocket" && (
<LocalIcon
icon="rocket-launch"
width={64}
height={64}
className={styles.heroIcon}
/>
)}
{slideDefinition.hero.type === "shield" && (
<LocalIcon
icon="verified-user-outline"
width={64}
height={64}
className={styles.heroIcon}
/>
)}
{slideDefinition.hero.type === "lock" && (
<LocalIcon
icon="lock-outline"
width={64}
height={64}
className={styles.heroIcon}
/>
)}
{slideDefinition.hero.type === "analytics" && (
<LocalIcon
icon="analytics"
width={64}
height={64}
className={styles.heroIcon}
/>
)}
{slideDefinition.hero.type === "diamond" && (
<DiamondOutlinedIcon sx={{ fontSize: 64, color: "#000000" }} />
)}
{slideDefinition.hero.type === "logo" && (
<img
src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`}
alt="Stirling logo"
/>
)}
</div>
);
};
return (
<Modal
opened={true}
onClose={onSkip}
closeOnClickOutside={false}
closeOnEscape={allowDismiss}
centered
size="lg"
radius="lg"
withCloseButton={false}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
styles={{
body: { padding: 0, maxHeight: "90vh", overflow: "hidden" },
content: {
overflow: "hidden",
border: "none",
background: "var(--bg-surface)",
maxHeight: "90vh",
},
}}
>
<Stack gap={0} className={styles.modalContent}>
<div className={styles.heroWrapper}>
<AnimatedSlideBackground
gradientStops={slideContent.background.gradientStops}
circles={slideContent.background.circles}
isActive
slideKey={slideContent.key}
/>
{allowDismiss && (
<ActionIcon
onClick={onSkip}
radius="md"
size={36}
style={{
position: "absolute",
top: 16,
right: 16,
backgroundColor: "rgba(255, 255, 255, 0.2)",
color: "white",
backdropFilter: "blur(4px)",
zIndex: 10,
}}
styles={{
root: {
"&:hover": {
backgroundColor: "rgba(255, 255, 255, 0.3)",
},
},
}}
>
<CloseIcon fontSize="small" />
</ActionIcon>
)}
<div className={styles.heroLogo} key={`logo-${slideContent.key}`}>
{renderHero()}
</div>
</div>
<div
className={styles.modalBody}
style={{ overflowY: "auto", maxHeight: "calc(90vh - 220px)" }}
>
<Stack gap={16}>
<div
key={`title-${slideContent.key}`}
className={`${styles.title} ${styles.titleText}`}
>
{slideContent.title}
</div>
<div className={styles.bodyText}>
<div
key={`body-${slideContent.key}`}
className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}
>
{slideContent.body}
</div>
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
</div>
{modalSlideCount > 1 && (
<OnboardingStepper
totalSteps={modalSlideCount}
activeStep={currentModalSlideIndex}
/>
)}
<div className={styles.buttonContainer}>
<SlideButtons
slideDefinition={slideDefinition}
licenseNotice={runtimeState.licenseNotice}
flowState={{ selectedRole: runtimeState.selectedRole }}
onAction={onAction}
/>
</div>
</Stack>
</div>
</Stack>
</Modal>
);
}
@@ -0,0 +1,54 @@
import React from "react";
interface OnboardingStepperProps {
totalSteps: number;
activeStep: number; // 0-indexed
className?: string;
}
/**
* Renders a progress indicator where the active step is a pill and others are dots.
* Colors come from theme.css variables.
*/
export function OnboardingStepper({
totalSteps,
activeStep,
className,
}: OnboardingStepperProps) {
const items = Array.from({ length: totalSteps }, (_, index) => index);
return (
<div
className={className}
style={{
display: "flex",
gap: 8,
alignItems: "center",
justifyContent: "center",
}}
>
{items.map((index) => {
const isActive = index === activeStep;
const baseStyles: React.CSSProperties = {
background: isActive
? "var(--onboarding-step-active)"
: "var(--onboarding-step-inactive)",
};
return (
<div
key={index}
style={{
...baseStyles,
width: isActive ? 44 : 8,
height: 8,
borderRadius: 9999,
}}
/>
);
})}
</div>
);
}
export default OnboardingStepper;
@@ -0,0 +1,46 @@
/* Glow effect for tour highlighted area */
.tour-highlight-glow {
stroke: var(--mantine-primary-color-filled);
stroke-width: 3px;
rx: 8px;
ry: 8px;
filter: drop-shadow(0 0 10px var(--mantine-primary-color-filled));
}
/* Add glowing border to navigation items during admin tour */
.modal-nav-item.tour-nav-glow {
position: relative;
box-shadow:
0 0 0 2px var(--mantine-primary-color-filled),
0 0 15px var(--mantine-primary-color-filled),
inset 0 0 15px rgba(59, 130, 246, 0.1);
border-radius: 8px;
}
@keyframes pulse-glow {
0%,
100% {
box-shadow:
0 0 0 3px var(--mantine-primary-color-filled),
0 0 20px var(--mantine-primary-color-filled),
inset 0 0 20px rgba(59, 130, 246, 0.1);
}
50% {
box-shadow:
0 0 0 3px var(--mantine-primary-color-filled),
0 0 30px var(--mantine-primary-color-filled),
inset 0 0 30px rgba(59, 130, 246, 0.2);
}
}
/* RTL: mirror step indicator and controls in Reactour popovers */
:root[dir="rtl"] .reactour__popover {
direction: rtl;
}
/* Minimal overrides retained for glow only */
:root[dir="rtl"] .reactour__badge {
left: auto;
right: 16px;
}
@@ -0,0 +1,173 @@
/**
* OnboardingTour Component
*
* Reusable tour wrapper that encapsulates all Reactour configuration.
* Used by the main Onboarding component for both the 'tour' step and
* when the tour is open but onboarding is inactive.
*/
import React from "react";
import { TourProvider, useTour, type StepType } from "@reactour/tour";
import { CloseButton, ActionIcon } from "@mantine/core";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import CheckIcon from "@mui/icons-material/Check";
import type { TFunction } from "i18next";
import i18n from "@app/i18n";
/**
* TourContent - Controls the tour visibility
* Syncs the forceOpen prop with the reactour tour state.
*/
function TourContent({ forceOpen = false }: { forceOpen?: boolean }) {
const { setIsOpen, setCurrentStep } = useTour();
const previousIsOpenRef = React.useRef(forceOpen);
React.useEffect(() => {
const wasClosedNowOpen = !previousIsOpenRef.current && forceOpen;
previousIsOpenRef.current = forceOpen;
if (wasClosedNowOpen) {
setCurrentStep(0);
}
setIsOpen(forceOpen);
}, [forceOpen, setIsOpen, setCurrentStep]);
return null;
}
interface AdvanceArgs {
setCurrentStep: (value: number | ((prev: number) => number)) => void;
currentStep: number;
steps?: StepType[];
setIsOpen: (value: boolean) => void;
}
interface CloseArgs {
setIsOpen: (value: boolean) => void;
}
interface OnboardingTourProps {
tourSteps: StepType[];
tourType: "admin" | "tools" | "whatsnew";
isRTL: boolean;
t: TFunction;
isOpen: boolean;
onAdvance: (args: AdvanceArgs) => void;
onClose: (args: CloseArgs) => void;
}
export default function OnboardingTour({
tourSteps,
tourType,
isRTL,
t,
isOpen,
onAdvance,
onClose,
}: OnboardingTourProps) {
if (!isOpen) return null;
return (
<TourProvider
key={`${tourType}-${i18n.language}`}
steps={tourSteps}
maskClassName={tourType === "admin" ? "admin-tour-mask" : undefined}
onClickClose={onClose}
onClickMask={onAdvance}
onClickHighlighted={(e, clickProps) => {
e.stopPropagation();
onAdvance(clickProps);
}}
keyboardHandler={(e, clickProps, status) => {
if (e.key === "ArrowRight" && !status?.isRightDisabled && clickProps) {
e.preventDefault();
onAdvance(clickProps);
} else if (e.key === "Escape" && !status?.isEscDisabled && clickProps) {
e.preventDefault();
onClose(clickProps);
}
}}
rtl={isRTL}
styles={{
popover: (base) => ({
...base,
backgroundColor: "var(--mantine-color-body)",
color: "var(--mantine-color-text)",
borderRadius: "8px",
padding: "20px",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
maxWidth: "400px",
}),
maskArea: (base) => ({
...base,
rx: 8,
}),
badge: (base) => ({
...base,
backgroundColor: "var(--mantine-primary-color-filled)",
}),
controls: (base) => ({
...base,
justifyContent: "center",
}),
}}
highlightedMaskClassName="tour-highlight-glow"
showNavigation={true}
showBadge={false}
showCloseButton={true}
disableInteraction={true}
disableDotsNavigation={false}
prevButton={() => null}
nextButton={({
currentStep: tourCurrentStep,
stepsLength,
setCurrentStep,
setIsOpen,
}) => {
const isLast = tourCurrentStep === stepsLength - 1;
const ArrowIcon = isRTL ? ArrowBackIcon : ArrowForwardIcon;
return (
<ActionIcon
onClick={() =>
onAdvance({
setCurrentStep,
currentStep: tourCurrentStep,
steps: tourSteps,
setIsOpen,
})
}
variant="subtle"
size="lg"
aria-label={
isLast
? t("onboarding.finish", "Finish")
: t("onboarding.next", "Next")
}
>
{isLast ? <CheckIcon /> : <ArrowIcon />}
</ActionIcon>
);
}}
components={{
Close: ({ onClick }) => (
<CloseButton
onClick={onClick}
size="md"
style={{ position: "absolute", top: "8px", right: "8px" }}
/>
),
Content: ({ content }: { content: string }) => (
<div
style={{ paddingRight: "16px" }}
dangerouslySetInnerHTML={{ __html: content }}
/>
),
}}
>
<TourContent forceOpen={true} />
</TourProvider>
);
}
export type { AdvanceArgs, CloseArgs };
@@ -0,0 +1,226 @@
import type { StepType } from "@reactour/tour";
import type { TFunction } from "i18next";
import {
addGlowToElements,
removeAllGlows,
} from "@app/components/onboarding/tourGlow";
import {
waitForElement,
waitForHighlightable,
} from "@app/components/onboarding/tourUtils";
export enum AdminTourStep {
WELCOME,
CONFIG_BUTTON,
SETTINGS_OVERVIEW,
TEAMS_AND_USERS,
SYSTEM_CUSTOMIZATION,
DATABASE_SECTION,
CONNECTIONS_SECTION,
ADMIN_TOOLS,
WRAP_UP,
}
interface AdminStepActions {
saveAdminState: () => void;
openConfigModal: () => void;
navigateToSection: (section: string) => void;
scrollNavToSection: (section: string) => Promise<void> | void;
}
interface CreateAdminStepsConfigArgs {
t: TFunction;
actions: AdminStepActions;
}
export function createAdminStepsConfig({
t,
actions,
}: CreateAdminStepsConfigArgs): Record<AdminTourStep, StepType> {
const {
saveAdminState,
openConfigModal,
navigateToSection,
scrollNavToSection,
} = actions;
return {
[AdminTourStep.WELCOME]: {
selector: '[data-tour="config-button"]',
content: t(
"adminOnboarding.welcome",
"Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators.",
),
position: "right",
padding: 10,
action: () => {
saveAdminState();
},
},
[AdminTourStep.CONFIG_BUTTON]: {
selector: '[data-tour="config-button"]',
content: t(
"adminOnboarding.configButton",
"Open <strong>Settings</strong> to access all system configuration and administrative controls.",
),
position: "right",
padding: 10,
actionAfter: () => {
openConfigModal();
},
},
[AdminTourStep.SETTINGS_OVERVIEW]: {
selector: ".modal-nav",
content: t(
"adminOnboarding.settingsOverview",
"This is the <strong>Settings Panel</strong>. Admin settings are organised by category for easy navigation.",
),
position: "right",
padding: 0,
action: () => {
removeAllGlows();
},
},
[AdminTourStep.TEAMS_AND_USERS]: {
selector: '[data-tour="admin-people-nav"]',
highlightedSelectors: [
'[data-tour="admin-people-nav"]',
'[data-tour="admin-teams-nav"]',
'[data-tour="settings-content-area"]',
],
content: t(
"adminOnboarding.teamsAndUsers",
"Manage <strong>Teams</strong> and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself.",
),
position: "right",
padding: 10,
action: () => {
removeAllGlows();
navigateToSection("people");
setTimeout(() => {
addGlowToElements([
'[data-tour="admin-people-nav"]',
'[data-tour="admin-teams-nav"]',
'[data-tour="settings-content-area"]',
]);
}, 100);
},
},
[AdminTourStep.SYSTEM_CUSTOMIZATION]: {
selector: '[data-tour="admin-adminGeneral-nav"]',
highlightedSelectors: [
'[data-tour="admin-adminGeneral-nav"]',
'[data-tour="admin-adminFeatures-nav"]',
'[data-tour="admin-adminEndpoints-nav"]',
'[data-tour="settings-content-area"]',
],
content: t(
"adminOnboarding.systemCustomization",
"We have extensive ways to customise the UI: <strong>System Settings</strong> let you change the app name and languages, <strong>Features</strong> allows server certificate management, and <strong>Endpoints</strong> lets you enable or disable specific tools for your users.",
),
position: "right",
padding: 10,
action: () => {
removeAllGlows();
navigateToSection("adminGeneral");
setTimeout(() => {
addGlowToElements([
'[data-tour="admin-adminGeneral-nav"]',
'[data-tour="admin-adminFeatures-nav"]',
'[data-tour="admin-adminEndpoints-nav"]',
'[data-tour="settings-content-area"]',
]);
}, 100);
},
},
[AdminTourStep.DATABASE_SECTION]: {
selector: '[data-tour="admin-adminDatabase-nav"]',
highlightedSelectors: [
'[data-tour="admin-adminDatabase-nav"]',
'[data-tour="settings-content-area"]',
],
content: t(
"adminOnboarding.databaseSection",
"For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure.",
),
position: "right",
padding: 10,
action: () => {
removeAllGlows();
navigateToSection("adminDatabase");
setTimeout(() => {
addGlowToElements([
'[data-tour="admin-adminDatabase-nav"]',
'[data-tour="settings-content-area"]',
]);
}, 100);
},
},
[AdminTourStep.CONNECTIONS_SECTION]: {
selector: '[data-tour="admin-adminConnections-nav"]',
highlightedSelectors: [
'[data-tour="admin-adminConnections-nav"]',
'[data-tour="settings-content-area"]',
],
content: t(
"adminOnboarding.connectionsSection",
"The <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications.",
),
position: "right",
padding: 10,
action: () => {
removeAllGlows();
navigateToSection("adminConnections");
setTimeout(() => {
addGlowToElements([
'[data-tour="admin-adminConnections-nav"]',
'[data-tour="settings-content-area"]',
]);
}, 100);
},
actionAfter: async () => {
await scrollNavToSection("adminAudit");
},
},
[AdminTourStep.ADMIN_TOOLS]: {
selector: '[data-tour="admin-adminAudit-nav"]',
highlightedSelectors: [
'[data-tour="admin-adminAudit-nav"]',
'[data-tour="admin-adminUsage-nav"]',
'[data-tour="settings-content-area"]',
],
content: t(
"adminOnboarding.adminTools",
"Finally, we have advanced administration tools like <strong>Auditing</strong> to track system activity and <strong>Usage Analytics</strong> to monitor how your users interact with the platform.",
),
position: "right",
padding: 10,
action: () => {
removeAllGlows();
navigateToSection("adminAudit");
setTimeout(() => {
addGlowToElements([
'[data-tour="admin-adminAudit-nav"]',
'[data-tour="admin-adminUsage-nav"]',
'[data-tour="settings-content-area"]',
]);
}, 100);
},
},
[AdminTourStep.WRAP_UP]: {
selector: '[data-tour="admin-help-nav"]',
content: t(
"adminOnboarding.wrapUp",
"That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. You can replay it anytime — just open <strong>Settings</strong> and find it here in the <strong>Tours</strong> section under Help.",
),
position: "right",
padding: 10,
action: async () => {
removeAllGlows();
navigateToSection("help");
await waitForElement('[data-tour="admin-help-nav"]', 5000);
await waitForHighlightable('[data-tour="admin-help-nav"]', 5000);
},
},
};
}
@@ -0,0 +1,307 @@
import WelcomeSlide from "@app/components/onboarding/slides/WelcomeSlide";
import DesktopInstallSlide from "@app/components/onboarding/slides/DesktopInstallSlide";
import SecurityCheckSlide from "@app/components/onboarding/slides/SecurityCheckSlide";
import PlanOverviewSlide from "@app/components/onboarding/slides/PlanOverviewSlide";
import ServerLicenseSlide from "@app/components/onboarding/slides/ServerLicenseSlide";
import FirstLoginSlide from "@app/components/onboarding/slides/FirstLoginSlide";
import TourOverviewSlide from "@app/components/onboarding/slides/TourOverviewSlide";
import AnalyticsChoiceSlide from "@app/components/onboarding/slides/AnalyticsChoiceSlide";
import MFASetupSlide from "@app/components/onboarding/slides/MFASetupSlide";
import { SlideConfig, LicenseNotice } from "@app/types/types";
export type SlideId =
| "first-login"
| "welcome"
| "desktop-install"
| "security-check"
| "admin-overview"
| "server-license"
| "tour-overview"
| "analytics-choice"
| "mfa-setup";
export type HeroType =
| "rocket"
| "dual-icon"
| "shield"
| "diamond"
| "logo"
| "lock"
| "analytics";
export type ButtonAction =
| "next"
| "prev"
| "close"
| "complete-close"
| "download-selected"
| "security-next"
| "launch-admin"
| "launch-tools"
| "launch-auto"
| "see-plans"
| "skip-to-license"
| "skip-tour"
| "enable-analytics"
| "disable-analytics";
export interface FlowState {
selectedRole: "admin" | "user" | null;
}
export interface OSOption {
label: string;
url: string;
value: string;
}
export interface SlideFactoryParams {
osLabel: string;
osUrl: string;
osOptions?: OSOption[];
onDownloadUrlChange?: (url: string) => void;
selectedRole: "admin" | "user" | null;
onRoleSelect: (role: "admin" | "user" | null) => void;
licenseNotice?: LicenseNotice;
loginEnabled?: boolean;
// First login params
firstLoginUsername?: string;
onPasswordChanged?: () => void;
usingDefaultCredentials?: boolean;
analyticsError?: string | null;
analyticsLoading?: boolean;
onMfaSetupComplete?: () => void;
}
export interface HeroDefinition {
type: HeroType;
}
export interface ButtonDefinition {
key: string;
type: "button" | "icon";
label?: string;
icon?: "chevron-left";
variant?: "primary" | "secondary" | "default";
group: "left" | "right";
action: ButtonAction;
disabledWhen?: (state: FlowState) => boolean;
}
export interface SlideDefinition {
id: SlideId;
createSlide: (params: SlideFactoryParams) => SlideConfig;
hero: HeroDefinition;
buttons: ButtonDefinition[];
}
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
"first-login": {
id: "first-login",
createSlide: ({
firstLoginUsername,
onPasswordChanged,
usingDefaultCredentials,
}) =>
FirstLoginSlide({
username: firstLoginUsername || "",
onPasswordChanged: onPasswordChanged || (() => {}),
usingDefaultCredentials: usingDefaultCredentials || false,
}),
hero: { type: "lock" },
buttons: [], // Form has its own submit button
},
welcome: {
id: "welcome",
createSlide: () => WelcomeSlide(),
hero: { type: "rocket" },
buttons: [
{
key: "welcome-next",
type: "button",
label: "onboarding.buttons.next",
variant: "primary",
group: "right",
action: "next",
},
],
},
"desktop-install": {
id: "desktop-install",
createSlide: ({ osLabel, osUrl, osOptions, onDownloadUrlChange }) =>
DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }),
hero: { type: "dual-icon" },
buttons: [
{
key: "desktop-back",
type: "icon",
icon: "chevron-left",
group: "left",
action: "prev",
},
{
key: "desktop-skip",
type: "button",
label: "onboarding.buttons.skipForNow",
variant: "secondary",
group: "left",
action: "next",
},
{
key: "desktop-download",
type: "button",
label: "onboarding.buttons.download",
variant: "primary",
group: "right",
action: "download-selected",
},
],
},
"security-check": {
id: "security-check",
createSlide: ({ selectedRole, onRoleSelect }) =>
SecurityCheckSlide({ selectedRole, onRoleSelect }),
hero: { type: "shield" },
buttons: [
{
key: "security-back",
type: "button",
label: "onboarding.buttons.back",
variant: "secondary",
group: "left",
action: "prev",
},
{
key: "security-next",
type: "button",
label: "onboarding.buttons.next",
variant: "primary",
group: "right",
action: "security-next",
disabledWhen: (state) => !state.selectedRole,
},
],
},
"admin-overview": {
id: "admin-overview",
createSlide: ({ licenseNotice, loginEnabled }) =>
PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }),
hero: { type: "diamond" },
buttons: [
{
key: "admin-back",
type: "icon",
icon: "chevron-left",
group: "left",
action: "prev",
},
{
key: "admin-show",
type: "button",
label: "onboarding.buttons.showMeAround",
variant: "primary",
group: "right",
action: "launch-admin",
},
{
key: "admin-skip",
type: "button",
label: "onboarding.buttons.skipTheTour",
variant: "secondary",
group: "left",
action: "skip-to-license",
},
],
},
"server-license": {
id: "server-license",
createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }),
hero: { type: "dual-icon" },
buttons: [
{
key: "license-back",
type: "icon",
icon: "chevron-left",
group: "left",
action: "prev",
},
{
key: "license-close",
type: "button",
label: "onboarding.buttons.skipForNow",
variant: "secondary",
group: "left",
action: "close",
},
{
key: "license-see-plans",
type: "button",
label: "onboarding.serverLicense.seePlans",
variant: "primary",
group: "right",
action: "see-plans",
},
],
},
"tour-overview": {
id: "tour-overview",
createSlide: () => TourOverviewSlide(),
hero: { type: "rocket" },
buttons: [
{
key: "tour-overview-back",
type: "icon",
icon: "chevron-left",
group: "left",
action: "prev",
},
{
key: "tour-overview-skip",
type: "button",
label: "onboarding.buttons.skipForNow",
variant: "secondary",
group: "left",
action: "skip-tour",
},
{
key: "tour-overview-show",
type: "button",
label: "onboarding.buttons.showMeAround",
variant: "primary",
group: "right",
action: "launch-tools",
},
],
},
"analytics-choice": {
id: "analytics-choice",
createSlide: ({ analyticsError }) =>
AnalyticsChoiceSlide({ analyticsError }),
hero: { type: "analytics" },
buttons: [
{
key: "analytics-disable",
type: "button",
label: "no",
variant: "secondary",
group: "left",
action: "disable-analytics",
},
{
key: "analytics-enable",
type: "button",
label: "yes",
variant: "primary",
group: "right",
action: "enable-analytics",
},
],
},
"mfa-setup": {
id: "mfa-setup",
createSlide: ({ onMfaSetupComplete = () => {} }: SlideFactoryParams) =>
MFASetupSlide({ onMfaSetupComplete }),
hero: { type: "lock" },
buttons: [], // Form has its own submit button
},
};
@@ -0,0 +1,142 @@
export type OnboardingStepId =
| "first-login"
| "welcome"
| "desktop-install"
| "security-check"
| "admin-overview"
| "tool-layout"
| "tour-overview"
| "server-license"
| "analytics-choice"
| "mfa-setup";
export type OnboardingStepType = "modal-slide" | "tool-prompt";
export interface OnboardingRuntimeState {
selectedRole: "admin" | "user" | null;
tourRequested: boolean;
tourType: "admin" | "tools" | "whatsnew";
isDesktopApp: boolean;
desktopSlideEnabled: boolean;
analyticsNotConfigured: boolean;
analyticsEnabled: boolean;
licenseNotice: {
totalUsers: number | null;
freeTierLimit: number;
isOverLimit: boolean;
requiresLicense: boolean;
};
requiresPasswordChange: boolean;
firstLoginUsername: string;
usingDefaultCredentials: boolean;
requiresMfaSetup: boolean;
}
export interface OnboardingConditionContext extends OnboardingRuntimeState {
loginEnabled: boolean;
effectiveIsAdmin: boolean;
}
export interface OnboardingStep {
id: OnboardingStepId;
type: OnboardingStepType;
condition: (ctx: OnboardingConditionContext) => boolean;
slideId?:
| "first-login"
| "welcome"
| "desktop-install"
| "security-check"
| "admin-overview"
| "server-license"
| "tour-overview"
| "analytics-choice"
| "mfa-setup";
allowDismiss?: boolean;
}
export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = {
selectedRole: null,
tourRequested: false,
tourType: "whatsnew",
isDesktopApp: false,
analyticsNotConfigured: false,
analyticsEnabled: false,
licenseNotice: {
totalUsers: null,
freeTierLimit: 5,
isOverLimit: false,
requiresLicense: false,
},
requiresPasswordChange: false,
firstLoginUsername: "",
usingDefaultCredentials: false,
desktopSlideEnabled: true,
requiresMfaSetup: false,
};
export const ONBOARDING_STEPS: OnboardingStep[] = [
{
id: "first-login",
type: "modal-slide",
slideId: "first-login",
condition: (ctx) => ctx.requiresPasswordChange,
},
{
id: "welcome",
type: "modal-slide",
slideId: "welcome",
// Desktop has its own onboarding modal (DesktopOnboardingModal)
condition: (ctx) => !ctx.isDesktopApp,
},
{
id: "admin-overview",
type: "modal-slide",
slideId: "admin-overview",
condition: (ctx) => ctx.effectiveIsAdmin,
},
{
id: "desktop-install",
type: "modal-slide",
slideId: "desktop-install",
condition: (ctx) => !ctx.isDesktopApp && ctx.desktopSlideEnabled,
},
{
id: "security-check",
type: "modal-slide",
slideId: "security-check",
condition: () => false,
},
{
id: "tool-layout",
type: "tool-prompt",
condition: () => false,
},
{
id: "tour-overview",
type: "modal-slide",
slideId: "tour-overview",
condition: (ctx) =>
!ctx.effectiveIsAdmin && ctx.tourType !== "admin" && !ctx.isDesktopApp,
},
{
id: "server-license",
type: "modal-slide",
slideId: "server-license",
condition: (ctx) =>
ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense,
},
{
id: "mfa-setup",
type: "modal-slide",
slideId: "mfa-setup",
condition: (ctx) => ctx.requiresMfaSetup,
},
];
export function getStepById(id: OnboardingStepId): OnboardingStep | undefined {
return ONBOARDING_STEPS.find((step) => step.id === id);
}
export function getStepIndex(id: OnboardingStepId): number {
return ONBOARDING_STEPS.findIndex((step) => step.id === id);
}
@@ -0,0 +1,86 @@
const STORAGE_PREFIX = "onboarding";
const TOURS_TOOLTIP_KEY = `${STORAGE_PREFIX}::tours-tooltip-shown`;
const ONBOARDING_COMPLETED_KEY = `${STORAGE_PREFIX}::completed`;
export function isOnboardingCompleted(): boolean {
if (typeof window === "undefined") return false;
try {
return localStorage.getItem(ONBOARDING_COMPLETED_KEY) === "true";
} catch {
return false;
}
}
export function markOnboardingCompleted(): void {
if (typeof window === "undefined") return;
try {
localStorage.setItem(ONBOARDING_COMPLETED_KEY, "true");
} catch (error) {
console.error(
"[onboardingStorage] Error marking onboarding as completed:",
error,
);
}
}
export function resetOnboardingProgress(): void {
if (typeof window === "undefined") return;
try {
localStorage.removeItem(ONBOARDING_COMPLETED_KEY);
} catch (error) {
console.error(
"[onboardingStorage] Error resetting onboarding progress:",
error,
);
}
}
export function hasShownToursTooltip(): boolean {
if (typeof window === "undefined") return false;
try {
return localStorage.getItem(TOURS_TOOLTIP_KEY) === "true";
} catch {
return false;
}
}
export function markToursTooltipShown(): void {
if (typeof window === "undefined") return;
try {
localStorage.setItem(TOURS_TOOLTIP_KEY, "true");
} catch (error) {
console.error(
"[onboardingStorage] Error marking tours tooltip as shown:",
error,
);
}
}
export function migrateFromLegacyPreferences(): void {
if (typeof window === "undefined") return;
const migrationKey = `${STORAGE_PREFIX}::migrated`;
try {
// Skip if already migrated
if (localStorage.getItem(migrationKey) === "true") return;
const prefsRaw = localStorage.getItem("stirlingpdf_preferences");
if (prefsRaw) {
const prefs = JSON.parse(prefsRaw) as Record<string, unknown>;
// If user had completed onboarding in old system, mark new system as complete
if (
prefs.hasCompletedOnboarding === true ||
prefs.hasSeenIntroOnboarding === true
) {
markOnboardingCompleted();
}
}
// Mark migration complete
localStorage.setItem(migrationKey, "true");
} catch {
// If migration fails, onboarding will show again - safer than hiding it
}
}
@@ -0,0 +1,424 @@
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
import { useServerExperience } from "@app/hooks/useServerExperience";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import {
ONBOARDING_STEPS,
type OnboardingStepId,
type OnboardingStep,
type OnboardingRuntimeState,
type OnboardingConditionContext,
DEFAULT_RUNTIME_STATE,
} from "@app/components/onboarding/orchestrator/onboardingConfig";
import {
isOnboardingCompleted,
markOnboardingCompleted,
migrateFromLegacyPreferences,
} from "@app/components/onboarding/orchestrator/onboardingStorage";
import { accountService } from "@app/services/accountService";
import { useBypassOnboarding } from "@app/components/onboarding/useBypassOnboarding";
const AUTH_ROUTES = ["/login", "/signup", "/auth", "/invite"];
const SESSION_TOUR_REQUESTED = "onboarding::session::tour-requested";
const SESSION_TOUR_TYPE = "onboarding::session::tour-type";
const SESSION_SELECTED_ROLE = "onboarding::session::selected-role";
// Check if user has an auth token (to avoid flash before redirect)
function hasAuthToken(): boolean {
if (typeof window === "undefined") return false;
return !!localStorage.getItem("stirling_jwt");
}
// Get initial runtime state from session storage (survives remounts)
function getInitialRuntimeState(
baseState: OnboardingRuntimeState,
): OnboardingRuntimeState {
if (typeof window === "undefined") {
return baseState;
}
try {
const tourRequested =
sessionStorage.getItem(SESSION_TOUR_REQUESTED) === "true";
const sessionTourType = sessionStorage.getItem(SESSION_TOUR_TYPE);
const tourType =
sessionTourType === "admin" ||
sessionTourType === "tools" ||
sessionTourType === "whatsnew"
? sessionTourType
: "whatsnew";
const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as
| "admin"
| "user"
| null;
return {
...baseState,
tourRequested,
tourType,
selectedRole,
};
} catch {
return baseState;
}
}
function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
if (typeof window === "undefined") return;
try {
if (state.tourRequested !== undefined) {
sessionStorage.setItem(
SESSION_TOUR_REQUESTED,
state.tourRequested ? "true" : "false",
);
}
if (state.tourType !== undefined) {
sessionStorage.setItem(SESSION_TOUR_TYPE, state.tourType);
}
if (state.selectedRole !== undefined) {
if (state.selectedRole) {
sessionStorage.setItem(SESSION_SELECTED_ROLE, state.selectedRole);
} else {
sessionStorage.removeItem(SESSION_SELECTED_ROLE);
}
}
} catch (error) {
console.error(
"[useOnboardingOrchestrator] Error persisting runtime state:",
error,
);
}
}
function clearRuntimeStateSession(): void {
if (typeof window === "undefined") return;
try {
sessionStorage.removeItem(SESSION_TOUR_REQUESTED);
sessionStorage.removeItem(SESSION_TOUR_TYPE);
sessionStorage.removeItem(SESSION_SELECTED_ROLE);
} catch {
// Ignore errors
}
}
function parseMfaRequired(settings: string | null | undefined): boolean {
if (!settings) return false;
try {
const parsed = JSON.parse(settings) as { mfaRequired?: string };
return parsed.mfaRequired?.toLowerCase() === "true";
} catch (error) {
console.warn(
"[useOnboardingOrchestrator] Failed to parse account settings JSON:",
error,
);
return false;
}
}
export interface OnboardingOrchestratorState {
/** Whether onboarding is currently active */
isActive: boolean;
/** The current step being shown (null if no step is active) */
currentStep: OnboardingStep | null;
/** Index of current step in the active flow (for display purposes) */
currentStepIndex: number;
/** Total number of steps in the active flow */
totalSteps: number;
/** Runtime state that affects conditions */
runtimeState: OnboardingRuntimeState;
/** All steps that will be shown in this flow (filtered by conditions) */
activeFlow: OnboardingStep[];
/** Whether all steps have been seen */
isComplete: boolean;
/** Whether we're still initializing */
isLoading: boolean;
}
export interface OnboardingOrchestratorActions {
/** Move to the next step */
next: () => void;
/** Move to the previous step */
prev: () => void;
/** Skip the current step (marks as seen but doesn't complete) */
skip: () => void;
/** Mark current step as seen and move to next */
complete: () => void;
/** Update runtime state (e.g., after role selection) */
updateRuntimeState: (updates: Partial<OnboardingRuntimeState>) => void;
/** Force re-evaluation of the flow (used when conditions change) */
refreshFlow: () => void;
/** Manually start a specific step (for external triggers) */
startStep: (stepId: OnboardingStepId) => void;
/** Close/pause onboarding (can be resumed later) */
pause: () => void;
/** Resume onboarding from where it was paused */
resume: () => void;
}
export interface UseOnboardingOrchestratorResult {
state: OnboardingOrchestratorState;
actions: OnboardingOrchestratorActions;
}
export interface UseOnboardingOrchestratorOptions {
/** Override the default runtime state (used by desktop to set isDesktopApp: true) */
defaultRuntimeState?: OnboardingRuntimeState;
}
export function useOnboardingOrchestrator(
options?: UseOnboardingOrchestratorOptions,
): UseOnboardingOrchestratorResult {
const defaultState = options?.defaultRuntimeState ?? DEFAULT_RUNTIME_STATE;
const serverExperience = useServerExperience();
const { config, loading: configLoading } = useAppConfig();
const location = useLocation();
const bypassOnboarding = useBypassOnboarding();
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
getInitialRuntimeState(defaultState),
);
const [isPaused, setIsPaused] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const [currentStepIndex, setCurrentStepIndex] = useState(-1);
const migrationDone = useRef(false);
const initialIndexSet = useRef(false);
useEffect(() => {
if (!migrationDone.current) {
migrateFromLegacyPreferences();
migrationDone.current = true;
}
}, []);
useEffect(() => {
setRuntimeState((prev) => ({
...prev,
analyticsEnabled: config?.enableAnalytics === true,
analyticsNotConfigured: config?.enableAnalytics == null,
desktopSlideEnabled: config?.enableDesktopInstallSlide ?? true,
licenseNotice: {
totalUsers: serverExperience.totalUsers,
freeTierLimit: serverExperience.freeTierLimit,
isOverLimit: serverExperience.overFreeTierLimit ?? false,
requiresLicense:
!serverExperience.hasPaidLicense &&
(serverExperience.overFreeTierLimit === true ||
(serverExperience.effectiveIsAdmin &&
serverExperience.userCountResolved)),
},
}));
}, [
config?.enableAnalytics,
serverExperience.totalUsers,
serverExperience.freeTierLimit,
serverExperience.overFreeTierLimit,
serverExperience.hasPaidLicense,
serverExperience.effectiveIsAdmin,
serverExperience.userCountResolved,
]);
useEffect(() => {
const checkFirstLogin = async () => {
if (config?.enableLogin !== true || !hasAuthToken()) return;
try {
const [accountData, loginPageData] = await Promise.all([
accountService.getAccountData(),
accountService.getLoginPageData(),
]);
setRuntimeState((prev) => ({
...prev,
requiresPasswordChange: accountData.changeCredsFlag,
firstLoginUsername: accountData.username,
usingDefaultCredentials: loginPageData.showDefaultCredentials,
requiresMfaSetup: parseMfaRequired(accountData.settings),
}));
} catch (error) {
console.log(
"[OnboardingOrchestrator] Failed to fetch account data for onboarding runtime state:",
error,
);
// Account endpoint failed - user not logged in or security disabled
}
};
if (!configLoading) {
checkFirstLogin();
}
}, [config?.enableLogin, configLoading]);
const isOnAuthRoute = AUTH_ROUTES.some((route) =>
location.pathname.startsWith(route),
);
const loginEnabled = config?.enableLogin === true;
const isUnauthenticatedWithLoginEnabled = loginEnabled && !hasAuthToken();
const shouldBlockOnboarding =
bypassOnboarding ||
isOnAuthRoute ||
configLoading ||
isUnauthenticatedWithLoginEnabled;
const conditionContext = useMemo<OnboardingConditionContext>(
() => ({
...serverExperience,
...runtimeState,
effectiveIsAdmin:
serverExperience.effectiveIsAdmin ||
(!serverExperience.loginEnabled &&
runtimeState.selectedRole === "admin"),
}),
[serverExperience, runtimeState],
);
const activeFlow = useMemo(() => {
return ONBOARDING_STEPS.filter((step) => step.condition(conditionContext));
}, [conditionContext]);
// Wait for config AND admin status before calculating initial step
const adminStatusResolved =
!configLoading &&
(config?.enableLogin === false ||
config?.enableLogin === undefined ||
config?.isAdmin !== undefined);
useEffect(() => {
if (configLoading || !adminStatusResolved) return;
// If there are no steps to show, mark initialized/completed baseline
if (activeFlow.length === 0) {
setCurrentStepIndex(0);
initialIndexSet.current = true;
return;
}
// If onboarding has been completed, don't show it
if (isOnboardingCompleted()) {
setCurrentStepIndex(activeFlow.length);
initialIndexSet.current = true;
return;
}
// Start from the beginning
if (!initialIndexSet.current) {
setCurrentStepIndex(0);
initialIndexSet.current = true;
}
}, [activeFlow, configLoading, adminStatusResolved]);
const totalSteps = activeFlow.length;
const isComplete =
isInitialized &&
(totalSteps === 0 ||
currentStepIndex >= totalSteps ||
isOnboardingCompleted());
const currentStep =
currentStepIndex >= 0 && currentStepIndex < totalSteps
? activeFlow[currentStepIndex]
: null;
const isActive =
!shouldBlockOnboarding &&
!isPaused &&
!isComplete &&
isInitialized &&
currentStep !== null;
const isLoading =
configLoading ||
!adminStatusResolved ||
!isInitialized ||
!initialIndexSet.current ||
(currentStepIndex === -1 && activeFlow.length > 0);
useEffect(() => {
if (!configLoading && !isInitialized) setIsInitialized(true);
}, [configLoading, isInitialized]);
useEffect(() => {
if (isComplete) clearRuntimeStateSession();
}, [isComplete]);
const next = useCallback(() => {
const nextIndex = currentStepIndex + 1;
if (nextIndex >= totalSteps) {
// Reached the end, mark onboarding as completed
markOnboardingCompleted();
}
setCurrentStepIndex(nextIndex);
}, [currentStepIndex, totalSteps]);
const prev = useCallback(() => {
setCurrentStepIndex((prev) => Math.max(prev - 1, 0));
}, []);
const skip = useCallback(() => {
// Skip marks the entire onboarding as completed
markOnboardingCompleted();
setCurrentStepIndex(totalSteps);
}, [totalSteps]);
const complete = useCallback(() => {
const nextIndex = currentStepIndex + 1;
if (nextIndex >= totalSteps) {
// Reached the end, mark onboarding as completed
markOnboardingCompleted();
}
setCurrentStepIndex(nextIndex);
}, [currentStepIndex, totalSteps]);
const updateRuntimeState = useCallback(
(updates: Partial<OnboardingRuntimeState>) => {
persistRuntimeState(updates);
setRuntimeState((prev) => ({ ...prev, ...updates }));
},
[],
);
const refreshFlow = useCallback(() => {
initialIndexSet.current = false;
setCurrentStepIndex(-1);
}, []);
const startStep = useCallback(
(stepId: OnboardingStepId) => {
const index = activeFlow.findIndex((step) => step.id === stepId);
if (index !== -1) {
setCurrentStepIndex(index);
setIsPaused(false);
}
},
[activeFlow],
);
const pause = useCallback(() => setIsPaused(true), []);
const resume = useCallback(() => setIsPaused(false), []);
const state: OnboardingOrchestratorState = {
isActive,
currentStep,
currentStepIndex,
totalSteps,
runtimeState,
activeFlow,
isComplete,
isLoading,
};
const actions: OnboardingOrchestratorActions = {
next,
prev,
skip,
complete,
updateRuntimeState,
refreshFlow,
startStep,
pause,
resume,
};
return { state, actions };
}
@@ -0,0 +1,64 @@
import React from "react";
import { Trans } from "react-i18next";
import { Button } from "@mantine/core";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import i18n from "@app/i18n";
import { SlideConfig } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
interface AnalyticsChoiceSlideProps {
analyticsError?: string | null;
}
export default function AnalyticsChoiceSlide({
analyticsError,
}: AnalyticsChoiceSlideProps): SlideConfig {
return {
key: "analytics-choice",
title: i18n.t(
"analytics.title",
"Do you want to help make Stirling PDF better?",
),
body: (
<div className={styles.bodyCopyInner}>
<Trans
i18nKey="analytics.paragraph1"
defaults="Stirling PDF has opt-in analytics to help us improve the product. We do not track any personal information or file contents."
components={{ strong: <strong /> }}
/>
<br />
<Trans
i18nKey="analytics.paragraph2"
defaults="Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better."
components={{ strong: <strong /> }}
/>
<br />
<div style={{ textAlign: "right", marginTop: 0 }}>
<Button
variant="default"
size="sm"
onClick={() =>
window.open(
"https://docs.stirlingpdf.com/analytics-telemetry/",
"_blank",
)
}
rightSection={<OpenInNewIcon style={{ fontSize: 16 }} />}
>
{i18n.t("analytics.learnMore", "Learn more about our analytics")}
</Button>
</div>
{analyticsError && (
<div style={{ color: "var(--mantine-color-red-6)", marginTop: 12 }}>
{analyticsError}
</div>
)}
</div>
),
background: {
gradientStops: ["#0EA5E9", "#6366F1"],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -0,0 +1,74 @@
.hero {
position: relative;
width: 100%;
height: 220px;
overflow: hidden;
border-radius: 0;
}
.gradientLayer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: 180% 180%;
opacity: 0;
transition: opacity 0.8s ease-in-out;
z-index: 1;
}
.gradientLayerActive {
opacity: 1;
animation: gradientShift 18s ease-in-out infinite alternate;
z-index: 2;
}
.gradientLayerPrev {
opacity: 1;
z-index: 3;
transition: opacity 0.8s ease-in-out;
}
.gradientLayerPrevFadeOut {
opacity: 0;
z-index: 3;
transition: opacity 0.8s ease-in-out;
}
.circle {
position: absolute;
border-radius: 50%;
pointer-events: none;
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.12);
animation-name: circleSway;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
animation-direction: alternate;
animation-duration: var(--circle-duration, 15s);
animation-delay: var(--circle-delay, 0s);
will-change: transform;
z-index: 10;
}
@keyframes gradientShift {
0% {
background-position: 0% 50%;
}
100% {
background-position: 100% 50%;
}
}
@keyframes circleSway {
0% {
transform: translate3d(0, 0, 0);
}
100% {
transform: translate3d(
var(--circle-move-x, 40px),
var(--circle-move-y, 24px),
0
);
}
}
@@ -0,0 +1,131 @@
import React from "react";
import styles from "@app/components/onboarding/slides/AnimatedSlideBackground.module.css";
import { AnimatedSlideBackgroundProps } from "@app/types/types";
type CircleStyles = React.CSSProperties & {
"--circle-move-x"?: string;
"--circle-move-y"?: string;
"--circle-duration"?: string;
"--circle-delay"?: string;
};
interface AnimatedSlideBackgroundComponentProps extends AnimatedSlideBackgroundProps {
isActive: boolean;
slideKey: string;
}
export default function AnimatedSlideBackground({
gradientStops,
circles,
isActive,
}: AnimatedSlideBackgroundComponentProps) {
const [prevGradient, setPrevGradient] = React.useState<
[string, string] | null
>(null);
const [currentGradient, setCurrentGradient] =
React.useState<[string, string]>(gradientStops);
const [isTransitioning, setIsTransitioning] = React.useState(false);
const isFirstMount = React.useRef(true);
React.useEffect(() => {
// Skip transition on first mount
if (isFirstMount.current) {
isFirstMount.current = false;
setCurrentGradient(gradientStops);
return;
}
// Only transition if gradient actually changed
if (
currentGradient[0] !== gradientStops[0] ||
currentGradient[1] !== gradientStops[1]
) {
// Store previous gradient and start transition
setPrevGradient(currentGradient);
setIsTransitioning(true);
// Update to new gradient (will fade in)
setCurrentGradient(gradientStops);
}
}, [gradientStops]);
const currentGradientStyle = React.useMemo(
() => ({
backgroundImage: `linear-gradient(135deg, ${currentGradient[0]}, ${currentGradient[1]})`,
}),
[currentGradient],
);
const prevGradientStyle = prevGradient
? {
backgroundImage: `linear-gradient(135deg, ${prevGradient[0]}, ${prevGradient[1]})`,
}
: null;
return (
<div className={styles.hero} key="animated-background">
{prevGradientStyle && isTransitioning && (
<div
className={`${styles.gradientLayer} ${styles.gradientLayerPrevFadeOut}`}
style={prevGradientStyle}
onTransitionEnd={() => {
setPrevGradient(null);
setIsTransitioning(false);
}}
/>
)}
<div
className={`${styles.gradientLayer} ${isActive ? styles.gradientLayerActive : ""}`.trim()}
style={currentGradientStyle}
/>
{circles.map((circle, index) => {
const {
position,
size,
color,
opacity,
blur,
amplitude = 48,
duration = 15,
delay = 0,
} = circle;
const moveX = position === "bottom-left" ? amplitude : -amplitude;
const moveY =
position === "bottom-left" ? -amplitude * 0.6 : amplitude * 0.6;
const circleStyle: CircleStyles = {
width: size,
height: size,
background: color,
opacity: opacity ?? 0.9,
filter: blur ? `blur(${blur}px)` : undefined,
"--circle-move-x": `${moveX}px`,
"--circle-move-y": `${moveY}px`,
"--circle-duration": `${duration}s`,
"--circle-delay": `${delay}s`,
};
const defaultOffset = -size / 2;
const offsetX = circle.offsetX ?? 0;
const offsetY = circle.offsetY ?? 0;
if (position === "bottom-left") {
circleStyle.left = `${defaultOffset + offsetX}px`;
circleStyle.bottom = `${defaultOffset + offsetY}px`;
} else {
circleStyle.right = `${defaultOffset + offsetX}px`;
circleStyle.top = `${defaultOffset + offsetY}px`;
}
return (
<div
key={`circle-${index}-${position}`}
className={styles.circle}
style={circleStyle}
/>
);
})}
</div>
);
}
@@ -0,0 +1,55 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { SlideConfig } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import {
DesktopInstallTitle,
type OSOption,
} from "@app/components/onboarding/slides/DesktopInstallTitle";
export type { OSOption };
interface DesktopInstallSlideProps {
osLabel: string;
osUrl: string;
osOptions?: OSOption[];
onDownloadUrlChange?: (url: string) => void;
}
const DesktopInstallBody = () => {
const { t } = useTranslation();
return (
<span>
{t(
"onboarding.desktopInstall.body",
"Stirling works best as a desktop app. You can use it offline, access documents faster, and make edits locally on your computer.",
)}
</span>
);
};
export default function DesktopInstallSlide({
osLabel,
osUrl,
osOptions = [],
onDownloadUrlChange,
}: DesktopInstallSlideProps): SlideConfig {
return {
key: "desktop-install",
title: (
<DesktopInstallTitle
osLabel={osLabel}
osUrl={osUrl}
osOptions={osOptions || []}
onDownloadUrlChange={onDownloadUrlChange}
/>
),
body: <DesktopInstallBody />,
downloadUrl: osUrl,
background: {
gradientStops: ["#2563EB", "#0EA5E9"],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -0,0 +1,106 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Menu, ActionIcon } from "@mantine/core";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
export interface OSOption {
label: string;
url: string;
value: string;
}
interface DesktopInstallTitleProps {
osLabel: string;
osUrl: string;
osOptions: OSOption[];
onDownloadUrlChange?: (url: string) => void;
}
export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
osLabel,
osUrl,
osOptions,
onDownloadUrlChange,
}) => {
const { t } = useTranslation();
const [selectedOsUrl, setSelectedOsUrl] = React.useState<string>(osUrl);
React.useEffect(() => {
setSelectedOsUrl(osUrl);
}, [osUrl]);
const handleOsSelect = React.useCallback(
(option: OSOption) => {
setSelectedOsUrl(option.url);
onDownloadUrlChange?.(option.url);
},
[onDownloadUrlChange],
);
const currentOsOption =
osOptions.find((opt) => opt.url === selectedOsUrl) ||
(osOptions.length > 0 ? osOptions[0] : { label: osLabel, url: osUrl });
const displayLabel = currentOsOption.label || osLabel;
const title = displayLabel
? t("onboarding.desktopInstall.titleWithOs", "Download for {{osLabel}}", {
osLabel: displayLabel,
})
: t("onboarding.desktopInstall.title", "Download");
// If only one option or no options, don't show dropdown
if (osOptions.length <= 1) {
return <div style={{ textAlign: "center", width: "100%" }}>{title}</div>;
}
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
width: "100%",
}}
>
<span style={{ whiteSpace: "nowrap" }}>{title}</span>
<Menu position="bottom" offset={5} zIndex={10000}>
<Menu.Target>
<ActionIcon
variant="transparent"
size="sm"
style={{
background: "transparent",
border: "none",
color: "inherit",
padding: 0,
}}
>
<ExpandMoreIcon fontSize="small" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{osOptions.map((option) => {
const isSelected = option.url === selectedOsUrl;
return (
<Menu.Item
key={option.url}
onClick={() => handleOsSelect(option)}
style={{
backgroundColor: isSelected
? "light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))"
: "transparent",
color: isSelected
? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))"
: "inherit",
}}
>
{option.label}
</Menu.Item>
);
})}
</Menu.Dropdown>
</Menu>
</div>
);
};
@@ -0,0 +1,239 @@
import React, { useState } from "react";
import { Stack, PasswordInput, Button, Alert, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { SlideConfig } from "@app/types/types";
import LocalIcon from "@app/components/shared/LocalIcon";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import { accountService } from "@app/services/accountService";
import { alert as showToast } from "@app/components/toast";
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
interface FirstLoginSlideProps {
username: string;
onPasswordChanged: () => void;
usingDefaultCredentials?: boolean;
}
const DEFAULT_PASSWORD = "stirling";
function FirstLoginForm({
username,
onPasswordChanged,
usingDefaultCredentials = false,
}: FirstLoginSlideProps) {
const { t } = useTranslation();
// If using default credentials, pre-fill with "stirling" - user won't see this field
const [currentPassword, setCurrentPassword] = useState(
usingDefaultCredentials ? DEFAULT_PASSWORD : "",
);
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async () => {
// Validation
if (
(!usingDefaultCredentials && !currentPassword) ||
!newPassword ||
!confirmPassword
) {
setError(t("firstLogin.allFieldsRequired", "All fields are required"));
return;
}
if (newPassword !== confirmPassword) {
setError(
t("firstLogin.passwordsDoNotMatch", "New passwords do not match"),
);
return;
}
if (newPassword.length < 8) {
setError(
t(
"firstLogin.passwordTooShort",
"Password must be at least 8 characters",
),
);
return;
}
if (newPassword === currentPassword) {
setError(
t(
"firstLogin.passwordMustBeDifferent",
"New password must be different from current password",
),
);
return;
}
try {
setLoading(true);
setError("");
await accountService.changePasswordOnLogin(
currentPassword,
newPassword,
confirmPassword,
);
showToast({
alertType: "success",
title: t(
"firstLogin.passwordChangedSuccess",
"Password changed successfully! Please log in again.",
),
});
// Clear form
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
// Wait a moment for the user to see the success message
setTimeout(() => {
onPasswordChanged();
}, 1500);
} catch (err) {
console.error("Failed to change password:", err);
// Extract error message from axios response if available
const axiosError = err as { response?: { data?: { message?: string } } };
setError(
axiosError.response?.data?.message ||
t(
"firstLogin.passwordChangeFailed",
"Failed to change password. Please check your current password.",
),
);
} finally {
setLoading(false);
}
};
return (
<div className={styles.securitySlideContent}>
<div className={styles.securityCard}>
<Stack gap="md">
<div className={styles.securityAlertRow}>
<LocalIcon
icon="info-rounded"
width={20}
height={20}
style={{ color: "#3B82F6", flexShrink: 0 }}
/>
<span>
{t(
"firstLogin.welcomeMessage",
"For security reasons, you must change your password on your first login.",
)}
</span>
</div>
<Text size="sm" fw={500}>
{t("firstLogin.loggedInAs", "Logged in as")}:{" "}
<strong>{username}</strong>
</Text>
{error && (
<Alert
icon={
<LocalIcon icon="error-rounded" width="1rem" height="1rem" />
}
color="red"
variant="light"
>
{error}
</Alert>
)}
{/* Only show current password field if not using default credentials */}
{!usingDefaultCredentials && (
<PasswordInput
label={t("firstLogin.currentPassword", "Current Password")}
placeholder={t(
"firstLogin.enterCurrentPassword",
"Enter your current password",
)}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.currentTarget.value)}
required
styles={{
input: { height: 44 },
}}
/>
)}
<PasswordInput
label={t("firstLogin.newPassword", "New Password")}
placeholder={t(
"firstLogin.enterNewPassword",
"Enter new password (min 8 characters)",
)}
value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)}
minLength={8}
required
styles={{
input: { height: 44 },
}}
/>
<PasswordInput
label={t("firstLogin.confirmPassword", "Confirm New Password")}
placeholder={t(
"firstLogin.reEnterNewPassword",
"Re-enter new password",
)}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
required
minLength={8}
styles={{
input: { height: 44 },
}}
/>
<Button
fullWidth
onClick={handleSubmit}
loading={loading}
disabled={
!newPassword ||
!confirmPassword ||
newPassword.length < 8 ||
confirmPassword.length < 8
}
size="md"
mt="xs"
>
{t("firstLogin.changePassword", "Change Password")}
</Button>
</Stack>
</div>
</div>
);
}
export default function FirstLoginSlide({
username,
onPasswordChanged,
usingDefaultCredentials = false,
}: FirstLoginSlideProps): SlideConfig {
return {
key: "first-login",
title: "Set Your Password",
body: (
<FirstLoginForm
username={username}
onPasswordChanged={onPasswordChanged}
usingDefaultCredentials={usingDefaultCredentials}
/>
),
background: {
gradientStops: ["#059669", "#0891B2"], // Green to teal - security/trust colors
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -0,0 +1,252 @@
import {
useCallback,
useEffect,
useRef,
useState,
type FormEvent,
} from "react";
import {
Alert,
Box,
Button,
Group,
Loader,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { QRCodeSVG } from "qrcode.react";
import { SlideConfig } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import { accountService } from "@app/services/accountService";
import { useAccountLogout } from "@app/extensions/accountLogout";
import { useAuth } from "@app/auth/UseSession";
import LocalIcon from "@app/components/shared/LocalIcon";
import { BASE_PATH } from "@app/constants/app";
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
import { MfaSetupResponse } from "@app/responses/Mfa/MfaResponse";
interface MFASetupSlideProps {
onMfaSetupComplete?: () => void;
}
function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(
null,
);
const [mfaSetupCode, setMfaSetupCode] = useState("");
const [mfaError, setMfaError] = useState("");
const [mfaLoading, setMfaLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [setupComplete, setSetupComplete] = useState(false);
const setupCompleteRef = useRef(false);
const { signOut } = useAuth();
const accountLogout = useAccountLogout();
const qrLogoSrc = `${BASE_PATH}/modern-logo/StirlingPDFLogoNoTextDark.svg`;
const normalizeMfaCode = useCallback(
(value: string) => value.replace(/\D/g, "").slice(0, 6),
[],
);
const fetchMfaSetup = useCallback(async () => {
try {
setMfaLoading(true);
setMfaError("");
setMfaSetupCode("");
const data = await accountService.requestMfaSetup();
setMfaSetupData(data);
} catch (err) {
const axiosError = err as { response?: { data?: { error?: string } } };
setMfaError(
axiosError.response?.data?.error ||
"Unable to start two-factor setup. Please try again.",
);
} finally {
setMfaLoading(false);
}
}, []);
useEffect(() => {
setupCompleteRef.current = setupComplete;
}, [setupComplete]);
useEffect(() => {
void fetchMfaSetup();
return () => {
if (!setupCompleteRef.current) {
void accountService.cancelMfaSetup();
}
};
}, [fetchMfaSetup]);
const redirectToLogin = useCallback(() => {
window.location.assign("/login");
}, []);
const onLogout = useCallback(async () => {
await accountLogout({ signOut, redirectToLogin });
}, [accountLogout, redirectToLogin, signOut]);
const handleEnableMfa = useCallback(
async (event: FormEvent) => {
event.preventDefault();
if (!mfaSetupCode.trim()) {
setMfaError("Enter the authentication code to continue.");
return;
}
try {
setSubmitting(true);
setMfaError("");
await accountService.enableMfa(mfaSetupCode.trim());
setSetupComplete(true);
onMfaSetupComplete?.();
} catch (err) {
const axiosError = err as { response?: { data?: { error?: string } } };
setMfaError(
axiosError.response?.data?.error ||
"Unable to enable two-factor authentication. Check the code and try again.",
);
} finally {
setSubmitting(false);
}
},
[mfaSetupCode, onMfaSetupComplete],
);
const isReady = Boolean(mfaSetupData);
const mfaSetupContent = mfaSetupData ? (
<div className={styles.mfaSetupGrid}>
<Box className={styles.mfaQrCard}>
<QRCodeSVG
value={mfaSetupData.otpauthUri ?? ""}
size={168}
level="H"
imageSettings={{
src: qrLogoSrc,
height: 36,
width: 36,
excavate: true,
}}
/>
</Box>
<Stack gap="xs">
<Text size="sm" fw={600}>
Step-by-step
</Text>
<ol className={styles.mfaSteps}>
<li>Open Google Authenticator, Authy, or 1Password.</li>
<li>Scan the QR code or enter the setup key below.</li>
<li>Enter the 6-digit code from your app.</li>
</ol>
<Text size="xs" c="dimmed">
Setup key (manual entry)
</Text>
<TextInput
value={mfaSetupData.secret ?? ""}
readOnly
variant="filled"
styles={{ input: { fontFamily: "monospace" } }}
/>
</Stack>
</div>
) : null;
return (
<div className={styles.mfaSlideContent}>
<div className={styles.mfaCard}>
<Stack gap="md">
<Text size="sm" c="dimmed">
Secure your account by linking an authenticator app. Scan the QR
code or enter the setup key, then confirm the 6-digit code to
finish.
</Text>
{mfaError && (
<Alert
icon={<LocalIcon icon="error" width={16} height={16} />}
color="red"
variant="light"
>
{mfaError}
</Alert>
)}
{mfaLoading && !isReady && (
<Group gap="sm">
<Loader size="sm" />
<Text size="sm">Generating your QR code</Text>
</Group>
)}
{isReady && mfaSetupContent}
<form onSubmit={handleEnableMfa} className={styles.mfaForm}>
<Stack gap="sm">
<TextInput
id="mfa-setup-code"
label="Authentication code"
placeholder="123456"
value={mfaSetupCode}
onChange={(event) =>
setMfaSetupCode(normalizeMfaCode(event.currentTarget.value))
}
inputMode="numeric"
maxLength={6}
minLength={6}
disabled={!isReady || submitting || setupComplete}
/>
<Group justify="space-between" wrap="wrap">
<Button
type="button"
variant="light"
onClick={fetchMfaSetup}
disabled={mfaLoading || submitting || setupComplete}
>
Regenerate QR code
</Button>
<Button
type="submit"
loading={submitting}
disabled={
!isReady || setupComplete || mfaSetupCode.length < 6
}
>
Enable MFA
</Button>
<Button type="button" variant="light" onClick={onLogout}>
Logout
</Button>
</Group>
{setupComplete && (
<Alert color="green" variant="light">
MFA has been enabled. You can now continue.
</Alert>
)}
</Stack>
</form>
</Stack>
</div>
</div>
);
}
export default function MFASetupSlide({
onMfaSetupComplete,
}: MFASetupSlideProps = {}): SlideConfig {
return {
key: "mfa-setup-slide",
title: "Multi-Factor Authentication Setup",
body: <MFASetupContent onMfaSetupComplete={onMfaSetupComplete} />,
background: {
gradientStops: ["#059669", "#0891B2"], // Green to teal - security/trust colors
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -0,0 +1,95 @@
import React from "react";
import { Trans, useTranslation } from "react-i18next";
import { SlideConfig, LicenseNotice } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
interface PlanOverviewSlideProps {
isAdmin: boolean;
licenseNotice?: LicenseNotice;
loginEnabled?: boolean;
}
const DEFAULT_FREE_TIER_LIMIT = 5;
const PlanOverviewTitle: React.FC<{ isAdmin: boolean }> = ({ isAdmin }) => {
const { t } = useTranslation();
return (
<>
{isAdmin
? t("onboarding.planOverview.adminTitle", "Admin Overview")
: t("onboarding.planOverview.userTitle", "Plan Overview")}
</>
);
};
const AdminOverviewBody: React.FC<{
freeTierLimit: number;
loginEnabled: boolean;
}> = ({ freeTierLimit, loginEnabled }) => {
const adminBodyKey = loginEnabled
? "onboarding.planOverview.adminBodyLoginEnabled"
: "onboarding.planOverview.adminBodyLoginDisabled";
const defaultValue = loginEnabled
? "As an admin, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge."
: "Once you enable login mode, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge.";
return (
<Trans
i18nKey={adminBodyKey}
values={{ freeTierLimit }}
components={{ strong: <strong /> }}
defaults={defaultValue}
/>
);
};
const UserOverviewBody: React.FC = () => {
const { t } = useTranslation();
return (
<span>
{t(
"onboarding.planOverview.userBody",
"Invite teammates, assign roles, and keep your documents organized in one secure workspace. Enable login mode whenever you're ready to grow beyond solo use.",
)}
</span>
);
};
const PlanOverviewBody: React.FC<{
isAdmin: boolean;
freeTierLimit: number;
loginEnabled: boolean;
}> = ({ isAdmin, freeTierLimit, loginEnabled }) =>
isAdmin ? (
<AdminOverviewBody
freeTierLimit={freeTierLimit}
loginEnabled={loginEnabled}
/>
) : (
<UserOverviewBody />
);
export default function PlanOverviewSlide({
isAdmin,
licenseNotice,
loginEnabled = false,
}: PlanOverviewSlideProps): SlideConfig {
const freeTierLimit = licenseNotice?.freeTierLimit ?? DEFAULT_FREE_TIER_LIMIT;
return {
key: isAdmin ? "admin-overview" : "plan-overview",
title: <PlanOverviewTitle isAdmin={isAdmin} />,
body: (
<PlanOverviewBody
isAdmin={isAdmin}
freeTierLimit={freeTierLimit}
loginEnabled={loginEnabled}
/>
),
background: {
gradientStops: isAdmin ? ["#4F46E5", "#0EA5E9"] : ["#F97316", "#EF4444"],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -0,0 +1,65 @@
import React from "react";
import { Select } from "@mantine/core";
import { SlideConfig } from "@app/types/types";
import LocalIcon from "@app/components/shared/LocalIcon";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import i18n from "@app/i18n";
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
interface SecurityCheckSlideProps {
selectedRole: "admin" | "user" | null;
onRoleSelect: (role: "admin" | "user" | null) => void;
}
export default function SecurityCheckSlide({
selectedRole,
onRoleSelect,
}: SecurityCheckSlideProps): SlideConfig {
return {
key: "security-check",
title: "Security Check",
body: (
<div className={styles.securitySlideContent}>
<div className={styles.securityCard}>
<div className={styles.securityAlertRow}>
<LocalIcon
icon="error"
width={20}
height={20}
style={{ color: "#F04438", flexShrink: 0 }}
/>
<span>
{i18n.t(
"onboarding.securityCheck.message",
"The application has undergone significant changes recently. Your server admin's attention may be required. Please confirm your role to continue.",
)}
</span>
</div>
<Select
placeholder="Confirm your role"
value={selectedRole}
data={[
{ value: "admin", label: "Admin" },
{ value: "user", label: "User" },
]}
onChange={(value) =>
onRoleSelect((value as "admin" | "user") ?? null)
}
comboboxProps={{ withinPortal: true, zIndex: 5000 }}
styles={{
input: {
height: 48,
fontSize: 15,
},
}}
/>
</div>
</div>
),
background: {
gradientStops: ["#5B21B6", "#2563EB"],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -0,0 +1,62 @@
import React from "react";
import { Trans } from "react-i18next";
import { SlideConfig, LicenseNotice } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import i18n from "@app/i18n";
interface ServerLicenseSlideProps {
licenseNotice?: LicenseNotice;
}
const DEFAULT_FREE_TIER_LIMIT = 5;
export default function ServerLicenseSlide({
licenseNotice,
}: ServerLicenseSlideProps = {}): SlideConfig {
const freeTierLimit = licenseNotice?.freeTierLimit ?? DEFAULT_FREE_TIER_LIMIT;
const totalUsers = licenseNotice?.totalUsers ?? null;
const isOverLimit = licenseNotice?.isOverLimit ?? false;
const formattedTotalUsers =
totalUsers != null ? totalUsers.toLocaleString() : null;
const overLimitUserCopy = formattedTotalUsers ?? `more than ${freeTierLimit}`;
const title = isOverLimit
? i18n.t("onboarding.serverLicense.overLimitTitle", "Server License Needed")
: i18n.t("onboarding.serverLicense.freeTitle", "Server License");
const key = isOverLimit ? "server-license-over-limit" : "server-license";
const overLimitBody = (
<Trans
i18nKey="onboarding.serverLicense.overLimitBody"
values={{ freeTierLimit, overLimitUserCopy }}
components={{
strong: <strong />,
}}
defaults="Our licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. You have <strong>{{overLimitUserCopy}}</strong> Stirling users. To continue uninterrupted, upgrade to the Stirling Server plan - <strong>unlimited seats</strong>, PDF text editing, and full admin control for $99/server/mo."
/>
);
const freeBody = (
<Trans
i18nKey="onboarding.serverLicense.freeBody"
values={{ freeTierLimit }}
components={{
strong: <strong />,
}}
defaults="Our <strong>Open-Core</strong> licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. To scale uninterrupted, we recommend the Stirling Server plan - <strong>unlimited seats</strong> and <strong>SSO support</strong> for $99/server/mo."
/>
);
const body = isOverLimit ? overLimitBody : freeBody;
return {
key,
title,
body,
background: {
gradientStops: isOverLimit
? ["#F472B6", "#8B5CF6"]
: ["#F97316", "#F59E0B"],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -0,0 +1,26 @@
import React from "react";
import { Trans } from "react-i18next";
import i18n from "@app/i18n";
import { SlideConfig } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
export default function TourOverviewSlide(): SlideConfig {
return {
key: "tour-overview",
title: i18n.t("onboarding.tourOverview.title", "Tour Overview"),
body: (
<span className={styles.bodyCopyInner}>
<Trans
i18nKey="onboarding.tourOverview.body"
defaults="Stirling PDF V2 ships with dozens of tools and a refreshed layout. Take a quick tour to see what changed and where to find the features you need."
components={{ strong: <strong /> }}
/>
</span>
),
background: {
gradientStops: ["#2563EB", "#7C3AED"],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -0,0 +1,38 @@
import React from "react";
import { useTranslation, Trans } from "react-i18next";
import { SlideConfig } from "@app/types/types";
import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
function WelcomeSlideTitle() {
const { t } = useTranslation();
return (
<span className={styles.welcomeTitleContainer}>
{t("onboarding.welcomeSlide.title", "Welcome to Stirling")}
<span className={styles.v2Badge}>V2</span>
</span>
);
}
const WelcomeSlideBody = () => (
<span>
<Trans
i18nKey="onboarding.welcomeSlide.body"
components={{ strong: <strong /> }}
defaults="Stirling PDF is now ready for teams of all sizes. This update includes a new layout, powerful new admin capabilities, and our most requested feature - <strong>Edit Text</strong>."
/>
</span>
);
export default function WelcomeSlide(): SlideConfig {
return {
key: "welcome",
title: <WelcomeSlideTitle />,
body: <WelcomeSlideBody />,
background: {
gradientStops: ["#7C3AED", "#EC4899"],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -0,0 +1,29 @@
import { AnimatedCircleConfig } from "@app/types/types";
/**
* Unified circle background configuration used across all onboarding slides.
* Only gradient colors change between slides, creating smooth transitions.
*/
export const UNIFIED_CIRCLE_CONFIG: AnimatedCircleConfig[] = [
{
position: "bottom-left",
size: 270,
color: "rgba(255, 255, 255, 0.25)",
opacity: 0.9,
amplitude: 24,
duration: 4.5,
offsetX: 18,
offsetY: 14,
},
{
position: "top-right",
size: 300,
color: "rgba(255, 255, 255, 0.2)",
opacity: 0.9,
amplitude: 28,
duration: 4.5,
delay: 0.5,
offsetX: 24,
offsetY: 18,
},
];
@@ -0,0 +1,21 @@
export const addGlowToElements = (selectors: string[]) => {
selectors.forEach((selector) => {
const element = document.querySelector(selector);
if (element) {
if (selector === '[data-tour="settings-content-area"]') {
element.classList.add("tour-content-glow");
} else {
element.classList.add("tour-nav-glow");
}
}
});
};
export const removeAllGlows = () => {
document
.querySelectorAll(".tour-content-glow")
.forEach((el) => el.classList.remove("tour-content-glow"));
document
.querySelectorAll(".tour-nav-glow")
.forEach((el) => el.classList.remove("tour-nav-glow"));
};
@@ -0,0 +1,110 @@
/**
* Waits for a CSS selector to appear in the DOM using MutationObserver.
* Resolves immediately if already present; resolves after timeoutMs if it
* never appears (no throw — tour steps are best-effort).
*/
export function waitForElement(
selector: string,
timeoutMs = 7000,
): Promise<void> {
return new Promise((resolve) => {
if (typeof document === "undefined") {
resolve();
return;
}
if (document.querySelector(selector)) {
resolve();
return;
}
const observer = new MutationObserver(() => {
if (document.querySelector(selector)) {
clearTimeout(timer);
observer.disconnect();
resolve();
}
});
const timer = setTimeout(() => {
observer.disconnect();
resolve();
}, timeoutMs);
observer.observe(document.body, { childList: true, subtree: true });
});
}
const nudgeReactour = () => {
window.dispatchEvent(new Event("resize"));
requestAnimationFrame(() => window.dispatchEvent(new Event("resize")));
};
/**
* Waits for a CSS selector to be present AND have a non-zero bounding box,
* then nudges Reactour to recalculate its spotlight position.
*
* Uses MutationObserver to detect element insertion, then ResizeObserver to
* detect when the element receives layout dimensions.
*/
export function waitForHighlightable(
selector: string,
timeoutMs = 7000,
): Promise<void> {
return new Promise((resolve) => {
if (typeof document === "undefined") {
resolve();
return;
}
let mutationObserver: MutationObserver | null = null;
let resizeObserver: ResizeObserver | null = null;
const cleanup = () => {
mutationObserver?.disconnect();
resizeObserver?.disconnect();
};
const done = () => {
clearTimeout(timer);
cleanup();
nudgeReactour();
resolve();
};
const timer = setTimeout(done, timeoutMs);
const watchLayout = (el: HTMLElement) => {
if (el.getClientRects().length > 0) {
done();
return;
}
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.contentRect.width > 0 || entry.contentRect.height > 0) {
done();
return;
}
}
});
resizeObserver.observe(el);
};
const el = document.querySelector<HTMLElement>(selector);
if (el) {
watchLayout(el);
return;
}
mutationObserver = new MutationObserver(() => {
const found = document.querySelector<HTMLElement>(selector);
if (found) {
mutationObserver!.disconnect();
mutationObserver = null;
watchLayout(found);
}
});
mutationObserver.observe(document.body, { childList: true, subtree: true });
});
}
@@ -0,0 +1,65 @@
import { useEffect, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
import { markOnboardingCompleted } from "@app/components/onboarding/orchestrator/onboardingStorage";
const SESSION_KEY = "onboarding::bypass-all";
const PARAM_KEY = "bypassOnboarding";
function isTruthy(value: string | null): boolean {
return value?.toLowerCase() === "true";
}
function readStoredBypass(): boolean {
if (typeof window === "undefined") return false;
try {
return sessionStorage.getItem(SESSION_KEY) === "true";
} catch {
return false;
}
}
function setStoredBypass(enabled: boolean): void {
if (typeof window === "undefined") return;
try {
if (enabled) {
sessionStorage.setItem(SESSION_KEY, "true");
} else {
sessionStorage.removeItem(SESSION_KEY);
}
} catch {
// Ignore storage errors to avoid blocking the bypass flow
}
}
/**
* Detects the `bypassOnboarding` query parameter and stores it in session storage
* so that onboarding remains disabled while the app is open. Also marks onboarding
* as completed to ensure any dependent UI elements remain hidden.
*/
export function useBypassOnboarding(): boolean {
const location = useLocation();
const [bypassOnboarding, setBypassOnboarding] = useState<boolean>(() =>
readStoredBypass(),
);
const shouldBypassFromSearch = useMemo(() => {
try {
const params = new URLSearchParams(location.search);
return isTruthy(params.get(PARAM_KEY));
} catch {
return false;
}
}, [location.search]);
useEffect(() => {
const fromStorage = readStoredBypass();
const nextBypass = shouldBypassFromSearch || fromStorage;
setBypassOnboarding(nextBypass);
if (nextBypass) {
setStoredBypass(true);
markOnboardingCompleted();
}
}, [shouldBypassFromSearch]);
return bypassOnboarding;
}
@@ -0,0 +1,79 @@
/**
* useOnboardingDownload Hook
*
* Encapsulates OS detection and download URL logic for the desktop install slide.
*/
import { useState, useEffect, useMemo, useCallback } from "react";
import { useOs } from "@app/hooks/useOs";
import { DOWNLOAD_URLS } from "@app/constants/downloads";
interface OsInfo {
label: string;
url: string;
}
interface OsOption {
label: string;
url: string;
value: string;
}
interface UseOnboardingDownloadResult {
osInfo: OsInfo;
osOptions: OsOption[];
selectedDownloadUrl: string;
setSelectedDownloadUrl: (url: string) => void;
handleDownloadSelected: () => void;
}
export function useOnboardingDownload(): UseOnboardingDownloadResult {
const osType = useOs();
const [selectedDownloadUrl, setSelectedDownloadUrl] = useState<string>("");
const osInfo = useMemo<OsInfo>(() => {
switch (osType) {
case "windows":
return { label: "Windows", url: DOWNLOAD_URLS.WINDOWS };
case "mac":
return { label: "Mac", url: DOWNLOAD_URLS.MAC };
case "linux-x64":
case "linux-arm64":
return { label: "Linux", url: DOWNLOAD_URLS.LINUX_DOCS };
default:
return { label: "", url: "" };
}
}, [osType]);
const osOptions = useMemo<OsOption[]>(
() =>
[
{ label: "Windows", url: DOWNLOAD_URLS.WINDOWS, value: "windows" },
{ label: "Mac", url: DOWNLOAD_URLS.MAC, value: "mac" },
{ label: "Linux", url: DOWNLOAD_URLS.LINUX_DOCS, value: "linux" },
].filter((opt) => opt.url),
[],
);
// Initialize selected URL from detected OS
useEffect(() => {
if (!selectedDownloadUrl && osInfo.url) {
setSelectedDownloadUrl(osInfo.url);
}
}, [osInfo.url, selectedDownloadUrl]);
const handleDownloadSelected = useCallback(() => {
const downloadUrl = selectedDownloadUrl || osInfo.url;
if (downloadUrl) {
window.open(downloadUrl, "_blank", "noopener");
}
}, [selectedDownloadUrl, osInfo.url]);
return {
osInfo,
osOptions,
selectedDownloadUrl,
setSelectedDownloadUrl,
handleDownloadSelected,
};
}
@@ -0,0 +1,82 @@
import { useEffect, useCallback, useState } from "react";
import {
SERVER_LICENSE_REQUEST_EVENT,
START_TOUR_EVENT,
type ServerLicenseRequestPayload,
type TourType,
type StartTourPayload,
} from "@app/constants/events";
import type { OnboardingRuntimeState } from "@app/components/onboarding/orchestrator/onboardingConfig";
export function useServerLicenseRequest(): {
showLicenseSlide: boolean;
licenseNotice: OnboardingRuntimeState["licenseNotice"] | null;
closeLicenseSlide: () => void;
} {
const [showLicenseSlide, setShowLicenseSlide] = useState(false);
const [licenseNotice, setLicenseNotice] = useState<
OnboardingRuntimeState["licenseNotice"] | null
>(null);
useEffect(() => {
if (typeof window === "undefined") return;
const handleLicenseRequest = (event: Event) => {
const { detail } = event as CustomEvent<ServerLicenseRequestPayload>;
if (detail?.licenseNotice) {
setLicenseNotice({
totalUsers: detail.licenseNotice.totalUsers ?? null,
freeTierLimit: detail.licenseNotice.freeTierLimit ?? 5,
isOverLimit: detail.licenseNotice.isOverLimit ?? false,
requiresLicense: true,
});
}
setShowLicenseSlide(true);
};
window.addEventListener(SERVER_LICENSE_REQUEST_EVENT, handleLicenseRequest);
return () =>
window.removeEventListener(
SERVER_LICENSE_REQUEST_EVENT,
handleLicenseRequest,
);
}, []);
const closeLicenseSlide = useCallback(() => {
setShowLicenseSlide(false);
}, []);
return { showLicenseSlide, licenseNotice, closeLicenseSlide };
}
export function useTourRequest(): {
tourRequested: boolean;
requestedTourType: TourType;
clearTourRequest: () => void;
} {
const [tourRequested, setTourRequested] = useState(false);
const [requestedTourType, setRequestedTourType] =
useState<TourType>("whatsnew");
useEffect(() => {
if (typeof window === "undefined") return;
const handleTourRequest = (event: Event) => {
const { detail } = event as CustomEvent<StartTourPayload>;
setRequestedTourType(detail?.tourType ?? "whatsnew");
setTourRequested(true);
};
window.addEventListener(START_TOUR_EVENT, handleTourRequest);
return () =>
window.removeEventListener(START_TOUR_EVENT, handleTourRequest);
}, []);
const clearTourRequest = useCallback(() => {
setTourRequested(false);
}, []);
return { tourRequested, requestedTourType, clearTourRequest };
}
@@ -0,0 +1,217 @@
import type { StepType } from "@reactour/tour";
import type { TFunction } from "i18next";
import {
waitForElement,
waitForHighlightable,
} from "@app/components/onboarding/tourUtils";
export enum TourStep {
ALL_TOOLS,
SELECT_CROP_TOOL,
TOOL_INTERFACE,
FILES_BUTTON,
FILE_SOURCES,
WORKBENCH,
ACTIVE_FILES,
FILE_CHECKBOX,
CROP_SETTINGS,
RUN_BUTTON,
RESULTS,
FILE_REPLACEMENT,
PIN_BUTTON,
WRAP_UP,
}
interface UserStepActions {
saveWorkbenchState: () => void;
closeFilesModal: () => void;
backToAllTools: () => void;
selectCropTool: () => void;
loadSampleFile: () => void;
switchToActiveFiles: () => void;
pinFile: () => void;
revealFileCardHoverMenu: () => void;
modifyCropSettings: () => void;
executeTool: () => void;
openFilesModal: () => void;
openSettingsHelpSection: () => void;
}
interface CreateUserStepsConfigArgs {
t: TFunction;
actions: UserStepActions;
}
export function createUserStepsConfig({
t,
actions,
}: CreateUserStepsConfigArgs): Record<TourStep, StepType> {
const {
saveWorkbenchState,
closeFilesModal,
backToAllTools,
selectCropTool,
loadSampleFile,
switchToActiveFiles,
pinFile,
revealFileCardHoverMenu,
modifyCropSettings,
executeTool,
openFilesModal,
openSettingsHelpSection,
} = actions;
return {
[TourStep.ALL_TOOLS]: {
selector: '[data-tour="tool-panel"]',
content: t(
"onboarding.allTools",
"This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.",
),
position: "center",
padding: 0,
action: () => {
saveWorkbenchState();
closeFilesModal();
backToAllTools();
},
},
[TourStep.SELECT_CROP_TOOL]: {
selector: '[data-tour="tool-button-crop"]',
content: t(
"onboarding.selectCropTool",
"Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools.",
),
position: "right",
padding: 0,
actionAfter: () => selectCropTool(),
},
[TourStep.TOOL_INTERFACE]: {
selector: '[data-tour="tool-panel"]',
content: t(
"onboarding.toolInterface",
"This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet.",
),
position: "center",
padding: 0,
},
[TourStep.FILES_BUTTON]: {
selector: '[data-tour="files-button"]',
content: t(
"onboarding.filesButton",
"The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on.",
),
position: "right",
padding: 10,
actionAfter: () => openFilesModal(),
},
[TourStep.FILE_SOURCES]: {
selector: '[data-tour="file-sources"]',
content: t(
"onboarding.fileSources",
"You can upload new files or access recent files from here. For the tour, we'll just use a sample file.",
),
position: "right",
padding: 0,
action: async () => {
await waitForElement('[data-tour="file-sources"]', 5000);
await waitForHighlightable('[data-tour="file-sources"]', 5000);
},
actionAfter: () => {
loadSampleFile();
closeFilesModal();
},
},
[TourStep.WORKBENCH]: {
selector: '[data-tour="workbench"]',
content: t(
"onboarding.workbench",
"This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs.",
),
position: "center",
padding: 0,
},
[TourStep.ACTIVE_FILES]: {
selector: '[data-tour="workbench"]',
content: t(
"onboarding.activeFiles",
"The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process.",
),
position: "center",
padding: 0,
action: () => switchToActiveFiles(),
},
[TourStep.FILE_CHECKBOX]: {
selector: '[data-tour="file-card-checkbox"]',
content: t(
"onboarding.fileCheckbox",
"Clicking one of the files selects it for processing. You can select multiple files for batch operations.",
),
position: "top",
padding: 10,
},
[TourStep.CROP_SETTINGS]: {
selector: '[data-tour="crop-settings"]',
content: t(
"onboarding.cropSettings",
"Now that we've selected the file we want crop, we can configure the <strong>Crop</strong> tool to choose the area that we want to crop the PDF to.",
),
position: "left",
padding: 10,
action: () => modifyCropSettings(),
},
[TourStep.RUN_BUTTON]: {
selector: '[data-tour="run-button"]',
content: t(
"onboarding.runButton",
"Once the tool has been configured, this button allows you to run the tool on all the selected PDFs.",
),
position: "top",
padding: 10,
actionAfter: () => executeTool(),
},
[TourStep.RESULTS]: {
selector: '[data-tour="tool-panel"]',
content: t(
"onboarding.results",
"After the tool has finished running, the <strong>Review</strong> step will show a preview of the results in this panel, and allow you to undo the operation or download the file. ",
),
position: "center",
padding: 0,
},
[TourStep.FILE_REPLACEMENT]: {
selector: '[data-tour="file-card-checkbox"]',
content: t(
"onboarding.fileReplacement",
"The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools.",
),
position: "left",
padding: 10,
},
[TourStep.PIN_BUTTON]: {
selector: '[data-tour="file-card-pin"]',
content: t(
"onboarding.pinButton",
"You can use the <strong>Pin</strong> button if you'd rather your files stay active after running tools on them.",
),
position: "left",
padding: 10,
action: () => revealFileCardHoverMenu(),
actionAfter: () => pinFile(),
},
[TourStep.WRAP_UP]: {
selector: '[data-tour="admin-help-nav"]',
content: t(
"onboarding.wrapUp",
"You're all set! You can replay this tour anytime — just open <strong>Settings</strong> and find it here in the <strong>Tours</strong> section under Help.",
),
position: "right",
padding: 10,
action: async () => {
openSettingsHelpSection();
await waitForElement('[data-tour="admin-help-nav"]', 5000);
await waitForHighlightable('[data-tour="admin-help-nav"]', 5000);
},
},
};
}
@@ -0,0 +1,145 @@
import type { StepType } from "@reactour/tour";
import type { TFunction } from "i18next";
import {
waitForElement,
waitForHighlightable,
} from "@app/components/onboarding/tourUtils";
export enum WhatsNewTourStep {
QUICK_ACCESS,
LEFT_PANEL,
FILE_UPLOAD,
TOP_BAR,
PAGE_EDITOR_VIEW,
ACTIVE_FILES_VIEW,
WRAP_UP,
}
interface WhatsNewStepActions {
saveWorkbenchState: () => void;
closeFilesModal: () => void;
backToAllTools: () => void;
openFilesModal: () => void;
loadSampleFile: () => Promise<void> | void;
switchToViewer: () => void;
switchToPageEditor: () => void;
switchToActiveFiles: () => void;
}
interface CreateWhatsNewStepsConfigArgs {
t: TFunction;
actions: WhatsNewStepActions;
}
export function createWhatsNewStepsConfig({
t,
actions,
}: CreateWhatsNewStepsConfigArgs): Record<WhatsNewTourStep, StepType> {
const {
saveWorkbenchState,
closeFilesModal,
backToAllTools,
openFilesModal,
loadSampleFile,
switchToViewer,
switchToPageEditor,
switchToActiveFiles,
} = actions;
return {
[WhatsNewTourStep.QUICK_ACCESS]: {
selector: '[data-tour="quick-access-bar"]',
content: t(
"onboarding.whatsNew.quickAccess",
"Start at the <strong>Quick Access</strong> rail to jump between Reader, Automate, your files, and all the tours.",
),
position: "right",
padding: 10,
action: () => {
saveWorkbenchState();
closeFilesModal();
backToAllTools();
},
},
[WhatsNewTourStep.LEFT_PANEL]: {
selector: '[data-tour="tool-panel"]',
content: t(
"onboarding.whatsNew.leftPanel",
"The left <strong>Tools</strong> panel lists everything you can do. Browse categories or search to find a tool quickly.",
),
position: "center",
padding: 0,
},
[WhatsNewTourStep.FILE_UPLOAD]: {
selector: '[data-tour="files-button"]',
content: t(
"onboarding.whatsNew.fileUpload",
"Use the <strong>Files</strong> button to upload or pick a recent PDF. We will load a sample so you can see the workspace.",
),
position: "right",
padding: 10,
actionAfter: async () => {
openFilesModal();
await waitForElement('[data-tour="file-sources"]', 5000);
await Promise.resolve(loadSampleFile());
closeFilesModal();
switchToViewer();
// wait for file render and top controls to mount
await waitForElement('[data-tour="view-switcher"]', 7000);
await waitForHighlightable('[data-tour="view-switcher"]', 7000);
},
},
[WhatsNewTourStep.TOP_BAR]: {
selector: '[data-tour="view-switcher"]',
content: t(
"onboarding.whatsNew.topBar",
"The top bar lets you swap between <strong>Viewer</strong>, <strong>Page Editor</strong>, and <strong>Active Files</strong>.",
),
position: "bottom",
padding: 8,
// Ensure the switcher has mounted before this step renders
action: async () => {
switchToViewer();
await waitForElement('[data-tour="view-switcher"]', 7000);
await waitForHighlightable('[data-tour="view-switcher"]', 7000);
},
},
[WhatsNewTourStep.PAGE_EDITOR_VIEW]: {
selector: '[data-tour="view-switcher"]',
content: t(
"onboarding.whatsNew.pageEditorView",
"Switch to the Page Editor to reorder, rotate, or delete pages.",
),
position: "bottom",
padding: 8,
action: async () => {
switchToPageEditor();
await waitForElement('[data-tour="view-switcher"]', 7000);
await waitForHighlightable('[data-tour="view-switcher"]', 7000);
},
},
[WhatsNewTourStep.ACTIVE_FILES_VIEW]: {
selector: '[data-tour="view-switcher"]',
content: t(
"onboarding.whatsNew.activeFilesView",
"Use Active Files to see everything you have open and pick what to work on.",
),
position: "bottom",
padding: 8,
action: async () => {
switchToActiveFiles();
await waitForElement('[data-tour="view-switcher"]', 7000);
await waitForHighlightable('[data-tour="view-switcher"]', 7000);
},
},
[WhatsNewTourStep.WRAP_UP]: {
selector: '[data-tour="help-button"]',
content: t(
"onboarding.whatsNew.wrapUp",
"That is what is new in V2. Open the <strong>Tours</strong> menu anytime to replay this, the Tools tour, or the Admin tour.",
),
position: "right",
padding: 10,
},
};
}
@@ -0,0 +1,65 @@
import { useState } from "react";
import classes from "@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css";
import PageSelectionInput from "@app/components/pageEditor/bulkSelectionPanel/PageSelectionInput";
import SelectedPagesDisplay from "@app/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay";
import PageSelectionSyntaxHint from "@app/components/shared/PageSelectionSyntaxHint";
import AdvancedSelectionPanel from "@app/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel";
interface BulkSelectionPanelProps {
csvInput: string;
setCsvInput: (value: string) => void;
selectedPageIds: string[];
displayDocument?: { pages: { id: string; pageNumber: number }[] };
onUpdatePagesFromCSV: (override?: string) => void;
}
const BulkSelectionPanel = ({
csvInput,
setCsvInput,
selectedPageIds,
displayDocument,
onUpdatePagesFromCSV,
}: BulkSelectionPanelProps) => {
const [advancedOpened, setAdvancedOpened] = useState<boolean>(false);
const maxPages = displayDocument?.pages?.length ?? 0;
const handleClear = () => {
setCsvInput("");
onUpdatePagesFromCSV("");
};
return (
<div className={classes.panelContainer}>
<PageSelectionInput
csvInput={csvInput}
setCsvInput={setCsvInput}
onUpdatePagesFromCSV={onUpdatePagesFromCSV}
onClear={handleClear}
advancedOpened={advancedOpened}
onToggleAdvanced={setAdvancedOpened}
/>
<PageSelectionSyntaxHint
input={csvInput}
maxPages={maxPages}
variant="panel"
/>
<SelectedPagesDisplay
selectedPageIds={selectedPageIds}
displayDocument={displayDocument}
syntaxError={null}
/>
<AdvancedSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}
onUpdatePagesFromCSV={onUpdatePagesFromCSV}
maxPages={maxPages}
advancedOpened={advancedOpened}
/>
</div>
);
};
export default BulkSelectionPanel;
@@ -0,0 +1,72 @@
.gridContainer {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.virtualRows {
width: 100%;
position: relative;
margin: 0 auto;
}
.virtualRow {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.rowContent {
display: flex;
justify-content: flex-start;
height: 100%;
align-items: center;
position: relative;
}
.selectionBox {
position: absolute;
border: 2px dashed #3b82f6;
background-color: rgba(59, 130, 246, 0.1);
pointer-events: none;
}
.dropIndicator {
position: absolute;
width: 4px;
background-color: rgba(96, 165, 250, 0.8);
border-radius: 2px;
pointer-events: none;
}
.dragOverlay {
position: relative;
cursor: grabbing;
}
.dragOverlayBadge {
position: absolute;
top: -8px;
right: -8px;
background-color: #3b82f6;
color: #ffffff;
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: bold;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.dragOverlayPreview {
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
opacity: 0.5;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,391 @@
import React, {
useState,
useCallback,
useRef,
useMemo,
useEffect,
} from "react";
import { ActionIcon, CheckboxIndicator } from "@mantine/core";
import { useTranslation } from "react-i18next";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import PushPinIcon from "@mui/icons-material/PushPin";
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import styles from "@app/components/pageEditor/PageEditor.module.css";
import { useFileContext } from "@app/contexts/FileContext";
import { FileId } from "@app/types/file";
import { PrivateContent } from "@app/components/shared/PrivateContent";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
import { downloadFile } from "@app/services/downloadService";
interface FileItem {
id: FileId;
name: string;
pageCount: number;
thumbnail: string | null;
size: number;
modifiedAt?: number | string | Date;
}
interface FileThumbnailProps {
file: FileItem;
index: number;
totalFiles: number;
selectedFiles: string[];
selectionMode: boolean;
onToggleFile: (fileId: FileId) => void;
onDeleteFile: (fileId: FileId) => void;
onViewFile: (fileId: FileId) => void;
onSetStatus: (status: string) => void;
onReorderFiles?: (
sourceFileId: FileId,
targetFileId: FileId,
selectedFileIds: FileId[],
) => void;
onDownloadFile?: (fileId: FileId) => void;
toolMode?: boolean;
isSupported?: boolean;
}
const FileThumbnail = ({
file,
index,
selectedFiles,
onToggleFile,
onDeleteFile,
onSetStatus,
onReorderFiles,
onDownloadFile,
isSupported = true,
}: FileThumbnailProps) => {
const { t } = useTranslation();
const terminology = useFileActionTerminology();
const icons = useFileActionIcons();
const DownloadOutlinedIcon = icons.download;
const { pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
// ---- Drag state ----
const [isDragging, setIsDragging] = useState(false);
const dragElementRef = useRef<HTMLDivElement | null>(null);
const [actionsWidth, setActionsWidth] = useState<number | undefined>(
undefined,
);
const [showActions, setShowActions] = useState(false);
// Resolve the actual File object for pin/unpin operations
const actualFile = useMemo(() => {
return activeFiles.find((f) => f.fileId === file.id);
}, [activeFiles, file.id]);
const isPinned = actualFile ? isFilePinned(actualFile) : false;
const downloadSelectedFile = useCallback(() => {
// Prefer parent-provided handler if available
if (typeof onDownloadFile === "function") {
onDownloadFile(file.id);
return;
}
// Fallback: attempt to download using the File object if provided
const maybeFile = (file as unknown as { file?: File }).file;
if (maybeFile instanceof File) {
void downloadFile({
data: maybeFile,
filename: maybeFile.name || file.name || "download",
});
return;
}
// If we can't find a way to download, surface a status message
onSetStatus?.(terminology.downloadUnavailable);
}, [file, onDownloadFile, onSetStatus, t]);
const handleRef = useRef<HTMLSpanElement | null>(null);
// ---- Selection ----
const isSelected = selectedFiles.includes(file.id);
// ---- Drag & drop wiring ----
const fileElementRef = useCallback(
(element: HTMLDivElement | null) => {
if (!element) return;
dragElementRef.current = element;
const dragCleanup = draggable({
element,
getInitialData: () => ({
type: "file",
fileId: file.id,
fileName: file.name,
selectedFiles: [file.id], // Always drag only this file, ignore selection state
}),
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
},
});
const dropCleanup = dropTargetForElements({
element,
getData: () => ({
type: "file",
fileId: file.id,
}),
canDrop: ({ source }) => {
const sourceData = source.data;
return sourceData.type === "file" && sourceData.fileId !== file.id;
},
onDrop: ({ source }) => {
const sourceData = source.data;
if (sourceData.type === "file" && onReorderFiles) {
const sourceFileId = sourceData.fileId as FileId;
const selectedFileIds = sourceData.selectedFiles as FileId[];
onReorderFiles(sourceFileId, file.id, selectedFileIds);
}
},
});
return () => {
dragCleanup();
dropCleanup();
};
},
[file.id, file.name, selectedFiles, onReorderFiles],
);
// Update dropdown width on resize
useEffect(() => {
const update = () => {
if (dragElementRef.current)
setActionsWidth(dragElementRef.current.offsetWidth);
};
update();
window.addEventListener("resize", update);
return () => window.removeEventListener("resize", update);
}, []);
// Close the actions dropdown when hovering outside this file card (and its dropdown)
useEffect(() => {
if (!showActions) return;
const isInsideCard = (target: EventTarget | null) => {
const container = dragElementRef.current;
if (!container) return false;
return target instanceof Node && container.contains(target);
};
const handleMouseMove = (e: MouseEvent) => {
if (!isInsideCard(e.target)) {
setShowActions(false);
}
};
const handleTouchStart = (e: TouchEvent) => {
// On touch devices, close if the touch target is outside the card
if (!isInsideCard(e.target)) {
setShowActions(false);
}
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("touchstart", handleTouchStart, {
passive: true,
});
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("touchstart", handleTouchStart);
};
}, [showActions]);
// ---- Card interactions ----
const handleCardClick = () => {
if (!isSupported) return;
onToggleFile(file.id);
};
return (
<div
ref={fileElementRef}
data-file-id={file.id}
data-testid="file-thumbnail"
data-selected={isSelected}
data-supported={isSupported}
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
style={{
opacity: isSupported ? (isDragging ? 0.9 : 1) : 0.5,
filter: isSupported ? "none" : "grayscale(50%)",
}}
tabIndex={0}
role="listitem"
aria-selected={isSelected}
onClick={handleCardClick}
>
{/* Header bar */}
<div
className={`${styles.header} ${isSelected ? styles.headerSelected : styles.headerResting}`}
>
{/* Logo/checkbox area */}
<div className={styles.logoMark}>
{isSupported ? (
<CheckboxIndicator
checked={isSelected}
onChange={() => onToggleFile(file.id)}
color="var(--checkbox-checked-bg)"
/>
) : (
<div className={styles.unsupportedPill}>
<span>{t("unsupported", "Unsupported")}</span>
</div>
)}
</div>
{/* Centered index */}
<div
className={styles.headerIndex}
aria-label={`Position ${index + 1}`}
>
{index + 1}
</div>
{/* Kebab menu */}
<ActionIcon
aria-label={t("moreOptions", "More options")}
variant="subtle"
className={styles.kebab}
onClick={(e) => {
e.stopPropagation();
setShowActions((v) => !v);
}}
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</div>
{/* Actions overlay */}
{showActions && (
<div
className={styles.actionsOverlay}
style={{ width: actionsWidth }}
onClick={(e) => e.stopPropagation()}
>
<button
className={styles.actionRow}
onClick={() => {
if (actualFile) {
if (isPinned) {
unpinFile(actualFile);
onSetStatus?.(`Unpinned ${file.name}`);
} else {
pinFile(actualFile);
onSetStatus?.(`Pinned ${file.name}`);
}
}
setShowActions(false);
}}
>
{isPinned ? (
<PushPinIcon fontSize="small" />
) : (
<PushPinOutlinedIcon fontSize="small" />
)}
<span>{isPinned ? t("unpin", "Unpin") : t("pin", "Pin")}</span>
</button>
<button
className={styles.actionRow}
onClick={() => {
downloadSelectedFile();
setShowActions(false);
}}
>
<DownloadOutlinedIcon fontSize="small" />
<span>{terminology.download}</span>
</button>
<div className={styles.actionsDivider} />
<button
className={`${styles.actionRow} ${styles.actionDanger}`}
onClick={() => {
onDeleteFile(file.id);
onSetStatus(`Deleted ${file.name}`);
setShowActions(false);
}}
>
<DeleteOutlineIcon fontSize="small" />
<span>{t("delete", "Delete")}</span>
</button>
</div>
)}
{/* File content area */}
<div className="file-container w-[90%] h-[80%] relative">
{/* Stacked file effect - multiple shadows to simulate pages */}
<div
style={{
width: "100%",
height: "100%",
backgroundColor: "var(--mantine-color-gray-1)",
borderRadius: 6,
border: "1px solid var(--mantine-color-gray-3)",
padding: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
boxShadow: "2px 2px 0 rgba(0,0,0,0.1), 4px 4px 0 rgba(0,0,0,0.05)",
}}
>
{file.thumbnail && (
<PrivateContent>
<img
src={file.thumbnail}
alt={file.name}
draggable={false}
onError={(e) => {
// Hide broken image if blob URL was revoked
const img = e.target as HTMLImageElement;
img.style.display = "none";
}}
style={{
maxWidth: "80%",
maxHeight: "80%",
objectFit: "contain",
borderRadius: 0,
background: "#ffffff",
border: "1px solid var(--border-default)",
display: "block",
marginLeft: "auto",
marginRight: "auto",
alignSelf: "start",
}}
/>
</PrivateContent>
)}
</div>
{/* Pin indicator (bottom-left) */}
{isPinned && (
<span className={styles.pinIndicator} aria-hidden>
<PushPinIcon fontSize="small" />
</span>
)}
{/* Drag handle (span wrapper so we can attach a ref reliably) */}
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
<DragIndicatorIcon fontSize="small" />
</span>
</div>
</div>
);
};
export default React.memo(FileThumbnail);
@@ -0,0 +1,100 @@
/* Page container hover effects - optimized for smooth scrolling */
.pageContainer {
transition: transform 0.2s ease-in-out;
/* Enable hardware acceleration for smoother scrolling */
will-change: transform;
transform: translateZ(0);
backface-visibility: hidden;
}
.pageContainer:hover {
transform: scale(1.02) translateZ(0);
}
.pageSurface {
transition: background-color 0.4s ease;
}
.pageJustMoved {
animation: pageMovedHighlight 1.2s ease-out;
}
@keyframes pageMovedHighlight {
0% {
background-color: rgba(59, 130, 246, 0.32);
}
60% {
background-color: rgba(59, 130, 246, 0.12);
}
100% {
background-color: rgba(59, 130, 246, 0);
}
}
.pageContainer:hover .pageNumber {
opacity: 1 !important;
}
.pageContainer:hover .pageHoverControls {
opacity: 0.95 !important;
}
/* Checkbox container - prevent transform inheritance */
.checkboxContainer {
transform: none !important;
transition: none !important;
}
/* Page movement animations */
.pageMoveAnimation {
transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
/* Animations */
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Action styles */
.actionRow:hover {
background: var(--hover-bg);
}
.actionDanger {
color: var(--text-brand-accent);
}
.actionsDivider {
height: 1px;
background: var(--border-default);
margin: 4px 0;
}
.pinIndicator {
position: absolute;
bottom: 4px;
left: 4px;
z-index: 1;
color: rgba(0, 0, 0, 0.35); /* match drag handle color */
}
.unsupportedPill {
margin-left: 1.75rem;
background: #6b7280;
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 10px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
min-width: 80px;
height: 20px;
}
@@ -0,0 +1,816 @@
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
import { useFileState, useFileActions } from "@app/contexts/FileContext";
import {
useNavigationGuard,
useNavigationState,
} from "@app/contexts/NavigationContext";
import { usePageEditor } from "@app/contexts/PageEditorContext";
import { PageEditorFunctions, PDFPage } from "@app/types/pageEditor";
// Thumbnail generation is now handled by individual PageThumbnail components
import "@app/components/pageEditor/PageEditor.module.css";
import PageThumbnail from "@app/components/pageEditor/PageThumbnail";
import DragDropGrid from "@app/components/pageEditor/DragDropGrid";
import SkeletonLoader from "@app/components/shared/SkeletonLoader";
import { FileId } from "@app/types/file";
import { GRID_CONSTANTS } from "@app/components/pageEditor/constants";
import { useInitialPageDocument } from "@app/components/pageEditor/hooks/useInitialPageDocument";
import { usePageDocument } from "@app/components/pageEditor/hooks/usePageDocument";
import { usePageEditorState } from "@app/components/pageEditor/hooks/usePageEditorState";
import { usePageEditorWorkbenchBarButtons } from "@app/components/pageEditor/pageEditorWorkbenchBarButtons";
import { useFileColorMap } from "@app/components/pageEditor/hooks/useFileColorMap";
import { useWheelZoom } from "@app/hooks/useWheelZoom";
import { useEditedDocumentState } from "@app/components/pageEditor/hooks/useEditedDocumentState";
import { useUndoManagerState } from "@app/components/pageEditor/hooks/useUndoManagerState";
import { usePageSelectionManager } from "@app/components/pageEditor/hooks/usePageSelectionManager";
import { usePageEditorCommands } from "@app/components/pageEditor/hooks/useEditorCommands";
import { usePageEditorExport } from "@app/components/pageEditor/hooks/usePageEditorExport";
import { useThumbnailGeneration } from "@app/hooks/useThumbnailGeneration";
import { convertSplitPageIdsToIndexes } from "@app/components/pageEditor/utils/splitPositions";
export interface PageEditorProps {
onFunctionsReady?: (functions: PageEditorFunctions) => void;
}
const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
// Use split contexts to prevent re-renders
const { state, selectors } = useFileState();
const { actions } = useFileActions();
// Navigation guard for unsaved changes
const {
setHasUnsavedChanges,
registerNavigationWarningHandlers,
unregisterNavigationWarningHandlers,
} = useNavigationGuard();
const navigationState = useNavigationState();
// Get PageEditor coordination functions
const {
updateFileOrderFromPages,
fileOrder,
reorderedPages,
clearReorderedPages,
updateCurrentPages,
savePersistedDocument,
clearPersistedDocument,
} = usePageEditor();
const [visiblePageIds, setVisiblePageIds] = useState<string[]>([]);
const thumbnailRequestsRef = useRef<Set<string>>(new Set());
const { requestThumbnail, getThumbnailFromCache } = useThumbnailGeneration();
const handleVisibleItemsChange = useCallback((items: PDFPage[]) => {
setVisiblePageIds((prev) => {
const ids = items.map((item) => item.id);
if (
prev.length === ids.length &&
prev.every((id, index) => id === ids[index])
) {
return prev;
}
return ids;
});
}, []);
// Zoom state management
const [zoomLevel, setZoomLevel] = useState(1.0);
const containerRef = useRef<HTMLDivElement>(null);
const [isContainerHovered, setIsContainerHovered] = useState(false);
const rootFontSize = useMemo(() => {
if (typeof window === "undefined") {
return 16;
}
const computed = getComputedStyle(document.documentElement).fontSize;
const parsed = parseFloat(computed);
return Number.isNaN(parsed) ? 16 : parsed;
}, []);
const itemGapPx = useMemo(() => {
return parseFloat(GRID_CONSTANTS.ITEM_GAP) * rootFontSize * zoomLevel;
}, [rootFontSize, zoomLevel]);
// Zoom actions
const zoomIn = useCallback(() => {
setZoomLevel((prev) => Math.min(prev + 0.1, 3.0));
}, []);
const zoomOut = useCallback(() => {
setZoomLevel((prev) => Math.max(prev - 0.1, 0.5));
}, []);
// Derive page editor files from PageEditorContext's fileOrder (page editor workspace order)
// Filter to only show PDF files (PageEditor only supports PDFs)
// Use stable string keys to prevent infinite loops
// Cache file objects to prevent infinite re-renders from new object references
const fileOrderKey = fileOrder.join(",");
const selectedIdsKey = [...state.ui.selectedFileIds].sort().join(",");
const filesSignature = selectors.getFilesSignature();
const fileObjectsRef = useRef(new Map<FileId, any>());
const gridItemRefsRef = useRef<React.MutableRefObject<
Map<string, HTMLDivElement>
> | null>(null);
const pageEditorFiles = useMemo(() => {
const cache = fileObjectsRef.current;
const newFiles: any[] = [];
fileOrder.forEach((fileId) => {
const stub = selectors.getStirlingFileStub(fileId);
const isSelected = state.ui.selectedFileIds.includes(fileId);
const isPdf = stub?.name?.toLowerCase().endsWith(".pdf") ?? false;
if (!isPdf) return; // Skip non-PDFs
const cached = cache.get(fileId);
// Check if data actually changed (compare by fileId, not position)
if (
cached &&
cached.fileId === fileId &&
cached.name === (stub?.name || "") &&
cached.versionNumber === stub?.versionNumber &&
cached.isSelected === isSelected
) {
// Reuse existing object reference
newFiles.push(cached);
} else {
// Create new object only if data changed
const newFile = {
fileId,
name: stub?.name || "",
versionNumber: stub?.versionNumber,
isSelected,
};
cache.set(fileId, newFile);
newFiles.push(newFile);
}
});
// Clean up removed files from cache
const activeIds = new Set(newFiles.map((f) => f.fileId));
for (const cachedId of cache.keys()) {
if (!activeIds.has(cachedId)) {
cache.delete(cachedId);
}
}
return newFiles;
}, [fileOrderKey, selectedIdsKey, filesSignature]);
// Get ALL file IDs in order (not filtered by selection)
const orderedFileIds = useMemo(() => {
return pageEditorFiles.map((f) => f.fileId);
}, [pageEditorFiles]);
// Get selected file IDs for filtering
const selectedFileIds = useMemo(() => {
return pageEditorFiles.filter((f) => f.isSelected).map((f) => f.fileId);
}, [pageEditorFiles]);
const activeFilesSignature = selectors.getFilesSignature();
// Check if there are any PDF files in FileContext (bypasses fileOrder, which is
// populated asynchronously via effect). Used to avoid a one-render flash of
// "No PDF files loaded" when PageEditor first mounts after files are added.
const hasPdfFiles = useMemo(() => {
return state.files.ids.some((id) => {
const stub = selectors.getStirlingFileStub(id);
return stub?.name?.toLowerCase().endsWith(".pdf") ?? false;
});
}, [state.files.ids, selectors]);
// UI state
const globalProcessing = state.ui.isProcessing;
const initialDocument = useInitialPageDocument();
const { document: mergedPdfDocument } = usePageDocument();
const { setEditedDocument, displayDocument, getEditedDocument } =
useEditedDocumentState({
initialDocument,
mergedPdfDocument,
reorderedPages,
clearReorderedPages,
fileOrder,
updateCurrentPages,
});
const displayDocumentRef = useRef(displayDocument);
useEffect(() => {
displayDocumentRef.current = displayDocument;
}, [displayDocument]);
const queueThumbnailRequestsForPages = useCallback(
(pageIds: string[]) => {
const doc = displayDocumentRef.current;
if (!doc || pageIds.length === 0) return;
const loadedCount = doc.pages.filter((p) => p.thumbnail).length;
const pending = thumbnailRequestsRef.current.size;
const MAX_CONCURRENT_THUMBNAILS =
loadedCount < 8
? 1
: doc.totalPages < 20
? 3
: doc.totalPages < 50
? 5
: 8;
const available = Math.max(0, MAX_CONCURRENT_THUMBNAILS - pending);
if (available === 0) return;
const toLoad: string[] = [];
for (const pageId of pageIds) {
if (toLoad.length >= available) break;
if (thumbnailRequestsRef.current.has(pageId)) continue;
const page = doc.pages.find((p) => p.id === pageId);
if (!page || page.thumbnail) continue;
toLoad.push(pageId);
}
if (toLoad.length === 0) return;
toLoad.forEach((pageId) => {
const page = doc.pages.find((p) => p.id === pageId);
if (!page) return;
const cached = getThumbnailFromCache(pageId);
if (cached) {
thumbnailRequestsRef.current.add(pageId);
Promise.resolve(cached)
.then((cache) => {
setEditedDocument((prev) => {
if (!prev) return prev;
const pageIndex = prev.pages.findIndex((p) => p.id === pageId);
if (pageIndex === -1) return prev;
const updated = [...prev.pages];
updated[pageIndex] = {
...prev.pages[pageIndex],
thumbnail: cache,
};
return { ...prev, pages: updated };
});
})
.finally(() => {
thumbnailRequestsRef.current.delete(pageId);
});
return;
}
const fileId = page.originalFileId;
if (!fileId) return;
const file = selectors.getFile(fileId);
if (!file) return;
thumbnailRequestsRef.current.add(pageId);
requestThumbnail(
pageId,
file,
page.originalPageNumber || page.pageNumber,
)
.then((thumbnail) => {
if (thumbnail) {
setEditedDocument((prev) => {
if (!prev) return prev;
const pageIndex = prev.pages.findIndex((p) => p.id === pageId);
if (pageIndex === -1) return prev;
const updated = [...prev.pages];
updated[pageIndex] = { ...prev.pages[pageIndex], thumbnail };
return { ...prev, pages: updated };
});
}
})
.catch((error) => {
console.error("[Thumbnail Loading] Error:", error);
})
.finally(() => {
thumbnailRequestsRef.current.delete(pageId);
});
});
},
[getThumbnailFromCache, requestThumbnail, selectors, setEditedDocument],
);
useEffect(() => {
if (!displayDocument) {
return;
}
queueThumbnailRequestsForPages(visiblePageIds);
}, [displayDocument, visiblePageIds, queueThumbnailRequestsForPages]);
const lastInitialDocumentSignatureRef = useRef<string | null>(null);
const displayDocumentId = displayDocument?.id ?? null;
const displayDocumentLength = displayDocument?.pages.length ?? 0;
useEffect(() => {
if (!displayDocument || displayDocument.pages.length === 0) {
lastInitialDocumentSignatureRef.current = null;
return;
}
const signature = `${displayDocumentId}:${displayDocumentLength}`;
if (lastInitialDocumentSignatureRef.current === signature) {
return;
}
const INITIAL_VISIBLE_PAGE_COUNT = 8;
const initialIds = displayDocument.pages
.slice(0, INITIAL_VISIBLE_PAGE_COUNT)
.map((page) => page.id);
queueThumbnailRequestsForPages(initialIds);
lastInitialDocumentSignatureRef.current = signature;
}, [
displayDocumentId,
displayDocumentLength,
queueThumbnailRequestsForPages,
]);
useEffect(() => {
setVisiblePageIds([]);
}, [displayDocumentId]);
useEffect(() => {
return () => {
if (navigationState.workbench !== "pageEditor") {
return;
}
const doc = displayDocumentRef.current;
if (doc && doc.pages.length > 0) {
const signature = doc.pages.map((page) => page.id).join(",");
savePersistedDocument(doc, signature);
}
};
}, [savePersistedDocument, navigationState.workbench]);
// UI state management
const {
selectionMode,
selectedPageIds,
movingPage,
isAnimating,
splitPositions,
exportLoading,
setSelectionMode,
setSelectedPageIds,
setMovingPage,
setSplitPositions,
setExportLoading,
togglePage,
toggleSelectAll,
animateReorder,
} = usePageEditorState();
const {
csvInput,
setCsvInput,
totalPages,
getPageNumbersFromIds,
handleSelectAll,
handleDeselectAll,
handleSetSelectedPages,
updatePagesFromCSV,
} = usePageSelectionManager({
displayDocument,
selectedPageIds,
setSelectedPageIds,
setSelectionMode,
toggleSelectAll,
activeFilesSignature,
});
// Grid container ref for positioning split indicators
const gridContainerRef = useRef<HTMLDivElement>(null);
const {
canUndo,
canRedo,
executeCommandWithTracking,
handleUndo,
handleRedo,
clearUndoHistory,
} = useUndoManagerState({
setHasUnsavedChanges,
});
const {
createRotateCommand,
createDeleteCommand,
createSplitCommand,
executeCommand,
handleRotate,
handleDelete,
handleDeletePage,
handleSplit,
handleSplitAll,
handlePageBreak,
handlePageBreakAll,
handleInsertFiles,
handleReorderPages,
closePdf,
} = usePageEditorCommands({
displayDocument,
getEditedDocument,
setEditedDocument,
splitPositions,
setSplitPositions,
selectedPageIds,
setSelectedPageIds,
getPageNumbersFromIds,
executeCommandWithTracking,
updateFileOrderFromPages,
actions,
selectors,
setSelectionMode,
clearUndoHistory,
});
const { onExportSelected, onExportAll, applyChanges } = usePageEditorExport({
displayDocument,
selectedPageIds,
splitPositions,
selectedFileIds,
selectors,
actions,
setHasUnsavedChanges,
exportLoading,
setExportLoading,
setSplitPositions,
clearPersistedDocument,
updateCurrentPages,
});
// Register navigation warning handlers for the global modal
useEffect(() => {
registerNavigationWarningHandlers({
onApplyAndContinue: async () => {
await applyChanges();
},
onExportAndContinue: async () => {
await onExportAll();
},
});
return () => unregisterNavigationWarningHandlers();
}, [
applyChanges,
onExportAll,
registerNavigationWarningHandlers,
unregisterNavigationWarningHandlers,
]);
// Derived values for usePageEditorWorkbenchBarButtons (must be after displayDocument)
const selectedPageCount = selectedPageIds.length;
usePageEditorWorkbenchBarButtons({
totalPages,
selectedPageCount,
csvInput,
setCsvInput,
selectedPageIds,
displayDocument: displayDocument || undefined,
updatePagesFromCSV,
handleSelectAll,
handleDeselectAll,
handleDelete,
onExportSelected,
onSaveChanges: applyChanges,
exportLoading,
});
// Export preview function - defined after export functions to avoid circular dependency
const handleExportPreview = useCallback(
(selectedOnly: boolean = false) => {
if (!displayDocument) return;
// For now, trigger the actual export directly
// In the original, this would show a preview modal first
if (selectedOnly) {
onExportSelected();
} else {
onExportAll();
}
},
[displayDocument, onExportSelected, onExportAll],
);
// Expose functions to parent component
useEffect(() => {
if (onFunctionsReady) {
onFunctionsReady({
handleUndo,
handleRedo,
canUndo,
canRedo,
handleRotate,
handleDelete,
handleSplit,
handleSplitAll,
handlePageBreak,
handlePageBreakAll,
handleSelectAll,
handleDeselectAll,
handleSetSelectedPages,
showExportPreview: handleExportPreview,
onExportSelected,
onExportAll,
applyChanges,
exportLoading,
selectionMode,
selectedPageIds,
displayDocument: displayDocument || undefined,
splitPositions,
totalPages: displayDocument?.pages.length || 0,
closePdf,
});
}
}, [
onFunctionsReady,
handleUndo,
handleRedo,
canUndo,
canRedo,
handleRotate,
handleDelete,
handleSplit,
handleSplitAll,
handlePageBreak,
handlePageBreakAll,
handleSelectAll,
handleDeselectAll,
handleSetSelectedPages,
handleExportPreview,
onExportSelected,
onExportAll,
applyChanges,
exportLoading,
selectionMode,
selectedPageIds,
splitPositions,
displayDocument?.pages.length,
closePdf,
]);
useWheelZoom({
ref: containerRef,
onZoomIn: zoomIn,
onZoomOut: zoomOut,
enabled: !!displayDocument,
});
// Handle keyboard zoom shortcuts
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!isContainerHovered) return;
// Check if Ctrl (Windows/Linux) or Cmd (Mac) is pressed
if (event.ctrlKey || event.metaKey) {
if (event.key === "=" || event.key === "+") {
// Ctrl+= or Ctrl++ for zoom in
event.preventDefault();
zoomIn();
} else if (event.key === "-" || event.key === "_") {
// Ctrl+- for zoom out
event.preventDefault();
zoomOut();
} else if (event.key === "0") {
// Ctrl+0 for reset zoom
event.preventDefault();
setZoomLevel(1.0);
}
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [isContainerHovered, zoomIn, zoomOut]);
// Display all pages - use edited or original document
const displayedPages = displayDocument?.pages || [];
// Track color assignments by insertion order (files keep their color)
const fileColorIndexMap = useFileColorMap(orderedFileIds);
// Memoize renderItem to prevent DragDropGrid's React.memo from blocking updates
// when selectedPageIds changes
const renderItemCallback = useCallback(
(
page: PDFPage,
index: number,
refs: React.MutableRefObject<Map<string, HTMLDivElement>>,
boxSelectedIds: string[],
clearBoxSelection: () => void,
activeDragIds: string[],
justMoved: boolean,
dragHandleProps?: any,
zoomLevelParam?: number,
) => {
gridItemRefsRef.current = refs;
const fileColorIndex = page.originalFileId
? (fileColorIndexMap.get(page.originalFileId) ?? 0)
: 0;
const isBoxSelected = boxSelectedIds.includes(page.id);
return (
<PageThumbnail
key={page.id}
page={page}
index={index}
totalPages={displayDocument?.pages.length || 0}
fileColorIndex={fileColorIndex}
selectedPageIds={selectedPageIds}
selectionMode={selectionMode}
movingPage={movingPage}
isAnimating={isAnimating}
isBoxSelected={isBoxSelected}
clearBoxSelection={clearBoxSelection}
activeDragIds={activeDragIds}
justMoved={justMoved}
pageRefs={refs}
dragHandleProps={dragHandleProps}
onReorderPages={handleReorderPages}
onTogglePage={togglePage}
onAnimateReorder={animateReorder}
onExecuteCommand={executeCommand}
onSetStatus={() => {}}
onSetMovingPage={setMovingPage}
onDeletePage={handleDeletePage}
createRotateCommand={createRotateCommand}
createDeleteCommand={createDeleteCommand}
createSplitCommand={createSplitCommand}
pdfDocument={displayDocument!}
setPdfDocument={setEditedDocument}
splitPositions={splitPositions}
onInsertFiles={handleInsertFiles}
zoomLevel={zoomLevelParam || zoomLevel}
/>
);
},
[
selectedPageIds,
selectionMode,
movingPage,
isAnimating,
displayDocument,
fileColorIndexMap,
handleReorderPages,
togglePage,
animateReorder,
executeCommand,
setMovingPage,
handleDeletePage,
createRotateCommand,
createDeleteCommand,
createSplitCommand,
setEditedDocument,
splitPositions,
handleInsertFiles,
zoomLevel,
],
);
return (
<div
ref={containerRef}
data-scrolling-container="true"
onMouseEnter={() => setIsContainerHovered(true)}
onMouseLeave={() => setIsContainerHovered(false)}
style={{
height: "100%",
overflow: "auto",
position: "relative",
width: "100%",
}}
>
<LoadingOverlay visible={globalProcessing && !initialDocument} />
{!initialDocument && !globalProcessing && !hasPdfFiles && (
<Center h="100%">
<Stack align="center" gap="md">
<Text size="lg" c="dimmed">
📄
</Text>
<Text c="dimmed">No PDF files loaded</Text>
<Text size="sm" c="dimmed">
Add files to start editing pages
</Text>
</Stack>
</Center>
)}
{!initialDocument && (globalProcessing || hasPdfFiles) && (
<Box p={0}>
<SkeletonLoader type="controls" />
<SkeletonLoader type="pageGrid" count={8} />
</Box>
)}
{displayDocument && (
<Box
ref={gridContainerRef}
p={0}
pt="2rem"
pb="4rem"
style={{ position: "relative" }}
>
{/* Split Lines Overlay */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
pointerEvents: "none",
zIndex: 10,
}}
>
{(() => {
const refsMap = gridItemRefsRef.current?.current;
const containerEl = gridContainerRef.current;
if (!refsMap || !containerEl) {
return null;
}
const containerRect = containerEl.getBoundingClientRect();
const splitIndexes = convertSplitPageIdsToIndexes(
displayDocument,
splitPositions,
);
return Array.from(splitIndexes).map((position) => {
const currentPage = displayedPages[position];
if (!currentPage) {
return null;
}
const currentEl = refsMap.get(currentPage.id);
if (!currentEl) {
return null;
}
const currentRect = currentEl.getBoundingClientRect();
const nextPage = displayedPages[position + 1];
let lineLeft;
if (nextPage) {
const nextEl = refsMap.get(nextPage.id);
if (nextEl) {
const nextRect = nextEl.getBoundingClientRect();
const sameRow =
Math.abs(nextRect.top - currentRect.top) <
currentRect.height / 2;
if (sameRow) {
lineLeft = (currentRect.right + nextRect.left) / 2;
} else {
lineLeft = currentRect.right + itemGapPx / 2;
}
} else {
lineLeft = currentRect.right + itemGapPx / 2;
}
} else {
lineLeft = currentRect.right + itemGapPx / 2;
}
return (
<div
key={`split-${position}`}
style={{
position: "absolute",
left: `${lineLeft - containerRect.left}px`,
top: `${currentRect.top - containerRect.top}px`,
width: "1px",
height: `${currentRect.height}px`,
borderLeft: "1px dashed #3b82f6",
}}
/>
);
});
})()}
</div>
{/* Pages Grid */}
<DragDropGrid
items={displayedPages}
onReorderPages={handleReorderPages}
zoomLevel={zoomLevel}
selectedFileIds={selectedFileIds}
selectedPageIds={selectedPageIds}
onVisibleItemsChange={handleVisibleItemsChange}
getThumbnailData={(pageId) => {
const page = displayDocument.pages.find((p) => p.id === pageId);
if (!page?.thumbnail) return null;
return {
src: page.thumbnail,
rotation: page.rotation || 0,
};
}}
renderItem={renderItemCallback}
/>
</Box>
)}
</div>
);
};
export default PageEditor;
@@ -0,0 +1,263 @@
import { Tooltip, ActionIcon } from "@mantine/core";
import UndoIcon from "@mui/icons-material/Undo";
import RedoIcon from "@mui/icons-material/Redo";
import ContentCutIcon from "@mui/icons-material/ContentCut";
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import RotateRightIcon from "@mui/icons-material/RotateRight";
import DeleteIcon from "@mui/icons-material/Delete";
import InsertPageBreakIcon from "@mui/icons-material/InsertPageBreak";
interface PageEditorControlsProps {
// Close/Reset functions
onClosePdf: () => void;
// Undo/Redo
onUndo: () => void;
onRedo: () => void;
canUndo: boolean;
canRedo: boolean;
// Page operations
onRotate: (direction: "left" | "right") => void;
onDelete: () => void;
onSplit: () => void;
onSplitAll: () => void;
onPageBreak: () => void;
onPageBreakAll: () => void;
onExportAll: () => void;
exportLoading: boolean;
// Selection state
selectionMode: boolean;
selectedPageIds: string[];
displayDocument?: { pages: { id: string; pageNumber: number }[] };
// Split state (for tooltip logic)
splitPositions?: Set<string>;
totalPages?: number;
}
const PageEditorControls = ({
onUndo,
onRedo,
canUndo,
canRedo,
onRotate,
onDelete,
onSplit,
onPageBreak,
selectedPageIds,
displayDocument,
splitPositions,
}: PageEditorControlsProps) => {
// Calculate split tooltip text using smart toggle logic
const getSplitTooltip = () => {
if (!splitPositions || !displayDocument || selectedPageIds.length === 0) {
return "Split Selected";
}
const totalPages = displayDocument.pages.length;
const selectedValidPageIds = displayDocument.pages
.filter(
(page, index) =>
selectedPageIds.includes(page.id) && index < totalPages - 1,
)
.map((page) => page.id);
if (selectedValidPageIds.length === 0) {
return "Split Selected";
}
const existingSplitsCount = selectedValidPageIds.filter((id) =>
splitPositions.has(id),
).length;
const noSplitsCount = selectedValidPageIds.length - existingSplitsCount;
const willRemoveSplits = existingSplitsCount > noSplitsCount;
if (willRemoveSplits) {
return existingSplitsCount === selectedValidPageIds.length
? "Remove All Selected Splits"
: "Remove Selected Splits";
} else {
return existingSplitsCount === 0
? "Split Selected"
: "Complete Selected Splits";
}
};
// Calculate page break tooltip text
const getPageBreakTooltip = () => {
return selectedPageIds.length > 0
? `Insert ${selectedPageIds.length} Page Break${selectedPageIds.length > 1 ? "s" : ""}`
: "Insert Page Breaks";
};
return (
<div
style={{
position: "sticky",
left: 0,
right: 0,
bottom: 0,
zIndex: 50,
display: "flex",
justifyContent: "center",
pointerEvents: "none",
background: "transparent",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
boxShadow: "0 -2px 8px rgba(0,0,0,0.04)",
backgroundColor: "var(--bg-toolbar)",
border: "1px solid var(--border-default)",
borderRadius: "16px 16px 0 0",
pointerEvents: "auto",
minWidth: 360,
maxWidth: 700,
flexWrap: "wrap",
justifyContent: "center",
padding: "1rem",
paddingBottom: "1rem",
}}
>
{/* Undo/Redo */}
<Tooltip label="Undo">
<ActionIcon
onClick={onUndo}
disabled={!canUndo}
variant="subtle"
style={{
color: canUndo ? "var(--text-secondary)" : "var(--text-muted)",
}}
radius="md"
size="lg"
>
<UndoIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Redo">
<ActionIcon
onClick={onRedo}
disabled={!canRedo}
variant="subtle"
style={{
color: canRedo ? "var(--text-secondary)" : "var(--text-muted)",
}}
radius="md"
size="lg"
>
<RedoIcon />
</ActionIcon>
</Tooltip>
<div
style={{
width: 1,
height: 28,
backgroundColor: "var(--mantine-color-gray-3)",
margin: "0 8px",
}}
/>
{/* Page Operations */}
<Tooltip label="Rotate Selected Left">
<ActionIcon
onClick={() => onRotate("left")}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{
color:
selectedPageIds.length > 0
? "var(--text-secondary)"
: "var(--text-muted)",
}}
radius="md"
size="lg"
>
<RotateLeftIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Rotate Selected Right">
<ActionIcon
onClick={() => onRotate("right")}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{
color:
selectedPageIds.length > 0
? "var(--text-secondary)"
: "var(--text-muted)",
}}
radius="md"
size="lg"
>
<RotateRightIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete Selected">
<ActionIcon
onClick={onDelete}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{
color:
selectedPageIds.length > 0
? "var(--text-secondary)"
: "var(--text-muted)",
}}
radius="md"
size="lg"
>
<DeleteIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={getSplitTooltip()}>
<ActionIcon
onClick={onSplit}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{
color:
selectedPageIds.length > 0
? "var(--text-secondary)"
: "var(--text-muted)",
}}
radius="md"
size="lg"
>
<ContentCutIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={getPageBreakTooltip()}>
<ActionIcon
onClick={onPageBreak}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{
color:
selectedPageIds.length > 0
? "var(--text-secondary)"
: "var(--text-muted)",
}}
radius="md"
size="lg"
>
<InsertPageBreakIcon />
</ActionIcon>
</Tooltip>
</div>
</div>
);
};
export default PageEditorControls;
@@ -0,0 +1,64 @@
import { ActionIcon, Popover } from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import { Tooltip } from "@app/components/shared/Tooltip";
import BulkSelectionPanel from "@app/components/pageEditor/BulkSelectionPanel";
interface PageSelectByNumberButtonProps {
disabled: boolean;
totalPages: number;
label: string;
csvInput: string;
setCsvInput: (value: string) => void;
selectedPageIds: string[];
displayDocument?: { pages: { id: string; pageNumber: number }[] };
updatePagesFromCSV: (override?: string) => void;
}
export default function PageSelectByNumberButton({
disabled,
totalPages,
label,
csvInput,
setCsvInput,
selectedPageIds,
displayDocument,
updatePagesFromCSV,
}: PageSelectByNumberButtonProps) {
return (
<Tooltip
content={label}
position="bottom"
offset={12}
arrow
portalTarget={document.body}
>
<div>
<Popover position="left" withArrow shadow="md" offset={8}>
<Popover.Target>
<div style={{ display: "inline-flex" }}>
<ActionIcon
variant="subtle"
radius="md"
disabled={disabled || totalPages === 0}
aria-label={label}
>
<LocalIcon icon="pin-end" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: "24rem", maxWidth: "32rem" }}>
<BulkSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}
selectedPageIds={selectedPageIds}
displayDocument={displayDocument}
onUpdatePagesFromCSV={updatePagesFromCSV}
/>
</div>
</Popover.Dropdown>
</Popover>
</div>
</Tooltip>
);
}
@@ -0,0 +1,567 @@
import React, {
useCallback,
useState,
useEffect,
useRef,
useMemo,
} from "react";
import { Text, Checkbox } from "@mantine/core";
import { useIsMobile } from "@app/hooks/useIsMobile";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import RotateRightIcon from "@mui/icons-material/RotateRight";
import DeleteIcon from "@mui/icons-material/Delete";
import ContentCutIcon from "@mui/icons-material/ContentCut";
import AddIcon from "@mui/icons-material/Add";
import { PDFPage, PDFDocument } from "@app/types/pageEditor";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import { getFileColorWithOpacity } from "@app/components/pageEditor/fileColors";
import styles from "@app/components/pageEditor/PageEditor.module.css";
import HoverActionMenu, {
HoverAction,
} from "@app/components/shared/HoverActionMenu";
import { StirlingFileStub } from "@app/types/fileContext";
import { PrivateContent } from "@app/components/shared/PrivateContent";
interface PageThumbnailProps {
page: PDFPage;
index: number;
totalPages: number;
fileColorIndex: number;
selectedPageIds: string[];
selectionMode: boolean;
movingPage: number | null;
isAnimating: boolean;
isBoxSelected?: boolean;
clearBoxSelection?: () => void;
activeDragIds: string[];
justMoved?: boolean;
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
dragHandleProps?: any;
onReorderPages: (
sourcePageNumber: number,
targetIndex: number,
selectedPageIds?: string[],
) => void;
onTogglePage: (pageId: string) => void;
onAnimateReorder: () => void;
onExecuteCommand: (command: { execute: () => void }) => void;
onSetStatus: (status: string) => void;
onSetMovingPage: (page: number | null) => void;
onDeletePage: (pageNumber: number) => void;
createRotateCommand: (
pageIds: string[],
rotation: number,
) => { execute: () => void };
createDeleteCommand: (pageIds: string[]) => { execute: () => void };
createSplitCommand: (
pageId: string,
pageNumber: number,
) => { execute: () => void };
pdfDocument: PDFDocument;
setPdfDocument: (doc: PDFDocument) => void;
splitPositions: Set<string>;
onInsertFiles?: (
files: File[] | StirlingFileStub[],
insertAfterPage: number,
isFromStorage?: boolean,
) => void;
zoomLevel?: number;
}
const PageThumbnail: React.FC<PageThumbnailProps> = ({
page,
index: _index,
totalPages,
fileColorIndex,
selectedPageIds,
selectionMode,
movingPage,
isAnimating,
isBoxSelected = false,
clearBoxSelection,
activeDragIds,
pageRefs,
dragHandleProps,
onReorderPages,
onTogglePage,
onExecuteCommand,
onSetStatus,
onDeletePage,
createRotateCommand,
createSplitCommand,
pdfDocument,
splitPositions,
onInsertFiles,
zoomLevel = 1.0,
justMoved = false,
}: PageThumbnailProps) => {
const pageIndex = page.pageNumber - 1;
const isSelected = Array.isArray(selectedPageIds)
? selectedPageIds.includes(page.id)
: false;
const [isMouseDown, setIsMouseDown] = useState(false);
const [mouseStartPos, setMouseStartPos] = useState<{
x: number;
y: number;
} | null>(null);
const [isHovered, setIsHovered] = useState(false);
const isMobile = useIsMobile();
const lastClickTimeRef = useRef<number>(0);
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(
page.thumbnail,
);
const elementRef = useRef<HTMLDivElement | null>(null);
const { openFilesModal } = useFilesModalContext();
// Check if this page is currently being dragged
const isDragging = activeDragIds.includes(page.id);
// Calculate document aspect ratio from first non-blank page
const getDocumentAspectRatio = useCallback(() => {
// Find first non-blank page with a thumbnail to get aspect ratio
const firstRealPage = pdfDocument.pages.find(
(p) => !p.isBlankPage && p.thumbnail,
);
if (firstRealPage?.thumbnail) {
// Try to get aspect ratio from an actual thumbnail image
// For now, default to A4 but could be enhanced to measure image dimensions
return "1 / 1.414"; // A4 ratio as fallback
}
return "1 / 1.414"; // Default A4 ratio
}, [pdfDocument.pages]);
// Update thumbnail URL when page prop changes
useEffect(() => {
if (page.thumbnail && page.thumbnail !== thumbnailUrl) {
setThumbnailUrl(page.thumbnail);
}
}, [page.thumbnail, thumbnailUrl]);
// Merge refs - combine our ref tracking with dnd-kit's ref
const mergedRef = useCallback(
(element: HTMLDivElement | null) => {
// Track in our refs map
elementRef.current = element;
if (element) {
pageRefs.current.set(page.id, element);
} else {
pageRefs.current.delete(page.id);
}
// Call dnd-kit's ref if provided
if (dragHandleProps?.ref) {
dragHandleProps.ref(element);
}
},
[page.id, pageRefs, dragHandleProps],
);
// DOM command handlers
const handleRotateLeft = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
// Use the command system for undo/redo support
const command = createRotateCommand([page.id], -90);
onExecuteCommand(command);
onSetStatus(`Rotated page ${page.pageNumber} left`);
},
[
page.id,
page.pageNumber,
onExecuteCommand,
onSetStatus,
createRotateCommand,
],
);
const handleRotateRight = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
// Use the command system for undo/redo support
const command = createRotateCommand([page.id], 90);
onExecuteCommand(command);
onSetStatus(`Rotated page ${page.pageNumber} right`);
},
[
page.id,
page.pageNumber,
onExecuteCommand,
onSetStatus,
createRotateCommand,
],
);
const handleDelete = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onDeletePage(page.pageNumber);
onSetStatus(`Deleted page ${page.pageNumber}`);
},
[page.pageNumber, onDeletePage, onSetStatus],
);
const handleSplit = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
// Create a command to toggle split at this position
const command = createSplitCommand(page.id, page.pageNumber);
onExecuteCommand(command);
const hasSplit = splitPositions.has(page.id);
const action = hasSplit ? "removed" : "added";
onSetStatus(`Split marker ${action} after position ${pageIndex + 1}`);
},
[
pageIndex,
splitPositions,
onExecuteCommand,
onSetStatus,
createSplitCommand,
],
);
const handleInsertFileAfter = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (onInsertFiles) {
// Open file manager modal with custom handler for page insertion
openFilesModal({
insertAfterPage: page.pageNumber,
customHandler: (
files: File[] | StirlingFileStub[],
insertAfterPage?: number,
isFromStorage?: boolean,
) => {
if (insertAfterPage !== undefined) {
onInsertFiles(files, insertAfterPage, isFromStorage);
}
},
});
onSetStatus(`Select files to insert after page ${page.pageNumber}`);
} else {
// Fallback to normal file handling
openFilesModal({ insertAfterPage: page.pageNumber });
onSetStatus(`Select files to insert after page ${page.pageNumber}`);
}
},
[openFilesModal, page.pageNumber, onSetStatus, onInsertFiles],
);
// Handle click vs drag differentiation
const handleMouseDown = useCallback((e: React.MouseEvent) => {
setIsMouseDown(true);
setMouseStartPos({ x: e.clientX, y: e.clientY });
}, []);
const handleMouseUp = useCallback(
(e: React.MouseEvent) => {
if (!isMouseDown || !mouseStartPos) {
setIsMouseDown(false);
setMouseStartPos(null);
return;
}
// Calculate distance moved
const deltaX = Math.abs(e.clientX - mouseStartPos.x);
const deltaY = Math.abs(e.clientY - mouseStartPos.y);
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
// If mouse moved less than 2 pixels, consider it a click (not a drag)
if (distance < 2 && !isDragging) {
// Prevent rapid double-clicks from causing issues (debounce with 100ms threshold)
const now = Date.now();
if (now - lastClickTimeRef.current > 100) {
lastClickTimeRef.current = now;
// Clear box selection when clicking on a non-selected page
if (!isBoxSelected && clearBoxSelection) {
clearBoxSelection();
}
// Don't toggle page selection if it's box-selected (just keep the box selection)
if (!isBoxSelected) {
onTogglePage(page.id);
}
}
}
setIsMouseDown(false);
setMouseStartPos(null);
},
[
isMouseDown,
mouseStartPos,
isDragging,
page.id,
isBoxSelected,
clearBoxSelection,
onTogglePage,
],
);
const handleMouseLeave = useCallback(() => {
setIsMouseDown(false);
setMouseStartPos(null);
setIsHovered(false);
}, []);
const fileColorBorder = page.isBlankPage
? "transparent"
: getFileColorWithOpacity(fileColorIndex, 0.3);
// Spread dragHandleProps but use our merged ref
const { ref: _, ...restDragProps } = dragHandleProps || {};
// Build hover menu actions
const hoverActions = useMemo<HoverAction[]>(
() => [
{
id: "move-left",
icon: <ArrowBackIcon style={{ fontSize: 20 }} />,
label: "Move Left",
onClick: (e) => {
e.stopPropagation();
if (pageIndex > 0 && !isAnimating) {
onReorderPages(page.pageNumber, pageIndex - 1);
onSetStatus(`Moved page ${page.pageNumber} left`);
}
},
disabled: pageIndex === 0,
},
{
id: "move-right",
icon: <ArrowForwardIcon style={{ fontSize: 20 }} />,
label: "Move Right",
onClick: (e) => {
e.stopPropagation();
if (pageIndex < totalPages - 1 && !isAnimating) {
// +2 compensates for ReorderPagesCommand's internal targetIndex - 1 adjustment.
onReorderPages(page.pageNumber, pageIndex + 2);
onSetStatus(`Moved page ${page.pageNumber} right`);
}
},
disabled: pageIndex === totalPages - 1,
},
{
id: "rotate-left",
icon: <RotateLeftIcon style={{ fontSize: 20 }} />,
label: "Rotate Left",
onClick: handleRotateLeft,
},
{
id: "rotate-right",
icon: <RotateRightIcon style={{ fontSize: 20 }} />,
label: "Rotate Right",
onClick: handleRotateRight,
},
{
id: "delete",
icon: <DeleteIcon style={{ fontSize: 20 }} />,
label: "Delete Page",
onClick: handleDelete,
color: "red",
},
{
id: "split",
icon: <ContentCutIcon style={{ fontSize: 20 }} />,
label: "Split After",
onClick: handleSplit,
hidden: pageIndex >= totalPages - 1,
},
{
id: "insert",
icon: <AddIcon style={{ fontSize: 20 }} />,
label: "Insert File After",
onClick: handleInsertFileAfter,
},
],
[
pageIndex,
totalPages,
isAnimating,
page.pageNumber,
handleRotateLeft,
handleRotateRight,
handleDelete,
handleSplit,
handleInsertFileAfter,
onReorderPages,
onSetStatus,
],
);
return (
<div
ref={mergedRef}
{...restDragProps}
data-page-id={page.id}
data-page-number={page.pageNumber}
className={`
${styles.pageContainer}
!rounded-lg
${selectionMode ? "cursor-pointer" : "cursor-grab"}
select-none
flex items-center justify-center
flex-shrink-0
shadow-sm
hover:shadow-md
transition-all
relative
${isDragging ? "opacity-50 scale-95" : ""}
${movingPage === page.pageNumber ? "page-moving" : ""}
${isBoxSelected ? "ring-4 ring-blue-400 ring-offset-2" : ""}
`}
style={{
width: `calc(20rem * ${zoomLevel})`,
height: `calc(20rem * ${zoomLevel})`,
transition: isAnimating ? "none" : "transform 0.2s ease-in-out",
zIndex: isHovered ? 50 : 1,
...(isBoxSelected && {
boxShadow: "0 0 0 4px rgba(59, 130, 246, 0.5)",
}),
}}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={handleMouseLeave}
>
{
<div
className={styles.checkboxContainer}
style={{
position: "absolute",
top: 8,
right: 8,
zIndex: 10,
backgroundColor: "white",
borderRadius: "4px",
padding: "2px",
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
pointerEvents: "auto",
}}
onMouseDown={(e) => {
e.stopPropagation();
onTogglePage(page.id);
}}
onMouseUp={(e) => e.stopPropagation()}
onDragStart={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Checkbox
checked={isSelected}
onChange={() => {
// Selection is handled by container mouseDown
}}
size="sm"
style={{ pointerEvents: "none" }}
/>
</div>
}
<div className="page-container w-[90%] h-[90%]" draggable={false}>
<div
className={`${styles.pageSurface} ${justMoved ? styles.pageJustMoved : ""}`}
style={{
width: "100%",
height: "100%",
backgroundColor: "var(--mantine-color-gray-1)",
borderRadius: 6,
boxShadow: page.isBlankPage
? "none"
: `0 0 ${4 + 4 * zoomLevel}px 3px ${fileColorBorder}`,
padding: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{page.isBlankPage ? (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div
style={{
width: "70%",
aspectRatio: getDocumentAspectRatio(),
backgroundColor: "white",
border: "1px solid #e9ecef",
borderRadius: 2,
}}
/>
</div>
) : thumbnailUrl ? (
<PrivateContent>
<img
className="ph-no-capture"
src={thumbnailUrl}
alt={`Page ${page.pageNumber}`}
draggable={false}
data-original-rotation={page.rotation}
style={{
width: "100%",
height: "100%",
objectFit: "contain",
borderRadius: 2,
transform: `rotate(${page.rotation}deg)`,
transition: "transform 0.3s ease-in-out",
}}
/>
</PrivateContent>
) : (
<div style={{ textAlign: "center" }}>
<Text size="lg" c="dimmed">
📄
</Text>
<Text size="xs" c="dimmed" mt={4}>
Page {page.pageNumber}
</Text>
</div>
)}
</div>
<Text
className={styles.pageNumber}
size="sm"
fw={500}
style={{
color: "var(--mantine-color-white)", // Use theme token for consistency
position: "absolute",
top: 5,
left: 5,
background: "rgba(162, 201, 255, 0.8)",
padding: "6px 8px",
borderRadius: 8,
zIndex: 2,
opacity: 0,
transition: "opacity 0.2s ease-in-out",
}}
>
{page.pageNumber}
</Text>
<HoverActionMenu
show={isHovered || isMobile}
actions={hoverActions}
position="inside"
className={styles.pageHoverControls}
/>
</div>
</div>
);
};
export default PageThumbnail;
@@ -0,0 +1,159 @@
import { useState } from "react";
import { Flex } from "@mantine/core";
import { useTranslation } from "react-i18next";
import classes from "@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css";
import {
appendExpression,
insertOperatorSmart,
firstNExpression,
lastNExpression,
everyNthExpression,
rangeExpression,
LogicalOperator,
} from "@app/utils/bulkselection/selectionBuilders";
import SelectPages from "@app/components/pageEditor/bulkSelectionPanel/SelectPages";
import OperatorsSection from "@app/components/pageEditor/bulkSelectionPanel/OperatorsSection";
interface AdvancedSelectionPanelProps {
csvInput: string;
setCsvInput: (value: string) => void;
onUpdatePagesFromCSV: (override?: string) => void;
maxPages: number;
advancedOpened?: boolean;
}
const AdvancedSelectionPanel = ({
csvInput,
setCsvInput,
onUpdatePagesFromCSV,
maxPages,
advancedOpened,
}: AdvancedSelectionPanelProps) => {
const { t } = useTranslation();
const [rangeEnd, setRangeEnd] = useState<number | "">("");
const handleRangeEndChange = (val: string | number) => {
const next = typeof val === "number" ? val : "";
setRangeEnd(next);
};
// Named validation functions
const validatePositiveNumber = (value: number): string | null => {
return value <= 0 ? "Enter a positive number" : null;
};
const validateRangeStart = (start: number): string | null => {
if (start <= 0) return "Values must be positive";
if (typeof rangeEnd === "number" && start > rangeEnd) {
return "From must be less than or equal to To";
}
return null;
};
// Named callback functions
const applyExpression = (expr: string) => {
const nextInput = appendExpression(csvInput, expr);
setCsvInput(nextInput);
onUpdatePagesFromCSV(nextInput);
};
const insertOperator = (op: LogicalOperator) => {
const next = insertOperatorSmart(csvInput, op);
setCsvInput(next);
// Trigger visual selection update for 'even' and 'odd' operators
if (op === "even" || op === "odd") {
onUpdatePagesFromCSV(next);
}
};
const handleFirstNApply = (value: number) => {
const expr = firstNExpression(value, maxPages);
if (expr) applyExpression(expr);
};
const handleLastNApply = (value: number) => {
const expr = lastNExpression(value, maxPages);
if (expr) applyExpression(expr);
};
const handleEveryNthApply = (value: number) => {
const expr = everyNthExpression(value);
if (expr) applyExpression(expr);
};
const handleRangeApply = (start: number) => {
if (typeof rangeEnd !== "number") return;
const expr = rangeExpression(start, rangeEnd, maxPages);
if (expr) applyExpression(expr);
setRangeEnd("");
};
return (
<>
{/* Advanced section */}
{advancedOpened && (
<div className={classes.advancedSection}>
<div className={classes.advancedContent}>
{/* Cards row */}
<Flex direction="row" mb="xs" wrap="wrap">
<SelectPages
title={t("bulkSelection.firstNPages.title", "First N Pages")}
placeholder={t(
"bulkSelection.firstNPages.placeholder",
"Number of pages",
)}
onApply={handleFirstNApply}
maxPages={maxPages}
validationFn={validatePositiveNumber}
/>
<SelectPages
title={t("bulkSelection.range.title", "Range")}
placeholder={t("bulkSelection.range.fromPlaceholder", "From")}
onApply={handleRangeApply}
maxPages={maxPages}
validationFn={validateRangeStart}
isRange={true}
rangeEndValue={rangeEnd}
onRangeEndChange={handleRangeEndChange}
rangeEndPlaceholder={t(
"bulkSelection.range.toPlaceholder",
"To",
)}
/>
<SelectPages
title={t("bulkSelection.lastNPages.title", "Last N Pages")}
placeholder={t(
"bulkSelection.lastNPages.placeholder",
"Number of pages",
)}
onApply={handleLastNApply}
maxPages={maxPages}
validationFn={validatePositiveNumber}
/>
<SelectPages
title={t("bulkSelection.everyNthPage.title", "Every Nth Page")}
placeholder={t(
"bulkSelection.everyNthPage.placeholder",
"Step size",
)}
onApply={handleEveryNthApply}
maxPages={maxPages}
/>
</Flex>
{/* Operators row at bottom */}
<OperatorsSection
csvInput={csvInput}
onInsertOperator={insertOperator}
/>
</div>
</div>
)}
</>
);
};
export default AdvancedSelectionPanel;
@@ -0,0 +1,313 @@
.panelGroup {
max-width: 100%;
flex-wrap: wrap;
min-width: 24rem;
}
.textInput {
flex: 1;
max-width: 100%;
}
.dropdownContainer {
margin-top: 0.5rem;
}
.menuDropdown {
min-width: 22.5rem;
}
.dropdownContent {
display: flex;
gap: 0.75rem;
}
.leftCol {
flex: 1 1 auto;
min-width: 0;
max-width: calc(100% - 8rem - 0.75rem);
overflow: hidden;
}
.rightCol {
width: 8rem;
border-left: 0.0625rem solid var(--border-default);
padding-left: 0.75rem;
display: flex;
flex-direction: column;
}
.operatorGroup {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.operatorChip {
width: 100%;
border-radius: 1.25rem;
border: 0.0625rem solid var(--bulk-card-border);
background-color: var(--bulk-card-bg);
color: var(--text-primary);
transition: all 0.2s ease;
min-height: 2rem;
}
.operatorChip:hover:not(:disabled) {
border-color: var(--bulk-card-hover-border);
background-color: var(--hover-bg);
transform: translateY(-0.0625rem);
box-shadow: var(--shadow-sm);
}
.operatorChip:active:not(:disabled) {
transform: translateY(0);
box-shadow: var(--shadow-xs);
}
.operatorChip:disabled {
opacity: 0.4;
cursor: not-allowed;
}
:global([data-mantine-color-scheme="dark"]) .operatorChip {
background-color: var(--bulk-card-bg);
border-color: var(--bulk-card-border);
color: var(--text-primary);
}
:global([data-mantine-color-scheme="dark"]) .operatorChip:hover:not(:disabled) {
background-color: var(--hover-bg);
border-color: var(--bulk-card-hover-border);
color: var(--text-primary);
}
.dropdownHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border-bottom: 0.0625rem solid var(--border-default);
margin-bottom: 0.5rem;
}
.closeButton {
min-width: 1.5rem;
height: 1.5rem;
padding: 0;
font-size: 1.25rem;
font-weight: bold;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.menuItemRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.chevron {
color: var(--text-muted);
}
/* Icon-based chevrons */
.chevronIcon {
transition: transform 150ms ease;
display: inline-flex;
align-items: center;
}
.chevronDown {
transform: rotate(90deg);
}
.chevronUp {
transform: rotate(270deg);
}
.inlineRow {
padding: 0.75rem 0.5rem;
}
.inlineRowCompact {
padding: 0.5rem 0.5rem 0.75rem 0.5rem;
}
.menuItemCloseHover {
background-color: var(--text-brand-accent);
opacity: 0.1;
transition: background-color 150ms ease;
}
:global([data-mantine-color-scheme="dark"]) .menuItemCloseHover {
background-color: var(--text-brand-accent);
opacity: 0.2;
}
.selectedList {
max-height: 8rem;
overflow: auto;
background-color: var(--bg-raised);
border: 0.0625rem solid var(--border-default);
border-radius: 0.75rem;
padding: 0.5rem 0.75rem;
margin-top: 0.5rem;
min-width: 24rem;
color: var(--text-primary);
}
.selectedText {
word-break: break-word;
max-width: 100%;
}
.advancedSection {
margin-top: 0.5rem;
min-width: 24rem;
}
.advancedHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border-bottom: 0.0625rem solid var(--border-default);
margin-bottom: 0.5rem;
}
.advancedContent {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.advancedItem {
padding: 0.5rem;
cursor: pointer;
border-radius: 0.25rem;
transition: background-color 150ms ease;
}
.advancedItem:hover {
background-color: var(--hover-bg);
}
:global([data-mantine-color-scheme="dark"]) .advancedItem:hover {
background-color: var(--hover-bg);
}
.advancedCard {
background-color: var(--bulk-card-bg);
border: none;
border-radius: 0.75rem;
padding: 0.25rem;
margin-bottom: 0.5rem;
width: 100%;
box-sizing: border-box;
color: var(--text-primary);
}
:global([data-mantine-color-scheme="dark"]) .advancedCard {
background-color: var(--bulk-card-bg);
}
.inputGroup {
width: 100%;
}
.fullWidthInput {
flex: 1;
}
.applyButton {
min-width: 4rem;
flex-shrink: 0;
}
/* Style inputs and buttons within advanced cards to match bg-raised */
.advancedCard :global(.mantine-NumberInput-input) {
background-color: var(--bg-raised) !important;
border-color: var(--border-default) !important;
color: var(--text-primary) !important;
}
.advancedCard :global(.mantine-Button-root) {
background-color: var(--bg-raised) !important;
border-color: var(--border-default) !important;
color: var(--text-primary) !important;
}
.advancedCard :global(.mantine-Button-root:hover) {
background-color: var(--hover-bg) !important;
border-color: var(--border-strong) !important;
}
/* Error helper text above the input */
.errorText {
margin-top: 0.25rem;
color: var(--text-brand-accent);
}
/* Compact error container for inline tool settings */
.errorCompact {
background-color: var(--bg-raised);
border: 0.0625rem solid var(--border-default);
border-radius: 0.75rem;
padding: 0.5rem 0.75rem;
margin-top: 0.5rem;
max-width: 100%;
}
/* Two-line clamp for compact error text */
.errorTextClamp {
color: var(--text-brand-accent);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Dark-mode adjustments */
:global([data-mantine-color-scheme="dark"]) .selectedList {
background-color: var(--bg-raised);
}
/* Small screens: allow the section to shrink instead of enforcing a large min width */
@media (max-width: 480px) {
.panelGroup,
.selectedList,
.advancedSection,
.panelContainer {
min-width: 0;
}
}
/* Outermost panel container scrolling */
.panelContainer {
max-height: 95vh;
overflow: auto;
background-color: var(--bulk-panel-bg);
color: var(--text-primary);
border-radius: 0.5rem;
}
/* Override Mantine Popover dropdown background */
:global(.mantine-Popover-dropdown) {
background-color: var(--bulk-panel-bg) !important;
border-color: var(--bulk-card-border) !important;
color: var(--text-primary) !important;
}
/* Override Mantine Switch outline */
.advancedSwitch :global(.mantine-Switch-input) {
outline: none !important;
}
.advancedSwitch :global(.mantine-Switch-input:focus) {
outline: none !important;
box-shadow: none !important;
}
@@ -0,0 +1,89 @@
import { Button, Text, Group, Divider } from "@mantine/core";
import { useTranslation } from "react-i18next";
import classes from "@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css";
import { LogicalOperator } from "@app/utils/bulkselection/selectionBuilders";
interface OperatorsSectionProps {
csvInput: string;
onInsertOperator: (op: LogicalOperator) => void;
}
const OperatorsSection = ({
csvInput,
onInsertOperator,
}: OperatorsSectionProps) => {
const { t } = useTranslation();
return (
<div>
<Text size="xs" c="var(--text-muted)" fw={500} mb="xs">
{t("bulkSelection.keywords.title", "Keywords")}:
</Text>
<Group gap="sm" wrap="nowrap">
<Button
size="sm"
variant="outline"
className={classes.operatorChip}
onClick={() => onInsertOperator("and")}
disabled={!csvInput.trim()}
title="Combine selections (both conditions must be true)"
>
<Text size="xs" fw={500}>
and
</Text>
</Button>
<Button
size="sm"
variant="outline"
className={classes.operatorChip}
onClick={() => onInsertOperator("or")}
disabled={!csvInput.trim()}
title="Add to selection (either condition can be true)"
>
<Text size="xs" fw={500}>
or
</Text>
</Button>
<Button
size="sm"
variant="outline"
className={classes.operatorChip}
onClick={() => onInsertOperator("not")}
disabled={!csvInput.trim()}
title="Exclude from selection"
>
<Text size="xs" fw={500}>
not
</Text>
</Button>
</Group>
<Divider my="sm" />
<Group gap="sm" wrap="nowrap">
<Button
size="sm"
variant="outline"
className={classes.operatorChip}
onClick={() => onInsertOperator("even")}
title="Select all even-numbered pages (2, 4, 6, 8...)"
>
<Text size="xs" fw={500}>
even
</Text>
</Button>
<Button
size="sm"
variant="outline"
className={classes.operatorChip}
onClick={() => onInsertOperator("odd")}
title="Select all odd-numbered pages (1, 3, 5, 7...)"
>
<Text size="xs" fw={500}>
odd
</Text>
</Button>
</Group>
</div>
);
};
export default OperatorsSection;
@@ -0,0 +1,101 @@
import { TextInput, Button, Text, Flex, Switch } from "@mantine/core";
import { useTranslation } from "react-i18next";
import LocalIcon from "@app/components/shared/LocalIcon";
import { Tooltip } from "@app/components/shared/Tooltip";
import { usePageSelectionTips } from "@app/components/tooltips/usePageSelectionTips";
import classes from "@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css";
interface PageSelectionInputProps {
csvInput: string;
setCsvInput: (value: string) => void;
onUpdatePagesFromCSV: (override?: string) => void;
onClear: () => void;
advancedOpened?: boolean;
onToggleAdvanced?: (v: boolean) => void;
}
const PageSelectionInput = ({
csvInput,
setCsvInput,
onUpdatePagesFromCSV,
onClear,
advancedOpened,
onToggleAdvanced,
}: PageSelectionInputProps) => {
const { t } = useTranslation();
const pageSelectionTips = usePageSelectionTips();
return (
<div className={classes.panelGroup}>
{/* Header row with tooltip/title and advanced toggle */}
<Flex justify="space-between" align="center" mb="sm">
<Tooltip
position="left"
offset={20}
header={pageSelectionTips.header}
portalTarget={document.body}
pinOnClick={true}
containerStyle={{ marginTop: "1rem" }}
tips={pageSelectionTips.tips}
>
<Flex onClick={(e) => e.stopPropagation()} align="center" gap="xs">
<LocalIcon
icon="gpp-maybe-outline-rounded"
width="1rem"
height="1rem"
style={{ color: "var(--text-instruction)" }}
/>
<Text>Page Selection</Text>
</Flex>
</Tooltip>
{typeof advancedOpened === "boolean" && (
<Flex align="center" gap="xs">
<Text size="sm" c="var(--text-secondary)">
{t("bulkSelection.advanced.title", "Advanced")}
</Text>
<Switch
size="sm"
checked={!!advancedOpened}
onChange={(e) => onToggleAdvanced?.(e.currentTarget.checked)}
title={t("bulkSelection.advanced.title", "Advanced")}
className={classes.advancedSwitch}
/>
</Flex>
)}
</Flex>
{/* Text input */}
<TextInput
value={csvInput}
onChange={(e) => {
const next = e.target.value;
setCsvInput(next);
onUpdatePagesFromCSV(next);
}}
placeholder="1,3,5-10"
rightSection={
csvInput && (
<Button
variant="subtle"
size="xs"
onClick={onClear}
style={{
color: "var(--text-muted)",
minWidth: "auto",
width: "24px",
height: "24px",
padding: 0,
}}
>
×
</Button>
)
}
onKeyDown={(e) => e.key === "Enter" && onUpdatePagesFromCSV()}
className={classes.textInput}
/>
</div>
);
};
export default PageSelectionInput;
@@ -0,0 +1,110 @@
import { useState } from "react";
import { Button, Text, NumberInput, Group } from "@mantine/core";
import classes from "@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css";
interface SelectPagesProps {
title: string;
placeholder: string;
onApply: (value: number) => void;
maxPages: number;
validationFn?: (value: number) => string | null;
isRange?: boolean;
rangeEndValue?: number | "";
onRangeEndChange?: (value: string | number) => void;
rangeEndPlaceholder?: string;
}
const SelectPages = ({
title,
placeholder,
onApply,
validationFn,
isRange = false,
rangeEndValue,
onRangeEndChange,
rangeEndPlaceholder,
}: SelectPagesProps) => {
const [value, setValue] = useState<number | "">("");
const [error, setError] = useState<string | null>(null);
const handleValueChange = (val: string | number) => {
const next = typeof val === "number" ? val : "";
setValue(next);
if (validationFn && typeof next === "number") {
setError(validationFn(next));
} else {
setError(null);
}
};
const handleApply = () => {
if (value === "" || typeof value !== "number") return;
onApply(value);
setValue("");
setError(null);
};
const isDisabled = Boolean(error) || value === "";
return (
<div className={classes.advancedCard}>
<Text size="sm" fw={600} c="var(--text-secondary)" mb="xs">
{title}
</Text>
{error && (
<Text size="xs" c="var(--text-brand-accent)" mb="xs">
{error}
</Text>
)}
<div className={classes.inputGroup}>
<Group gap="sm" align="flex-end" wrap="nowrap">
{isRange ? (
<>
<div style={{ flex: 1 }}>
<NumberInput
size="sm"
value={value}
onChange={handleValueChange}
min={1}
placeholder={placeholder}
error={Boolean(error)}
/>
</div>
<div style={{ flex: 1 }}>
<NumberInput
size="sm"
value={rangeEndValue}
onChange={onRangeEndChange}
min={1}
placeholder={rangeEndPlaceholder}
error={Boolean(error)}
/>
</div>
</>
) : (
<NumberInput
size="sm"
value={value}
onChange={handleValueChange}
min={1}
placeholder={placeholder}
className={classes.fullWidthInput}
error={Boolean(error)}
/>
)}
<Button
size="sm"
className={classes.applyButton}
onClick={handleApply}
disabled={isDisabled}
>
Apply
</Button>
</Group>
</div>
</div>
);
};
export default SelectPages;
@@ -0,0 +1,44 @@
import { Text } from "@mantine/core";
import classes from "@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css";
interface SelectedPagesDisplayProps {
selectedPageIds: string[];
displayDocument?: { pages: { id: string; pageNumber: number }[] };
syntaxError: string | null;
}
const SelectedPagesDisplay = ({
selectedPageIds,
displayDocument,
syntaxError,
}: SelectedPagesDisplayProps) => {
if (selectedPageIds.length === 0 && !syntaxError) {
return null;
}
return (
<div className={classes.selectedList}>
{syntaxError ? (
<Text size="xs" className={classes.errorText}>
{syntaxError}
</Text>
) : (
<Text size="sm" c="dimmed" className={classes.selectedText}>
Selected: {selectedPageIds.length} pages (
{displayDocument
? selectedPageIds
.map((id) => {
const page = displayDocument.pages.find((p) => p.id === id);
return page?.pageNumber || 0;
})
.filter((n) => n > 0)
.join(", ")
: ""}
)
</Text>
)}
</div>
);
};
export default SelectedPagesDisplay;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
// Shared constants for PageEditor grid layout
export const GRID_CONSTANTS = {
ITEM_WIDTH: "20rem", // page width
ITEM_HEIGHT: "21.5rem", // 20rem + 1.5rem gap
ITEM_GAP: "1.5rem", // gap between items
OVERSCAN_SMALL: 8, // Overscan for normal documents
OVERSCAN_LARGE: 12, // Overscan for large documents (12 rows = ~96 pages pre-rendered)
} as const;
@@ -0,0 +1,64 @@
/**
* File color palette for page editor
* Each file gets a distinct color for visual organization
* Colors are applied at 0.3 opacity for subtle highlighting
* Maximum 20 files supported in page editor
*/
export const FILE_COLORS = [
// Subtle colors (1-6) - fit well with UI theme
"rgb(59, 130, 246)", // Blue
"rgb(16, 185, 129)", // Green
"rgb(139, 92, 246)", // Purple
"rgb(6, 182, 212)", // Cyan
"rgb(20, 184, 166)", // Teal
"rgb(99, 102, 241)", // Indigo
// Mid-range colors (7-12) - more distinct
"rgb(244, 114, 182)", // Pink
"rgb(251, 146, 60)", // Orange
"rgb(234, 179, 8)", // Yellow
"rgb(132, 204, 22)", // Lime
"rgb(248, 113, 113)", // Red
"rgb(168, 85, 247)", // Violet
// Vibrant colors (13-20) - maximum distinction
"rgb(236, 72, 153)", // Fuchsia
"rgb(245, 158, 11)", // Amber
"rgb(34, 197, 94)", // Emerald
"rgb(14, 165, 233)", // Sky
"rgb(239, 68, 68)", // Rose
"rgb(168, 162, 158)", // Stone
"rgb(251, 191, 36)", // Gold
"rgb(192, 132, 252)", // Light Purple
] as const;
export const MAX_PAGE_EDITOR_FILES = 20;
/**
* Get color for a file by its index
* @param index - Zero-based file index
* @returns RGB color string
*/
export function getFileColor(index: number): string {
if (index < 0 || index >= FILE_COLORS.length) {
console.warn(`File index ${index} out of range, using default color`);
return FILE_COLORS[0];
}
return FILE_COLORS[index];
}
/**
* Get color with specified opacity
* @param index - Zero-based file index
* @param opacity - Opacity value (0-1), defaults to 0.3
* @returns RGBA color string
*/
export function getFileColorWithOpacity(
index: number,
opacity: number = 0.2,
): string {
const rgb = getFileColor(index);
// Convert rgb(r, g, b) to rgba(r, g, b, a)
return rgb.replace("rgb(", "rgba(").replace(")", `, ${opacity})`);
}
@@ -0,0 +1,266 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FileId } from "@app/types/file";
import { PDFDocument, PDFPage } from "@app/types/pageEditor";
interface UseEditedDocumentStateParams {
initialDocument: PDFDocument | null;
mergedPdfDocument: PDFDocument | null;
reorderedPages: PDFPage[] | null;
clearReorderedPages: () => void;
fileOrder: FileId[];
updateCurrentPages: (pages: PDFPage[] | null) => void;
}
export const useEditedDocumentState = ({
initialDocument,
mergedPdfDocument,
reorderedPages,
clearReorderedPages,
fileOrder,
updateCurrentPages,
}: UseEditedDocumentStateParams) => {
const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(
null,
);
const editedDocumentRef = useRef<PDFDocument | null>(null);
const pagePositionCacheRef = useRef<Map<string, number>>(new Map());
const pageNeighborCacheRef = useRef<Map<string, string | null>>(new Map());
const lastSyncedSignatureRef = useRef<string | null>(null);
// Clone the initial document once so we can safely mutate working state
useEffect(() => {
if (!initialDocument || editedDocument) return;
setEditedDocument({
...initialDocument,
pages: initialDocument.pages.map((page) => ({ ...page })),
});
}, [initialDocument, editedDocument]);
// Apply reorders triggered elsewhere in the editor
useEffect(() => {
if (!reorderedPages || !editedDocument) return;
setEditedDocument({
...editedDocument,
pages: reorderedPages,
});
clearReorderedPages();
}, [reorderedPages, editedDocument, clearReorderedPages]);
// Keep ref synced so effects can read latest without re-running
useEffect(() => {
editedDocumentRef.current = editedDocument;
}, [editedDocument]);
// Cache page positions to help future insertions preserve intent
useEffect(() => {
if (!editedDocument) return;
const positionCache = pagePositionCacheRef.current;
const neighborCache = pageNeighborCacheRef.current;
const pages = editedDocument.pages;
pages.forEach((page, index) => {
positionCache.set(page.id, index);
neighborCache.set(page.id, index > 0 ? pages[index - 1].id : null);
});
}, [editedDocument]);
const fileOrderKey = useMemo(() => fileOrder.join(","), [fileOrder]);
const mergedDocSignature = useMemo(() => {
if (!mergedPdfDocument?.pages) return "";
return mergedPdfDocument.pages.map((page) => page.id).join(",");
}, [mergedPdfDocument]);
useEffect(() => {
if (!mergedPdfDocument) {
lastSyncedSignatureRef.current = null;
}
}, [mergedPdfDocument]);
useEffect(() => {
if (!mergedPdfDocument) {
setEditedDocument(null);
}
}, [mergedPdfDocument, setEditedDocument]);
// Keep editedDocument in sync with out-of-band insert/remove events (e.g. uploads finishing)
useEffect(() => {
const currentEditedDocument = editedDocumentRef.current;
if (!mergedPdfDocument || !currentEditedDocument) return;
const signatureChanged =
mergedDocSignature !== lastSyncedSignatureRef.current;
const metadataChanged =
currentEditedDocument.id !== mergedPdfDocument.id ||
currentEditedDocument.file !== mergedPdfDocument.file ||
currentEditedDocument.name !== mergedPdfDocument.name;
if (!signatureChanged && !metadataChanged) return;
setEditedDocument((prev) => {
if (!prev) return prev;
let pages = prev.pages;
if (signatureChanged) {
const sourcePages = mergedPdfDocument.pages;
const sourceIds = new Set(sourcePages.map((p) => p.id));
const prevIds = new Set(prev.pages.map((p) => p.id));
const hasOverlap = sourcePages.some((page) => prevIds.has(page.id));
const shouldResetToMerged = !hasOverlap;
const newPages: PDFPage[] = [];
for (const page of sourcePages) {
if (!prevIds.has(page.id)) {
newPages.push(page);
}
}
const hasAdditions = newPages.length > 0;
const isEphemeralPage = (page: PDFPage) =>
Boolean(page.isBlankPage || page.isPlaceholder);
let hasRemovals = false;
for (const page of prev.pages) {
if (!sourceIds.has(page.id) && !isEphemeralPage(page)) {
hasRemovals = true;
break;
}
}
if (shouldResetToMerged) {
pages = sourcePages.map((page) => ({ ...page }));
} else if (hasAdditions || hasRemovals) {
pages = [...prev.pages];
const placeholderPositions = new Map<FileId, number>();
pages.forEach((page, index) => {
if (page.isPlaceholder && page.originalFileId) {
placeholderPositions.set(page.originalFileId, index);
}
});
const nextInsertIndexByFile = new Map(placeholderPositions);
if (hasRemovals) {
pages = pages.filter(
(page) => sourceIds.has(page.id) || isEphemeralPage(page),
);
}
if (hasAdditions) {
const mergedIndexMap = new Map<string, number>();
sourcePages.forEach((page, index) =>
mergedIndexMap.set(page.id, index),
);
const additions = newPages
.map((page) => ({
page,
cachedIndex: pagePositionCacheRef.current.get(page.id),
mergedIndex: mergedIndexMap.get(page.id) ?? sourcePages.length,
neighborId: pageNeighborCacheRef.current.get(page.id),
}))
.sort((a, b) => {
const aIndex = a.cachedIndex ?? a.mergedIndex;
const bIndex = b.cachedIndex ?? b.mergedIndex;
if (aIndex !== bIndex) return aIndex - bIndex;
return a.mergedIndex - b.mergedIndex;
});
additions.forEach(
({ page, neighborId, cachedIndex, mergedIndex }) => {
if (pages.some((existing) => existing.id === page.id)) {
return;
}
let insertIndex: number;
const originalFileId = page.originalFileId;
const placeholderIndex =
originalFileId !== undefined
? nextInsertIndexByFile.get(originalFileId)
: undefined;
if (originalFileId && placeholderIndex !== undefined) {
insertIndex = Math.min(placeholderIndex, pages.length);
nextInsertIndexByFile.set(originalFileId, insertIndex + 1);
} else if (neighborId === null) {
insertIndex = 0;
} else if (neighborId) {
const neighborIndex = pages.findIndex(
(p) => p.id === neighborId,
);
if (neighborIndex !== -1) {
insertIndex = neighborIndex + 1;
} else {
const fallbackIndex =
cachedIndex ?? mergedIndex ?? pages.length;
insertIndex = Math.min(fallbackIndex, pages.length);
}
} else {
const fallbackIndex =
cachedIndex ?? mergedIndex ?? pages.length;
insertIndex = Math.min(fallbackIndex, pages.length);
}
const clonedPage = { ...page };
pages.splice(insertIndex, 0, clonedPage);
},
);
}
}
if (shouldResetToMerged || hasAdditions || hasRemovals) {
pages = pages.map((page, index) => ({
...page,
pageNumber: index + 1,
}));
}
}
const shouldReplaceBase = metadataChanged || signatureChanged;
const baseDocument = shouldReplaceBase
? {
...mergedPdfDocument,
destroy: prev.destroy,
}
: prev;
if (baseDocument === prev && pages === prev.pages) {
return prev;
}
return {
...baseDocument,
pages,
totalPages: pages.length,
};
});
if (signatureChanged) {
lastSyncedSignatureRef.current = mergedDocSignature;
}
}, [mergedPdfDocument, fileOrderKey, mergedDocSignature]);
const displayDocument = editedDocument || initialDocument;
const getEditedDocument = useCallback(() => editedDocumentRef.current, []);
useEffect(() => {
updateCurrentPages(displayDocument?.pages ?? null);
}, [displayDocument, updateCurrentPages]);
return {
editedDocument,
setEditedDocument,
displayDocument,
getEditedDocument,
};
};
export type UseEditedDocumentStateReturn = ReturnType<
typeof useEditedDocumentState
>;
@@ -0,0 +1,455 @@
import { useCallback, useEffect, useRef } from "react";
import {
BulkRotateCommand,
DeletePagesCommand,
PageBreakCommand,
ReorderPagesCommand,
SplitCommand,
} from "@app/components/pageEditor/commands/pageCommands";
import type { useFileActions, useFileState } from "@app/contexts/FileContext";
import { PDFDocument, PDFPage } from "@app/types/pageEditor";
import { FileId } from "@app/types/file";
import { StirlingFileStub } from "@app/types/fileContext";
type FileActions = ReturnType<typeof useFileActions>["actions"];
type FileSelectors = ReturnType<typeof useFileState>["selectors"];
interface UsePageEditorCommandsParams {
displayDocument: PDFDocument | null;
getEditedDocument: () => PDFDocument | null;
setEditedDocument: React.Dispatch<React.SetStateAction<PDFDocument | null>>;
splitPositions: Set<string>;
setSplitPositions: React.Dispatch<React.SetStateAction<Set<string>>>;
selectedPageIds: string[];
setSelectedPageIds: (ids: string[]) => void;
getPageNumbersFromIds: (pageIds: string[]) => number[];
executeCommandWithTracking: (command: any) => void;
updateFileOrderFromPages: (pages: PDFPage[]) => void;
actions: FileActions;
selectors: FileSelectors;
setSelectionMode: (enabled: boolean) => void;
clearUndoHistory: () => void;
}
export const usePageEditorCommands = ({
displayDocument,
getEditedDocument,
setEditedDocument,
splitPositions,
setSplitPositions,
selectedPageIds,
setSelectedPageIds,
getPageNumbersFromIds,
executeCommandWithTracking,
updateFileOrderFromPages,
actions,
selectors,
setSelectionMode,
clearUndoHistory,
}: UsePageEditorCommandsParams) => {
const splitPositionsRef = useRef(splitPositions);
useEffect(() => {
splitPositionsRef.current = splitPositions;
}, [splitPositions]);
const closePdf = useCallback(() => {
actions.clearAllFiles();
clearUndoHistory();
setSelectedPageIds([]);
setSelectionMode(false);
}, [actions, clearUndoHistory, setSelectedPageIds, setSelectionMode]);
const handleRotatePages = useCallback(
(pageIds: string[], rotation: number) => {
const bulkRotateCommand = new BulkRotateCommand(
pageIds,
rotation,
getEditedDocument,
setEditedDocument,
);
executeCommandWithTracking(bulkRotateCommand);
},
[executeCommandWithTracking, getEditedDocument, setEditedDocument],
);
const createRotateCommand = useCallback(
(pageIds: string[], rotation: number) => ({
execute: () => {
const bulkRotateCommand = new BulkRotateCommand(
pageIds,
rotation,
getEditedDocument,
setEditedDocument,
);
executeCommandWithTracking(bulkRotateCommand);
},
}),
[executeCommandWithTracking, getEditedDocument, setEditedDocument],
);
const createDeleteCommand = useCallback(
(pageIds: string[]) => ({
execute: () => {
const currentDocument = getEditedDocument();
if (!currentDocument) return;
const pagesToDelete = pageIds
.map((pageId) => {
const page = currentDocument.pages.find((p) => p.id === pageId);
return page?.pageNumber || 0;
})
.filter((num) => num > 0);
if (pagesToDelete.length > 0) {
const deleteCommand = new DeletePagesCommand(
pagesToDelete,
getEditedDocument,
setEditedDocument,
setSelectedPageIds,
() => splitPositions,
setSplitPositions,
() => getPageNumbersFromIds(selectedPageIds),
() => closePdf(),
);
executeCommandWithTracking(deleteCommand);
}
},
}),
[
closePdf,
executeCommandWithTracking,
getEditedDocument,
getPageNumbersFromIds,
selectedPageIds,
setEditedDocument,
setSelectedPageIds,
setSplitPositions,
splitPositions,
],
);
const createSplitCommand = useCallback(
(pageId: string, pageNumber: number) => ({
execute: () => {
const splitCommand = new SplitCommand(
pageId,
pageNumber,
() => splitPositionsRef.current,
setSplitPositions,
);
executeCommandWithTracking(splitCommand);
},
}),
[executeCommandWithTracking, setSplitPositions],
);
const executeCommand = useCallback((command: any) => {
if (command && typeof command.execute === "function") {
command.execute();
}
}, []);
const handleRotate = useCallback(
(direction: "left" | "right") => {
if (!displayDocument || selectedPageIds.length === 0) return;
const rotation = direction === "left" ? -90 : 90;
handleRotatePages(selectedPageIds, rotation);
},
[displayDocument, selectedPageIds, handleRotatePages],
);
const handleDelete = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
const deleteCommand = new DeletePagesCommand(
selectedPageNumbers,
getEditedDocument,
setEditedDocument,
setSelectedPageIds,
() => splitPositions,
setSplitPositions,
() => selectedPageNumbers,
() => closePdf(),
);
executeCommandWithTracking(deleteCommand);
}, [
closePdf,
displayDocument,
executeCommandWithTracking,
getEditedDocument,
getPageNumbersFromIds,
selectedPageIds,
setEditedDocument,
setSelectedPageIds,
setSplitPositions,
splitPositions,
]);
const handleDeletePage = useCallback(
(pageNumber: number) => {
if (!displayDocument) return;
const deleteCommand = new DeletePagesCommand(
[pageNumber],
getEditedDocument,
setEditedDocument,
setSelectedPageIds,
() => splitPositions,
setSplitPositions,
() => getPageNumbersFromIds(selectedPageIds),
() => closePdf(),
);
executeCommandWithTracking(deleteCommand);
},
[
closePdf,
getEditedDocument,
executeCommandWithTracking,
getPageNumbersFromIds,
selectedPageIds,
setEditedDocument,
setSelectedPageIds,
setSplitPositions,
splitPositions,
],
);
const handleSplit = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
const selectedSplitPageIds = displayDocument.pages
.filter(
(page, index) =>
selectedPageIds.includes(page.id) &&
index < displayDocument.pages.length - 1,
)
.map((page) => page.id);
if (selectedSplitPageIds.length === 0) return;
const existingSplitsCount = selectedSplitPageIds.filter((id) =>
splitPositions.has(id),
).length;
const noSplitsCount = selectedSplitPageIds.length - existingSplitsCount;
const shouldRemoveSplits = existingSplitsCount > noSplitsCount;
const newSplitPositions = new Set(splitPositions);
if (shouldRemoveSplits) {
selectedSplitPageIds.forEach((id) => newSplitPositions.delete(id));
} else {
selectedSplitPageIds.forEach((id) => newSplitPositions.add(id));
}
const smartSplitCommand = {
execute: () => setSplitPositions(newSplitPositions),
undo: () => setSplitPositions(splitPositions),
description: shouldRemoveSplits
? `Remove ${selectedSplitPageIds.length} split(s)`
: `Add ${selectedSplitPageIds.length - existingSplitsCount} split(s)`,
};
executeCommandWithTracking(smartSplitCommand);
}, [
selectedPageIds,
displayDocument,
splitPositions,
setSplitPositions,
getPageNumbersFromIds,
executeCommandWithTracking,
]);
const handleSplitAll = handleSplit;
const handlePageBreak = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
const pageBreakCommand = new PageBreakCommand(
selectedPageNumbers,
getEditedDocument,
setEditedDocument,
);
executeCommandWithTracking(pageBreakCommand);
}, [
displayDocument,
executeCommandWithTracking,
getEditedDocument,
getPageNumbersFromIds,
selectedPageIds,
setEditedDocument,
]);
const handlePageBreakAll = handlePageBreak;
const handleInsertFiles = useCallback(
async (
files: File[] | StirlingFileStub[],
insertAfterPage: number,
isFromStorage?: boolean,
) => {
console.log("[PageEditor] handleInsertFiles called:", {
fileCount: files.length,
insertAfterPage,
isFromStorage,
});
const workingDocument = getEditedDocument();
if (!workingDocument || files.length === 0) {
console.log("[PageEditor] handleInsertFiles early return:", {
hasDocument: !!workingDocument,
fileCount: files.length,
});
return;
}
try {
const targetPage = workingDocument.pages.find(
(p) => p.pageNumber === insertAfterPage,
);
if (!targetPage) {
console.log("[PageEditor] Target page not found:", insertAfterPage);
return;
}
const insertAfterPageId = targetPage.id;
console.log("[PageEditor] Inserting files after page:", {
pageNumber: insertAfterPage,
pageId: insertAfterPageId,
});
let addedFileIds: FileId[] = [];
if (isFromStorage) {
const stubs = files as StirlingFileStub[];
const result = await actions.addStirlingFileStubs(stubs, {
selectFiles: true,
insertAfterPageId,
});
addedFileIds = result.map((file) => file.fileId);
} else {
const result = await actions.addFiles(files as File[], {
selectFiles: true,
insertAfterPageId,
});
addedFileIds = result.map((file) => file.fileId);
console.log("[PageEditor] Files added to context:", {
addedCount: addedFileIds.length,
fileIds: addedFileIds,
});
}
await new Promise((resolve) => setTimeout(resolve, 100));
const newPages: PDFPage[] = [];
for (const fileId of addedFileIds) {
const stub = selectors.getStirlingFileStub(fileId);
if (stub?.processedFile?.pages) {
const clonedPages = stub.processedFile.pages.map((page, idx) => ({
...page,
id: `${fileId}-${page.pageNumber ?? idx + 1}`,
pageNumber: page.pageNumber ?? idx + 1,
originalFileId: fileId,
originalPageNumber:
page.originalPageNumber ?? page.pageNumber ?? idx + 1,
rotation: page.rotation ?? 0,
thumbnail: page.thumbnail ?? null,
selected: false,
splitAfter: page.splitAfter ?? false,
}));
newPages.push(...clonedPages);
}
}
if (newPages.length > 0) {
const targetIndex = workingDocument.pages.findIndex(
(p) => p.id === targetPage.id,
);
if (targetIndex >= 0) {
const updatedPages = [...workingDocument.pages];
updatedPages.splice(targetIndex + 1, 0, ...newPages);
updatedPages.forEach((page, index) => {
page.pageNumber = index + 1;
});
setEditedDocument({
...workingDocument,
pages: updatedPages,
});
updateFileOrderFromPages(updatedPages);
}
}
} catch (error) {
console.error("Failed to insert files:", error);
}
},
[
getEditedDocument,
actions,
selectors,
updateFileOrderFromPages,
setEditedDocument,
],
);
const handleReorderPages = useCallback(
(
sourcePageNumber: number,
targetIndex: number,
draggedPageIds?: string[],
) => {
if (!displayDocument) return;
const selectedPages = draggedPageIds
? getPageNumbersFromIds(draggedPageIds)
: undefined;
const reorderCommand = new ReorderPagesCommand(
sourcePageNumber,
targetIndex,
selectedPages,
getEditedDocument,
setEditedDocument,
(newPages) => updateFileOrderFromPages(newPages),
);
executeCommandWithTracking(reorderCommand);
},
[
displayDocument,
getEditedDocument,
executeCommandWithTracking,
getPageNumbersFromIds,
setEditedDocument,
updateFileOrderFromPages,
],
);
return {
createRotateCommand,
createDeleteCommand,
createSplitCommand,
executeCommand,
handleRotate,
handleDelete,
handleDeletePage,
handleSplit,
handleSplitAll,
handlePageBreak,
handlePageBreakAll,
handleInsertFiles,
handleReorderPages,
closePdf,
};
};
export type UsePageEditorCommandsReturn = ReturnType<
typeof usePageEditorCommands
>;
@@ -0,0 +1,33 @@
import { useMemo, useRef } from "react";
import { FileId } from "@app/types/file";
/**
* Maintains stable color assignments for a collection of file IDs.
* Colors are assigned by insertion order and preserved across reorders.
*/
export function useFileColorMap(fileIds: FileId[]): Map<FileId, number> {
const assignmentsRef = useRef(new Map<FileId, number>());
const serializedIds = useMemo(() => fileIds.join(","), [fileIds]);
return useMemo(() => {
const assignments = assignmentsRef.current;
const activeIds = new Set(fileIds);
// Remove colors for files that no longer exist
for (const id of Array.from(assignments.keys())) {
if (!activeIds.has(id)) {
assignments.delete(id);
}
}
// Assign colors to any new files
fileIds.forEach((id) => {
if (!assignments.has(id)) {
assignments.set(id, assignments.size);
}
});
return assignments;
}, [serializedIds, fileIds]);
}
@@ -0,0 +1,44 @@
import { useState, useEffect, useRef } from "react";
import { usePageDocument } from "@app/components/pageEditor/hooks/usePageDocument";
import { PDFDocument } from "@app/types/pageEditor";
/**
* Hook that calls usePageDocument but only returns the FIRST non-null result
* After initialization, it ignores all subsequent updates
*/
export function useInitialPageDocument(): PDFDocument | null {
const { document: liveDocument } = usePageDocument();
const [initialDocument, setInitialDocument] = useState<PDFDocument | null>(
null,
);
const lastDocumentIdRef = useRef<string | null>(null);
const liveDocumentId = liveDocument?.id ?? null;
useEffect(() => {
if (!liveDocumentId) {
lastDocumentIdRef.current = null;
setInitialDocument(null);
return;
}
if (liveDocumentId !== lastDocumentIdRef.current) {
lastDocumentIdRef.current = liveDocumentId;
setInitialDocument(null);
}
}, [liveDocumentId]);
useEffect(() => {
if (!liveDocument || initialDocument) {
return;
}
console.log(
"📄 useInitialPageDocument: Captured initial document with",
liveDocument.pages.length,
"pages",
);
setInitialDocument(liveDocument);
}, [liveDocument, initialDocument]);
return initialDocument;
}

Some files were not shown because too many files have changed in this diff Show More