mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
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:
co-authored by
Claude Opus 4.7
parent
48027ee9d6
commit
0a50e765b7
@@ -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>
|
||||
);
|
||||
}
|
||||
+350
@@ -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;
|
||||
}
|
||||
+127
@@ -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
|
||||
}
|
||||
}
|
||||
+424
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
+74
@@ -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;
|
||||
+159
@@ -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;
|
||||
+313
@@ -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;
|
||||
}
|
||||
+89
@@ -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;
|
||||
+101
@@ -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;
|
||||
+44
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import { useMemo, useEffect, useRef, useState } from "react";
|
||||
import { useFileState } from "@app/contexts/FileContext";
|
||||
import { usePageEditor } from "@app/contexts/PageEditorContext";
|
||||
import { PDFDocument, PDFPage } from "@app/types/pageEditor";
|
||||
import { FileId } from "@app/types/file";
|
||||
import { FileAnalyzer } from "@app/services/fileAnalyzer";
|
||||
|
||||
export interface PageDocumentHook {
|
||||
document: PDFDocument | null;
|
||||
isVeryLargeDocument: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing PDF document state and metadata in PageEditor
|
||||
* Handles document merging, large document detection, and loading states
|
||||
*/
|
||||
export function usePageDocument(): PageDocumentHook {
|
||||
const { state, selectors } = useFileState();
|
||||
const {
|
||||
fileOrder,
|
||||
currentPages,
|
||||
persistedDocument,
|
||||
persistedDocumentSignature,
|
||||
} = usePageEditor();
|
||||
|
||||
// Use PageEditorContext's fileOrder instead of FileContext's global order
|
||||
// This ensures the page editor respects its own workspace ordering
|
||||
const allFileIds = fileOrder;
|
||||
|
||||
// Derive selected file IDs directly from FileContext (single source of truth)
|
||||
// Filter to only include PDF files (PageEditor only supports PDFs)
|
||||
// Use stable string keys to prevent infinite loops
|
||||
const allFileIdsKey = allFileIds.join(",");
|
||||
const selectedFileIdsKey = [...state.ui.selectedFileIds].sort().join(",");
|
||||
const activeFilesSignature = selectors.getFilesSignature();
|
||||
|
||||
// Get ALL PDF files (selected or not) for document building with placeholders
|
||||
const activeFileIds = useMemo(() => {
|
||||
return allFileIds.filter((id) => {
|
||||
const stub = selectors.getStirlingFileStub(id);
|
||||
return stub?.name?.toLowerCase().endsWith(".pdf") ?? false;
|
||||
});
|
||||
}, [allFileIdsKey, activeFilesSignature, selectors]);
|
||||
|
||||
const selectedActiveFileIds = useMemo(() => {
|
||||
if (activeFileIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const selectedSet = new Set(state.ui.selectedFileIds);
|
||||
if (selectedSet.size === 0) {
|
||||
return [];
|
||||
}
|
||||
return activeFileIds.filter((id) => selectedSet.has(id));
|
||||
}, [activeFileIds, selectedFileIdsKey]);
|
||||
|
||||
const primaryFileId = selectedActiveFileIds[0] ?? activeFileIds[0] ?? null;
|
||||
|
||||
// UI state
|
||||
const globalProcessing = state.ui.isProcessing;
|
||||
|
||||
// Get primary file record outside useMemo to track processedFile changes
|
||||
const primaryStirlingFileStub = primaryFileId
|
||||
? selectors.getStirlingFileStub(primaryFileId)
|
||||
: null;
|
||||
const processedFilePages = primaryStirlingFileStub?.processedFile?.pages;
|
||||
const processedFileTotalPages =
|
||||
primaryStirlingFileStub?.processedFile?.totalPages;
|
||||
|
||||
const placeholderDocumentRef = useRef<PDFDocument | null>(null);
|
||||
const [placeholderVersion, setPlaceholderVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!primaryFileId) {
|
||||
placeholderDocumentRef.current = null;
|
||||
setPlaceholderVersion((v) => v + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (primaryStirlingFileStub?.processedFile) {
|
||||
placeholderDocumentRef.current = null;
|
||||
setPlaceholderVersion((v) => v + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const file = selectors.getFile(primaryFileId);
|
||||
if (!file) {
|
||||
placeholderDocumentRef.current = null;
|
||||
setPlaceholderVersion((v) => v + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
let canceled = false;
|
||||
|
||||
const loadPlaceholder = async () => {
|
||||
try {
|
||||
const analysis = await FileAnalyzer.quickPDFAnalysis(file);
|
||||
if (canceled) return;
|
||||
const totalPages = Math.max(1, analysis.pageCount || 1);
|
||||
const pages: PDFPage[] = Array.from(
|
||||
{ length: totalPages },
|
||||
(_, index) => ({
|
||||
id: `placeholder-${primaryFileId}-page-${index + 1}`,
|
||||
pageNumber: index + 1,
|
||||
thumbnail: null,
|
||||
rotation: 0,
|
||||
selected: false,
|
||||
originalFileId: primaryFileId,
|
||||
originalPageNumber: index + 1,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!canceled) {
|
||||
placeholderDocumentRef.current = {
|
||||
id: `placeholder-${primaryFileId}`,
|
||||
name:
|
||||
selectors.getStirlingFileStub(primaryFileId)?.name ?? file.name,
|
||||
file,
|
||||
pages,
|
||||
totalPages,
|
||||
};
|
||||
setPlaceholderVersion((v) => v + 1);
|
||||
}
|
||||
} catch {
|
||||
if (!canceled) {
|
||||
placeholderDocumentRef.current = null;
|
||||
setPlaceholderVersion((v) => v + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadPlaceholder();
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [primaryFileId, primaryStirlingFileStub?.processedFile, selectors]);
|
||||
|
||||
// Compute merged document with stable signature (prevents infinite loops)
|
||||
const currentPagesSignature = useMemo(() => {
|
||||
return currentPages ? currentPages.map((page) => page.id).join(",") : "";
|
||||
}, [currentPages]);
|
||||
|
||||
const mergedPdfDocument = useMemo((): PDFDocument | null => {
|
||||
console.log("[usePageDocument] Building document:", {
|
||||
activeFileIds: activeFileIds.length,
|
||||
selectedActiveFileIds: selectedActiveFileIds.length,
|
||||
hasPersistedDoc: !!persistedDocument,
|
||||
persistedDocPages: persistedDocument?.pages.length,
|
||||
persistedSig: persistedDocumentSignature?.substring(0, 50),
|
||||
currentSig: currentPagesSignature.substring(0, 50),
|
||||
});
|
||||
|
||||
if (activeFileIds.length === 0) {
|
||||
console.log("[usePageDocument] No active files, returning null");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if persisted document is still valid
|
||||
// Must match signature AND have the same number of source files
|
||||
const persistedFileIds = persistedDocument
|
||||
? Array.from(
|
||||
new Set(
|
||||
persistedDocument.pages
|
||||
.map((p) => p.originalFileId)
|
||||
.filter(Boolean),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
const persistedIsValid =
|
||||
persistedDocument &&
|
||||
persistedDocumentSignature &&
|
||||
persistedDocumentSignature === currentPagesSignature &&
|
||||
currentPagesSignature.length > 0 &&
|
||||
persistedFileIds.length === activeFileIds.length; // Ensure file count matches
|
||||
|
||||
if (persistedIsValid) {
|
||||
console.log("[usePageDocument] Using persisted document");
|
||||
return persistedDocument;
|
||||
} else if (persistedDocument) {
|
||||
console.log(
|
||||
"[usePageDocument] Persisted document invalid - rebuilding:",
|
||||
{
|
||||
sigMatch: persistedDocumentSignature === currentPagesSignature,
|
||||
persistedFiles: persistedFileIds.length,
|
||||
activeFiles: activeFileIds.length,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!primaryStirlingFileStub?.processedFile &&
|
||||
placeholderDocumentRef.current
|
||||
) {
|
||||
return placeholderDocumentRef.current;
|
||||
}
|
||||
|
||||
const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null;
|
||||
|
||||
// If we have file IDs but no file record, something is wrong - return null to show loading
|
||||
if (!primaryStirlingFileStub) {
|
||||
console.log(
|
||||
"🎬 PageEditor: No primary file record found, showing loading",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const namingFileIds =
|
||||
selectedActiveFileIds.length > 0 ? selectedActiveFileIds : activeFileIds;
|
||||
|
||||
const name =
|
||||
namingFileIds.length <= 1
|
||||
? namingFileIds[0]
|
||||
? (selectors.getStirlingFileStub(namingFileIds[0])?.name ??
|
||||
"document.pdf")
|
||||
: "document.pdf"
|
||||
: namingFileIds
|
||||
.map((id) =>
|
||||
(selectors.getStirlingFileStub(id)?.name ?? "file").replace(
|
||||
/\.pdf$/i,
|
||||
"",
|
||||
),
|
||||
)
|
||||
.join(" + ");
|
||||
|
||||
// Build page insertion map from files with insertion positions
|
||||
const insertionMap = new Map<string, FileId[]>(); // insertAfterPageId -> fileIds
|
||||
const originalFileIds: FileId[] = [];
|
||||
|
||||
activeFileIds.forEach((fileId) => {
|
||||
const record = selectors.getStirlingFileStub(fileId);
|
||||
if (record?.insertAfterPageId !== undefined) {
|
||||
console.log("[usePageDocument] File has insertAfterPageId:", {
|
||||
fileId,
|
||||
insertAfterPageId: record.insertAfterPageId,
|
||||
});
|
||||
if (!insertionMap.has(record.insertAfterPageId)) {
|
||||
insertionMap.set(record.insertAfterPageId, []);
|
||||
}
|
||||
insertionMap.get(record.insertAfterPageId)!.push(fileId);
|
||||
} else {
|
||||
originalFileIds.push(fileId);
|
||||
}
|
||||
});
|
||||
|
||||
console.log("[usePageDocument] File categorization:", {
|
||||
originalFiles: originalFileIds.length,
|
||||
filesToInsert: insertionMap.size,
|
||||
totalActive: activeFileIds.length,
|
||||
});
|
||||
|
||||
// Build pages by interleaving original pages with insertions
|
||||
let pages: PDFPage[];
|
||||
|
||||
// Helper function to create pages from a file (or placeholder if deselected)
|
||||
const createPagesFromFile = (
|
||||
fileId: FileId,
|
||||
startPageNumber: number,
|
||||
isSelected: boolean,
|
||||
): PDFPage[] => {
|
||||
const stirlingFileStub = selectors.getStirlingFileStub(fileId);
|
||||
if (!stirlingFileStub) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// If file is deselected, create a single placeholder page
|
||||
if (!isSelected) {
|
||||
return [
|
||||
{
|
||||
id: `${fileId}-placeholder`,
|
||||
pageNumber: startPageNumber,
|
||||
originalPageNumber: 1,
|
||||
originalFileId: fileId,
|
||||
rotation: 0,
|
||||
thumbnail: null,
|
||||
selected: false,
|
||||
splitAfter: false,
|
||||
isPlaceholder: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const processedFile = stirlingFileStub.processedFile;
|
||||
|
||||
if (processedFile?.pages && processedFile.pages.length > 0) {
|
||||
// Use fully processed pages with thumbnails
|
||||
return processedFile.pages.map((page, pageIndex) => ({
|
||||
id: `${fileId}-${pageIndex + 1}`,
|
||||
pageNumber: startPageNumber + pageIndex,
|
||||
thumbnail: page.thumbnail || null,
|
||||
rotation: page.rotation || 0,
|
||||
selected: false,
|
||||
splitAfter: page.splitAfter || false,
|
||||
// Always use pageIndex + 1 for originalPageNumber to ensure correct numbering
|
||||
// This prevents stale or incorrect page numbers from being cached
|
||||
originalPageNumber: pageIndex + 1,
|
||||
originalFileId: fileId,
|
||||
isPlaceholder: false,
|
||||
}));
|
||||
}
|
||||
|
||||
if (processedFile?.totalPages) {
|
||||
// Fallback: create pages without thumbnails but with correct count
|
||||
return Array.from(
|
||||
{ length: processedFile.totalPages },
|
||||
(_, pageIndex) => ({
|
||||
id: `${fileId}-${pageIndex + 1}`,
|
||||
pageNumber: startPageNumber + pageIndex,
|
||||
originalPageNumber: pageIndex + 1,
|
||||
originalFileId: fileId,
|
||||
rotation: 0,
|
||||
thumbnail: null,
|
||||
selected: false,
|
||||
splitAfter: false,
|
||||
isPlaceholder: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// No processedFile yet - create a single loading placeholder
|
||||
// This will be replaced when processing completes
|
||||
return [
|
||||
{
|
||||
id: `${fileId}-loading`,
|
||||
pageNumber: startPageNumber,
|
||||
originalPageNumber: 1,
|
||||
originalFileId: fileId,
|
||||
rotation: 0,
|
||||
thumbnail: null,
|
||||
selected: false,
|
||||
splitAfter: false,
|
||||
isPlaceholder: true,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
// Collect all pages from original files, respecting their previous positions
|
||||
const selectedFileIdsSet = new Set(state.ui.selectedFileIds);
|
||||
|
||||
// Sort original files by their position in fileOrder (so placeholders stay in correct spot)
|
||||
// Use fileOrder as source of truth since it persists across page editor sessions
|
||||
const fileOrderMap = new Map(allFileIds.map((id, index) => [id, index]));
|
||||
|
||||
const sortedOriginalFileIds = [...originalFileIds].sort((a, b) => {
|
||||
const posA = fileOrderMap.get(a) ?? Number.MAX_SAFE_INTEGER;
|
||||
const posB = fileOrderMap.get(b) ?? Number.MAX_SAFE_INTEGER;
|
||||
return posA - posB;
|
||||
});
|
||||
|
||||
const originalFilePages: PDFPage[] = [];
|
||||
sortedOriginalFileIds.forEach((fileId) => {
|
||||
const isSelected = selectedFileIdsSet.has(fileId);
|
||||
const filePages = createPagesFromFile(fileId, 1, isSelected); // Temporary numbering
|
||||
originalFilePages.push(...filePages);
|
||||
});
|
||||
|
||||
// Start with all original pages numbered sequentially
|
||||
pages = originalFilePages.map((page, index) => ({
|
||||
...page,
|
||||
pageNumber: index + 1,
|
||||
}));
|
||||
|
||||
// Process each insertion by finding the page ID and inserting after it
|
||||
for (const [insertAfterPageId, fileIds] of insertionMap.entries()) {
|
||||
const targetPageIndex = pages.findIndex(
|
||||
(p) => p.id === insertAfterPageId,
|
||||
);
|
||||
|
||||
if (targetPageIndex === -1) continue;
|
||||
|
||||
// Collect all pages to insert
|
||||
const allNewPages: PDFPage[] = [];
|
||||
fileIds.forEach((fileId) => {
|
||||
const isSelected = selectedFileIdsSet.has(fileId);
|
||||
const insertedPages = createPagesFromFile(fileId, 1, isSelected);
|
||||
allNewPages.push(...insertedPages);
|
||||
});
|
||||
|
||||
// Insert all new pages after the target page
|
||||
pages.splice(targetPageIndex + 1, 0, ...allNewPages);
|
||||
|
||||
// Renumber all pages after insertion
|
||||
pages.forEach((page, index) => {
|
||||
page.pageNumber = index + 1;
|
||||
});
|
||||
}
|
||||
|
||||
if (pages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Pages are already in the correct order from the sorted assembly above
|
||||
// Just ensure page numbers are sequential
|
||||
pages = pages.map((page, index) => ({
|
||||
...page,
|
||||
pageNumber: index + 1,
|
||||
}));
|
||||
|
||||
const currentPagesSet = currentPages
|
||||
? new Set(currentPages.map((page) => page.id))
|
||||
: null;
|
||||
if (
|
||||
currentPagesSet &&
|
||||
currentPages &&
|
||||
currentPagesSet.size === pages.length
|
||||
) {
|
||||
const sameIds = pages.every((page) => currentPagesSet.has(page.id));
|
||||
if (sameIds) {
|
||||
const mergedById = new Map(pages.map((page) => [page.id, page]));
|
||||
pages = currentPages.map((currentPage, index) => {
|
||||
const source = mergedById.get(currentPage.id);
|
||||
const mergedPage = source
|
||||
? { ...source, ...currentPage }
|
||||
: currentPage;
|
||||
return {
|
||||
...mergedPage,
|
||||
pageNumber: index + 1,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const mergedDoc: PDFDocument = {
|
||||
id: activeFileIds.join("-"),
|
||||
name,
|
||||
file: primaryFile!,
|
||||
pages,
|
||||
totalPages: pages.length,
|
||||
};
|
||||
|
||||
return mergedDoc;
|
||||
}, [
|
||||
activeFileIds,
|
||||
selectedActiveFileIds,
|
||||
primaryFileId,
|
||||
primaryStirlingFileStub,
|
||||
processedFilePages,
|
||||
processedFileTotalPages,
|
||||
selectors,
|
||||
activeFilesSignature,
|
||||
selectedFileIdsKey,
|
||||
state.ui.selectedFileIds,
|
||||
state.files.byId, // Force recompute when any file stub changes (including processedFile updates)
|
||||
allFileIds,
|
||||
currentPagesSignature,
|
||||
currentPages,
|
||||
persistedDocument,
|
||||
persistedDocumentSignature,
|
||||
placeholderVersion,
|
||||
]);
|
||||
|
||||
// Large document detection for smart loading
|
||||
const isVeryLargeDocument = useMemo(() => {
|
||||
return mergedPdfDocument ? mergedPdfDocument.totalPages > 2000 : false;
|
||||
}, [mergedPdfDocument?.totalPages]);
|
||||
|
||||
// Loading state
|
||||
const isLoading = globalProcessing && !mergedPdfDocument;
|
||||
|
||||
return {
|
||||
document: mergedPdfDocument,
|
||||
isVeryLargeDocument,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user