From c731d5fd5d696385742b1f9e7fe687725f6ee31c Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Fri, 15 May 2026 15:36:50 +0100 Subject: [PATCH] UI redesign staging (#6149) Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Co-authored-by: James Brunton --- AGENTS.md | 20 +- frontend/.prettierignore | 2 + .../public/locales/en-GB/translation.toml | 159 +++- frontend/src/core/components/AppLayout.tsx | 3 +- frontend/src/core/components/AppProviders.tsx | 6 +- frontend/src/core/components/FileManager.tsx | 23 +- .../components/fileEditor/AddFileCard.tsx | 14 +- .../fileEditor/FileEditor.module.css | 41 +- .../core/components/fileEditor/FileEditor.tsx | 139 +--- .../fileEditor/FileEditorStatusDot.tsx | 9 + .../fileEditor/FileEditorThumbnail.module.css | 266 +++++++ .../fileEditor/FileEditorThumbnail.tsx | 577 ++++++--------- .../fileEditor/fileEditorRightRailButtons.tsx | 89 --- .../fileManager/CompactFileDetails.tsx | 21 +- .../components/fileManager/FileDetails.tsx | 24 +- .../fileManager/FileHistoryGroup.tsx | 3 - .../components/fileManager/FileListArea.tsx | 3 - .../components/fileManager/FileListItem.tsx | 25 +- .../fileManager/FileSourceButtons.tsx | 20 +- .../components/layout/Workbench.module.css | 21 + .../src/core/components/layout/Workbench.tsx | 92 +-- .../core/components/onboarding/Onboarding.tsx | 6 +- .../components/onboarding/adminStepsConfig.ts | 15 +- .../core/components/onboarding/tourUtils.ts | 110 +++ .../components/onboarding/userStepsConfig.ts | 26 +- .../onboarding/whatsNewStepsConfig.ts | 93 +-- .../core/components/pageEditor/PageEditor.tsx | 49 +- .../pageEditor/PageEditorControls.tsx | 29 +- .../pageEditor/PageSelectByNumberButton.tsx | 5 +- ....tsx => pageEditorWorkbenchBarButtons.tsx} | 48 +- .../rightRail/RightRailFooterExtensions.tsx | 9 - .../core/components/shared/AppConfigModal.tsx | 24 +- .../core/components/shared/BulkShareModal.tsx | 2 +- .../shared/BulkUploadToServerModal.tsx | 2 +- .../components/shared/CloudStorageIcons.tsx | 79 ++ .../src/core/components/shared/FileCard.tsx | 74 +- .../core/components/shared/FileDocIcon.tsx | 204 +++++ .../components/shared/FileDropdownMenu.tsx | 2 +- .../components/shared/FilePickerModal.tsx | 35 +- .../shared/FileSelectorPicker.module.css | 283 +++++++ .../components/shared/FileSelectorPicker.tsx | 632 ++++++++++++++++ .../core/components/shared/FileSidebar.css | 408 ++++++++++ .../core/components/shared/FileSidebar.tsx | 594 +++++++++++++++ .../components/shared/FileSidebarFileItem.css | 283 +++++++ .../components/shared/FileSidebarFileItem.tsx | 297 ++++++++ .../components/shared/HoverActionMenu.tsx | 21 +- .../src/core/components/shared/InfoBanner.tsx | 42 +- .../core/components/shared/LandingPage.css | 13 +- .../core/components/shared/LandingPage.tsx | 8 +- .../components/shared/LanguageSelector.tsx | 14 +- .../components/shared/MultiSelectControls.tsx | 4 +- .../shared/PageEditorFileDropdown.tsx | 2 +- .../src/core/components/shared/RightRail.tsx | 375 ---------- .../src/core/components/shared/TextInput.tsx | 11 +- .../shared/ViewerInlineControls.tsx | 81 ++ .../core/components/shared/WorkbenchBar.css | 187 +++++ .../core/components/shared/WorkbenchBar.tsx | 490 ++++++++++++ .../shared/config/configNavSections.tsx | 15 + .../config/configSections/GeneralSection.tsx | 66 +- .../config/configSections/HelpSection.tsx | 86 +++ .../core/components/shared/config/types.ts | 1 + .../shared/filePreview/DocumentThumbnail.tsx | 127 +++- .../shared/filePreview/getFileTypeIcon.tsx | 108 ++- .../shared/rightRail/RightRail.README.md | 145 ---- .../components/shared/rightRail/RightRail.css | 170 ----- .../shared/signing/CreateSessionPanel.tsx | 4 +- .../shared/textInput/TextInput.module.css | 4 + .../tools/FullscreenToolSurface.tsx | 70 +- .../src/core/components/tools/ToolPanel.css | 80 +- .../src/core/components/tools/ToolPanel.tsx | 166 +++-- .../components/tools/ToolPanelViewerBar.tsx | 99 +++ .../src/core/components/tools/ToolPicker.tsx | 12 +- .../tools/automate/AutomationRun.tsx | 4 +- .../tools/compare/CompareDocumentPane.tsx | 14 +- .../tools/compare/CompareWorkbenchView.tsx | 8 +- .../components/tools/compare/compareView.css | 121 ++- ....tsx => useCompareWorkbenchBarButtons.tsx} | 14 +- .../components/tools/crop/CropSettings.tsx | 16 +- .../tools/fullscreen/CompactToolItem.tsx | 4 +- .../tools/fullscreen/DetailedToolItem.tsx | 4 +- .../tools/rotate/RotateSettings.tsx | 10 +- .../tools/shared/FileStatusIndicator.tsx | 17 +- .../tools/shared/OperationButton.tsx | 4 + .../tools/shared/ReviewToolStep.tsx | 1 + .../tools/shared/ScopedOperationButton.tsx | 68 +- .../components/tools/showJS/ShowJSView.css | 2 +- .../tools/toolPicker/ToolButton.tsx | 15 +- .../tools/toolPicker/ToolPicker.css | 11 +- .../tools/toolPicker/ToolSearch.tsx | 10 +- .../components/viewer/CommentsSidebar.tsx | 2 +- .../core/components/viewer/EmbedPdfViewer.tsx | 106 +-- .../core/components/viewer/LocalEmbedPDF.tsx | 30 +- .../core/components/viewer/NonPdfViewer.tsx | 13 +- .../components/viewer/PdfViewerToolbar.tsx | 66 +- .../components/viewer/ThumbnailSidebar.tsx | 6 +- .../src/core/components/viewer/Viewer.tsx | 13 +- .../ViewerAnnotationControls.tsx | 41 +- .../components/viewer/nonpdf/NonPdfBanner.tsx | 52 +- .../components/viewer/nonpdf/TextViewer.tsx | 5 +- ...s.tsx => useViewerWorkbenchBarButtons.tsx} | 118 ++- frontend/src/core/contexts/FileContext.tsx | 14 +- .../src/core/contexts/FileManagerContext.tsx | 157 +++- .../src/core/contexts/FilesModalContext.tsx | 31 +- .../src/core/contexts/IndexedDBContext.tsx | 70 +- .../src/core/contexts/PageEditorContext.tsx | 3 + .../src/core/contexts/RightRailContext.tsx | 116 --- frontend/src/core/contexts/SidebarContext.tsx | 4 +- .../src/core/contexts/ToolWorkflowContext.tsx | 23 + .../contexts/TourOrchestrationContext.tsx | 16 + frontend/src/core/contexts/ViewerContext.tsx | 34 +- .../src/core/contexts/WorkbenchBarContext.tsx | 132 ++++ .../src/core/contexts/file/fileActions.ts | 166 ++--- .../src/core/contexts/viewer/viewerActions.ts | 8 + .../core/data/useTranslatedToolRegistry.tsx | 100 ++- .../changeMetadata/useMetadataExtraction.ts | 4 +- .../getPdfInfo/useGetPdfInfoOperation.ts | 2 +- .../hooks/tools/shared/toolOperationTypes.ts | 1 + .../hooks/tools/shared/useToolOperation.ts | 6 +- .../hooks/tools/shared/useViewScopedFiles.ts | 16 +- .../hooks/tools/showJS/useShowJSOperation.ts | 2 +- .../core/hooks/tools/useToolPanelGeometry.ts | 47 +- .../core/hooks/useFileActionTerminology.ts | 2 +- frontend/src/core/hooks/useFileManager.ts | 22 +- frontend/src/core/hooks/useFileThumbnail.ts | 19 + frontend/src/core/hooks/useFileWithUrl.ts | 10 +- .../src/core/hooks/useRightRailTooltipSide.ts | 35 - frontend/src/core/hooks/useTooltipPosition.ts | 6 +- frontend/src/core/hooks/useUrlSync.ts | 11 +- ...ilButtons.ts => useWorkbenchBarButtons.ts} | 25 +- .../core/hooks/useWorkbenchBarTooltipSide.ts | 8 + frontend/src/core/pages/HomePage.tsx | 28 +- frontend/src/core/services/fileStorage.ts | 90 ++- frontend/src/core/styles/theme.css | 31 +- .../enterprise/license-and-features.spec.ts | 2 +- .../enterprise/oauth-keycloak-login.spec.ts | 2 +- .../enterprise/saml-keycloak-login.spec.ts | 2 +- frontend/src/core/tests/helpers/api-stubs.ts | 9 + .../src/core/tests/helpers/stub-test-base.ts | 26 +- .../tests/stubbed/automation-page-ui.spec.ts | 4 +- .../src/core/tests/stubbed/compare.spec.ts | 142 ++-- .../stubbed/language-localization.spec.ts | 28 +- .../core/tests/stubbed/main-dashboard.spec.ts | 14 +- .../src/core/tests/stubbed/merge-tool.spec.ts | 5 +- .../src/core/tests/stubbed/navigation.spec.ts | 3 +- .../tests/stubbed/pdf-text-search.spec.ts | 26 +- .../src/core/tests/stubbed/settings.spec.ts | 13 +- .../tests/stubbed/tool-pages-common.spec.ts | 14 +- .../tests/stubbed/tour-onboarding.spec.ts | 264 +++++-- .../stubbed/workspace-people-teams.spec.ts | 2 +- frontend/src/core/tools/Annotate.tsx | 8 +- frontend/src/core/tools/Automate.tsx | 4 +- frontend/src/core/tools/Compare.tsx | 698 ++++++------------ frontend/src/core/tools/Merge.tsx | 13 +- frontend/src/core/types/sidebar.ts | 1 - .../types/{rightRail.ts => workbenchBar.ts} | 14 +- frontend/src/core/utils/fileUtils.ts | 13 +- .../src/core/utils/homePageNavigation.test.ts | 18 +- frontend/src/core/utils/homePageNavigation.ts | 5 +- frontend/src/core/utils/pdfiumPageRender.ts | 30 +- frontend/src/core/utils/thumbnailUtils.ts | 572 +------------- .../fileEditor/FileEditorFileName.test.tsx | 75 -- .../fileEditor/FileEditorFileName.tsx | 71 -- .../fileEditor/FileEditorStatusDot.tsx | 36 + .../rightRail/RightRailFooterExtensions.tsx | 131 ---- .../shared/config/configNavSections.tsx | 2 + .../src/desktop/hooks/useFileActionIcons.ts | 4 +- .../desktop/hooks/useFileActionTerminology.ts | 2 +- frontend/src/desktop/hooks/useSaveShortcut.ts | 2 +- .../components/shared/UpgradeBanner.tsx | 3 +- .../shared/config/configNavSections.tsx | 10 +- .../saas/components/auth/GuestUserBanner.tsx | 2 +- .../src/saas/components/shared/InfoBanner.tsx | 4 +- .../saas/components/shared/config/types.ts | 4 +- 173 files changed, 7290 insertions(+), 4269 deletions(-) create mode 100644 frontend/src/core/components/fileEditor/FileEditorStatusDot.tsx create mode 100644 frontend/src/core/components/fileEditor/FileEditorThumbnail.module.css delete mode 100644 frontend/src/core/components/fileEditor/fileEditorRightRailButtons.tsx create mode 100644 frontend/src/core/components/onboarding/tourUtils.ts rename frontend/src/core/components/pageEditor/{pageEditorRightRailButtons.tsx => pageEditorWorkbenchBarButtons.tsx} (78%) delete mode 100644 frontend/src/core/components/rightRail/RightRailFooterExtensions.tsx create mode 100644 frontend/src/core/components/shared/CloudStorageIcons.tsx create mode 100644 frontend/src/core/components/shared/FileDocIcon.tsx create mode 100644 frontend/src/core/components/shared/FileSelectorPicker.module.css create mode 100644 frontend/src/core/components/shared/FileSelectorPicker.tsx create mode 100644 frontend/src/core/components/shared/FileSidebar.css create mode 100644 frontend/src/core/components/shared/FileSidebar.tsx create mode 100644 frontend/src/core/components/shared/FileSidebarFileItem.css create mode 100644 frontend/src/core/components/shared/FileSidebarFileItem.tsx delete mode 100644 frontend/src/core/components/shared/RightRail.tsx create mode 100644 frontend/src/core/components/shared/ViewerInlineControls.tsx create mode 100644 frontend/src/core/components/shared/WorkbenchBar.css create mode 100644 frontend/src/core/components/shared/WorkbenchBar.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/HelpSection.tsx delete mode 100644 frontend/src/core/components/shared/rightRail/RightRail.README.md delete mode 100644 frontend/src/core/components/shared/rightRail/RightRail.css create mode 100644 frontend/src/core/components/tools/ToolPanelViewerBar.tsx rename frontend/src/core/components/tools/compare/hooks/{useCompareRightRailButtons.tsx => useCompareWorkbenchBarButtons.tsx} (93%) rename frontend/src/core/components/{shared/rightRail => viewer}/ViewerAnnotationControls.tsx (84%) rename frontend/src/core/components/viewer/{useViewerRightRailButtons.tsx => useViewerWorkbenchBarButtons.tsx} (83%) delete mode 100644 frontend/src/core/contexts/RightRailContext.tsx create mode 100644 frontend/src/core/contexts/WorkbenchBarContext.tsx create mode 100644 frontend/src/core/hooks/useFileThumbnail.ts delete mode 100644 frontend/src/core/hooks/useRightRailTooltipSide.ts rename frontend/src/core/hooks/{useRightRailButtons.ts => useWorkbenchBarButtons.ts} (61%) create mode 100644 frontend/src/core/hooks/useWorkbenchBarTooltipSide.ts rename frontend/src/core/types/{rightRail.ts => workbenchBar.ts} (74%) delete mode 100644 frontend/src/desktop/components/fileEditor/FileEditorFileName.test.tsx delete mode 100644 frontend/src/desktop/components/fileEditor/FileEditorFileName.tsx create mode 100644 frontend/src/desktop/components/fileEditor/FileEditorStatusDot.tsx delete mode 100644 frontend/src/desktop/components/rightRail/RightRailFooterExtensions.tsx diff --git a/AGENTS.md b/AGENTS.md index a90ceecb5..fa0340c27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -183,26 +183,26 @@ Use this pattern for desktop-specific or proprietary-specific features WITHOUT r **Example - Desktop-specific footer:** ```typescript -// core/components/rightRail/RightRailFooterExtensions.tsx (stub) -interface RightRailFooterExtensionsProps { +// core/components/workbenchBar/WorkbenchBarFooterExtensions.tsx (stub) +interface WorkbenchBarFooterExtensionsProps { className?: string; } -export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps) { +export function WorkbenchBarFooterExtensions(_props: WorkbenchBarFooterExtensionsProps) { return null; // Stub - does nothing in web builds } ``` ```tsx -// desktop/components/rightRail/RightRailFooterExtensions.tsx (real implementation) +// desktop/components/workbenchBar/WorkbenchBarFooterExtensions.tsx (real implementation) import { Box } from '@mantine/core'; import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator'; -interface RightRailFooterExtensionsProps { +interface WorkbenchBarFooterExtensionsProps { className?: string; } -export function RightRailFooterExtensions({ className }: RightRailFooterExtensionsProps) { +export function WorkbenchBarFooterExtensions({ className }: WorkbenchBarFooterExtensionsProps) { return ( @@ -212,15 +212,15 @@ export function RightRailFooterExtensions({ className }: RightRailFooterExtensio ``` ```tsx -// core/components/shared/RightRail.tsx (usage - works in ALL builds) -import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions'; +// core/components/shared/WorkbenchBar.tsx (usage - works in ALL builds) +import { WorkbenchBarFooterExtensions } from '@app/components/workbenchBar/WorkbenchBarFooterExtensions'; -export function RightRail() { +export function WorkbenchBar() { return (
{/* In web builds: renders nothing (stub returns null) */} {/* In desktop builds: renders BackendHealthIndicator */} - +
); } diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 3ad5fb2f0..f4812a8f1 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -2,6 +2,8 @@ dist/ # Tauri/Cargo build output (binary assets named *.js etc. confuse Prettier) src-tauri/target/ node_modules/ +src-tauri/target/ +src-tauri/gen/ public/vendor/ public/pdfjs*/ public/js/thirdParty/ diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 9dc9a8915..5ae0cfc43 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -52,8 +52,8 @@ exportAndContinue = "Export & Continue" false = "False" fileNotSavedToDisk = "Not saved to disk" fileSavedToDisk = "File saved to disk" -fileSelected = "Selected: {{filename}}" -filesSelected = "{{count}} files selected" +fileSelected = "{{filename}}" +filesSelected = "{{count}} files" font = "Font" fontSizeTooltip = "Size of the page number text in points. Larger numbers create bigger text." fontTypeTooltip = "Font family for the page numbers. Choose based on your document style." @@ -82,7 +82,7 @@ multiPdfPrompt = "Select PDFs (2+)" never = "Never" no = "No" noFavourites = "No favourites added" -noFileSelected = "No file selected. Please upload one." +noFileSelected = "No file loaded. Please upload one." noFilesToUndo = "Cannot undo: no files were processed in the last operation" noOperationToUndo = "No operation to undo" notAuthenticatedMessage = "User not authenticated." @@ -1460,14 +1460,14 @@ inactive = "Inactive" [adminOnboarding] adminTools = "Finally, we have advanced administration tools like Auditing to track system activity and Usage Analytics to monitor how your users interact with the platform." -configButton = "Click the Config button to access all system settings and administrative controls." +configButton = "Open Settings to access all system configuration and administrative controls." connectionsSection = "The Connections section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications." databaseSection = "For advanced production environments, we have settings to allow external database hookups so you can integrate with your existing infrastructure." settingsOverview = "This is the Settings Panel. Admin settings are organised by category for easy navigation." systemCustomization = "We have extensive ways to customise the UI: System Settings let you change the app name and languages, Features allows server certificate management, and Endpoints lets you enable or disable specific tools for your users." teamsAndUsers = "Manage Teams and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself." welcome = "Welcome to the Admin Tour! Let's explore the powerful enterprise features and settings available to system administrators." -wrapUp = "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the Help menu." +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 Settings and find it here in the Tours section under Help." [adminUserSettings] actions = "Actions" @@ -2728,6 +2728,7 @@ used = "used" [compare] addFilesHint = "Add PDFs in the Files step to enable selection." clearSelected = "Clear selected" +clearSlot = "Remove file" cta = "Compare" header = "Compare PDFs" loading = "Comparing..." @@ -2753,7 +2754,7 @@ label = "Original document" placeholder = "Select the original PDF" [compare.clear] -confirm = "Clear and return" +confirm = "Clear Selected" confirmBody = "This will close the current comparison and take you back to Active Files." confirmTitle = "Clear selected PDFs?" @@ -2779,6 +2780,7 @@ title = "These PDFs look highly different" [compare.edited] label = "Edited PDF" +selectBaseFirst = "Select original PDF first" placeholder = "Select the edited PDF" [compare.error] @@ -2860,6 +2862,7 @@ pageLabel = "Page" [compare.swap] confirm = "Swap and Re-run" +label = "Swap" confirmBody = "This will rerun the tool. Are you sure you want to swap the order of Original and Edited?" confirmTitle = "Re-run comparison?" @@ -3109,7 +3112,7 @@ maintainAspectRatio = "Maintain Aspect Ratio" markdown = "Markdown" maxAttachmentSize = "Maximum attachment size (MB)" multiple = "Multiple" -noFileSelected = "No file selected. Use the file panel to add files." +noFileSelected = "No file loaded. Use the file panel to add files." odpExt = "OpenDocument Presentation (.odp)" odtExt = "OpenDocument Text (.odt)" officeDocs = "Office Documents (Word, Excel, PowerPoint)" @@ -3130,9 +3133,9 @@ pdfxDigitalSignatureWarning = "The PDF contains a digital signature. This will b pptExt = "PowerPoint (.pptx)" results = "Results" rtfExt = "Rich Text Format (.rtf)" -selectedFiles = "Selected files" -selectFilesPlaceholder = "Select files in the main view to get started" -selectSourceFormatFirst = "Select a source format first" +selectedFiles = "Active files" +selectFilesPlaceholder = "Add files to the workbench to get started" +selectSourceFormatFirst = "Choose a source format first" settings = "Settings" single = "Single" sourceFormatPlaceholder = "Source format" @@ -3287,7 +3290,7 @@ forRegularWork = "For regular PDF work:" [crop] autoCrop = "Auto-crop whitespace" header = "Crop PDF" -noFileSelected = "Select a PDF file to begin cropping" +noFileSelected = "Add a PDF file to begin cropping" reset = "Reset to full PDF" submit = "Apply Crop" title = "Crop" @@ -3492,6 +3495,8 @@ unlockAll = "Use for all ({{count}})" unlockAllPartialFail = "Wrong password for: {{names}}" unlockAllSuccess = "Unlocked {{count}} file(s)." unlockPrompt = "Unlock PDF to continue" +viewerLocked = "This PDF is password-protected" +viewerUnlock = "Unlock" [encryptedPdfUnlock.password] label = "PDF password" @@ -3599,25 +3604,29 @@ or = "or" [fileEditor] addFiles = "Add Files" +pageCount = "{{count}} pages" +pageCount_one = "{{count}} page" +pageCount_other = "{{count}} pages" [fileManager] active = "Active" addToUpload = "Add to Upload" changesNotUploaded = "Changes not uploaded" clearAll = "Clear All" -clearSelection = "Clear Selection" +clearSelection = "Clear" clickToUpload = "Click to upload files" +closeAllFiles = "Close all files" closeFile = "Close File" cloudFile = "Cloud file" copyCreated = "Copy saved to this device." copyFailed = "Could not create a copy." delete = "Delete" deleteAll = "Delete All" -deleteSelected = "Delete Selected" -deselectAll = "Deselect All" +deleteSelected = "Delete Files" +deselectAll = "Uncheck All" details = "File Details" download = "Download" -downloadSelected = "Download Selected" +downloadSelected = "Download Files" dragDrop = "Drag & Drop files here" dropFilesHere = "Drop files here" failedToLoad = "Failed to load file to active set." @@ -3626,7 +3635,7 @@ fileFormat = "Format" fileHistory = "File History" fileName = "Name" fileSize = "Size" -filesSelected = "files selected" +filesSelected = "files" filesStored = "files stored" fileVersion = "Version" filterAll = "All" @@ -3652,7 +3661,7 @@ mobileUpload = "Mobile Upload" mobileUploadNotAvailable = "Mobile upload not enabled" myFiles = "My Files" noFiles = "No files available" -noFileSelected = "No files selected" +noFileSelected = "No files chosen" noFilesFound = "No files found matching your search" noRecentFiles = "No recent files found" openFile = "Open File" @@ -3675,18 +3684,19 @@ removeSharedPrompt = "This file is shared with you. You can remove it from this removeSharedServerOnlyBlockedPrompt = "This file is shared with you and stored only on the server." removeSharedServerOnlyPrompt = "This file is shared with you and stored only on the server. Remove it from your list?" restore = "Restore" -saveSelected = "Save Selected" +saveSelected = "Save Files" searchFiles = "Search files..." -selectAll = "Select All" -selectedCount = "{{count}} selected" -selectedFiles = "Selected Files" +selectAll = "Check All" +selectedCount = "{{count}} checked" +selectedFiles = "Files Added" share = "Share" +shared = "Shared" sharedByYou = "Shared by you" 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." sharedEditNoticeConfirm = "Got it" sharedEditNoticeTitle = "Read-only server copy" sharedWithYou = "Shared with you" -shareSelected = "Share Selected" +shareSelected = "Share Files" sharing = "Sharing" showAll = "Show All" showHistory = "Show History" @@ -3703,24 +3713,59 @@ supportMessage = "Powered by browser database storage for unlimited capacity" synced = "Synced" title = "Upload PDF Files" toolChain = "Tools Applied" -totalSelected = "Total Selected" +totalSelected = "Total Files" unsupported = "Unsupported" unzip = "Unzip" updateOnServer = "Update on Server" uploadError = "Failed to upload some files." -uploadSelected = "Upload Selected" +uploadSelected = "Upload Files" uploadToServer = "Upload to Server" [files] addFiles = "Add files" created = "Created" -selectFromWorkbench = "Select files from the workbench or " -selectMultipleFromWorkbench = "Select at least {{count}} files from the workbench or " +selectFromWorkbench = "Add files to the workbench or " +selectMultipleFromWorkbench = "Add at least {{count}} files to the workbench or " size = "File Size" title = "Files" upload = "Upload" uploadFiles = "Upload Files" +[fileSelectorPicker] +empty = "No files available" +placeholder = "Select file" +search = "Filter files…" +tabListLabel = "Saved files, workbench, upload" +upload = "Upload from computer" + +[fileSelectorPicker.sort] +dateAsc = "Oldest first" +dateDesc = "Newest first" +dateLabel = "Latest" +nameAsc = "A to Z" +nameDesc = "Z to A" +nameLabel = "A–Z" + +[fileSelectorPicker.tabs] +saved = "Saved files" +upload = "Upload" +workbench = "Workbench" + +[fileSidebar] +addFiles = "Add files" +collapse = "Collapse sidebar" +dropHint = "Open files to get started" +expand = "Expand sidebar" +files = "Files" +googleDrive = "Google Drive" +googleDriveDisabled = "Google Drive is not configured" +noFiles = "No files yet" +openFileManager = "Open file manager" +openFromComputer = "Open from computer" +openSettings = "Open settings" +search = "Search" +searchPlaceholder = "Search files..." + [fileToPDF] credit = "This service uses LibreOffice and Unoconv for file conversion." header = "Convert any file to PDF" @@ -4592,7 +4637,7 @@ goToFileEditor = "Go to file editor" submit = "Merge" tags = "merge,Page operations,Back end,server side" title = "Merge" -viewerModeHint = "Merge needs 2 or more files. Head to the file editor to select them." +viewerModeHint = "Merge needs 2 or more files. Head to the file editor to add them." [merge.error] failed = "An error occurred while merging the PDFs." @@ -4615,7 +4660,7 @@ title = "Remove Digital Signature" ascending = "Ascending" dateModified = "Date Modified" descending = "Descending" -description = "Files will be merged in the order they're selected. Drag to reorder or sort below." +description = "Files will be merged in the order shown. Drag to reorder or sort below." filename = "File Name" label = "Sort By" sort = "Sort" @@ -4859,7 +4904,7 @@ toolInterface = "This is the Crop tool interface. As you can se viewer = "The Viewer lets you read and annotate your PDFs." viewSwitcher = "Use these controls to select how you want to view your PDFs." workbench = "This is the Workbench - the main area where you view and edit your PDFs." -wrapUp = "You're all set! You've learnt about the main areas of the app and how to use them. Click the Help button whenever you like to see this tour again." +wrapUp = "You're all set! You can replay this tour anytime — just open Settings and find it here in the Tours section under Help." [onboarding.buttons] back = "Back" @@ -4924,7 +4969,6 @@ fileUpload = "Use the Files button to upload or pick a recent P leftPanel = "The left Tools panel lists everything you can do. Browse categories or search to find a tool quickly." pageEditorView = "Switch to the Page Editor to reorder, rotate, or delete pages." quickAccess = "Start at the Quick Access rail to jump between Reader, Automate, your files, and all the tours." -rightRail = "The Right Rail holds quick actions to select files, change theme or language, and download results." topBar = "The top bar lets you swap between Viewer, Page Editor, and Active Files." wrapUp = "That is what is new in V2. Open the Tours menu anytime to replay this, the Tools tour, or the Admin tour." @@ -4996,8 +5040,8 @@ text = "Foreground places the overlay on top of the page. Background places it b title = "Overlay Position" [pageEdit] -deselectAll = "Select None" -selectAll = "Select All" +deselectAll = "None" +selectAll = "Check All" [pageEditor] actualSize = "Actual Size" @@ -6525,9 +6569,10 @@ title = "High Contrast" text = "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions." title = "Invert All Colours" -[rightRail] +[workbenchBar] annotations = "Annotations" applyRedactionsFirst = "Apply redactions first" +closeAll = "Close All Files" closePdf = "Close PDF" closeSelected = "Close Selected Files" deleteSelected = "Delete Selected Pages" @@ -6557,6 +6602,7 @@ search = "Search PDF" selectAll = "Select All" selectByNumber = "Select by Page Numbers" selectLanguage = "Select language" +share = "Share" toggleAnnotations = "Toggle Annotations Visibility" toggleAttachments = "Toggle Attachments" toggleBookmarks = "Toggle Bookmarks" @@ -6564,6 +6610,9 @@ toggleComments = "Comments" toggleLayers = "Toggle Layers" toggleSidebar = "Toggle Sidebar" toggleTheme = "Toggle Theme" +activeFiles = "Active Files" +multiTool = "Multi-Tool" +viewer = "Viewer" [rotate] rotateLeft = "Rotate Anticlockwise" @@ -6797,7 +6846,7 @@ defaultPdfEditorChecking = "Checking..." defaultPdfEditorInactive = "Another application is set as default" defaultPdfEditorSet = "Already Default" defaultStartupView = "Default view on launch" -defaultStartupViewDescription = "Choose which tab is active in the left column when the app starts" +defaultStartupViewDescription = "Choose which view is active when the app starts" defaultToolPickerMode = "Default tool picker mode" defaultToolPickerModeDescription = "Choose whether the tool picker opens in fullscreen or sidebar by default" defaultViewerZoom = "Default reader zoom" @@ -6807,8 +6856,14 @@ hideUnavailableConversions = "Hide unavailable conversions" hideUnavailableConversionsDescription = "Remove disabled conversion options in the Convert tool instead of showing them greyed out." hideUnavailableTools = "Hide unavailable tools" hideUnavailableToolsDescription = "Remove tools that have been disabled by your server instead of showing them greyed out." +language = "Language" +languageDescription = "Choose the display language" logout = "Log out" setAsDefault = "Set as Default" +theme = "Theme" +themeDark = "Dark" +themeDescription = "Switch between light and dark mode" +themeLight = "Light" title = "General" user = "User" @@ -6983,6 +7038,20 @@ title = "Team" enableLoginFirst = "Enable login mode first" requiresEnterprise = "Requires Enterprise license" +[settings.help] +label = "Tours" +title = "Help" + +[settings.help.adminTour] +description = "Explore team management, system settings, and enterprise features." +start = "Start" +title = "Admin Tour" + +[settings.help.toolsTour] +description = "Walk through uploading files, picking a tool, and reviewing results." +start = "Start" +title = "Tools Tour" + [settings.workspace] people = "People" teams = "Teams" @@ -7580,8 +7649,8 @@ accessLimitedCommenter = "Comment access is coming soon. Ask the owner for edito accessLimitedTitle = "Limited access" accessLimitedViewer = "This link is view-only. Ask the owner for editor access if you need to download." addUser = "Add" -bulkDescription = "Create one link to share all selected files with signed-in users." -bulkTitle = "Share selected files" +bulkDescription = "Create one link to share all checked files with signed-in users." +bulkTitle = "Share checked files" commenterHint = "Commenting is coming soon." copied = "Link copied to clipboard" copy = "Copy" @@ -7600,7 +7669,7 @@ errorTitle = "Share failed" expiredBody = "This share link is invalid or has expired." expiredTitle = "Link expired" failure = "Unable to generate a share link. Please try again." -fileCount = "{{count}} files selected" +fileCount = "{{count}} files" fileLabel = "File" generate = "Generate Link" generated = "Share link generated" @@ -7656,12 +7725,12 @@ viewed = "Viewed" viewsCount = "Views: {{count}}" [storageUpload] -bulkDescription = "This uploads the selected files to your server storage." -bulkTitle = "Upload selected files" +bulkDescription = "This uploads the checked files to your server storage." +bulkTitle = "Upload checked files" description = "This uploads the current file to server storage for your own access." errorTitle = "Upload failed" failure = "Upload failed. Please check your login and storage settings." -fileCount = "{{count}} files selected" +fileCount = "{{count}} files" fileLabel = "File" hint = "Public links and access modes are controlled by your server settings." more = " +{{count}} more" @@ -7821,19 +7890,26 @@ settings = "Settings" [tool] endpointUnavailable = "This tool is unavailable on your server." endpointUnavailableClickable = "Not available in this mode. Click to sign in." +filesLoading = "Files are still loading, please wait." +filesLoadingProgress = "{{loaded}} / {{total}} files loading..." invalidParams = "Fill in the required settings." noFiles = "Add a file to get started." scopeFiles = "files" scopeThisFile = "this file" selectFilesHint = "Select files in Active Files to run this tool" singleFileScope = "Only applying to: {{fileName}}" -viewerMode = "Switch to the file editor to select multiple files." +viewerMode = "Switch to the file editor to add multiple files." [toolPanel] +allTools = "All tools" alpha = "Alpha" +backToTools = "Back to tools" +collapse = "Collapse panel" comingSoon = "Coming soon:" +expand = "Expand panel" placeholder = "Choose a tool to get started" premiumFeature = "Premium feature:" +search = "Search tools" [toolPanel.fullscreen] comingSoon = "Coming soon:" @@ -8384,6 +8460,7 @@ bullet3 = "Image will be resized to fit signature area" description = "Upload a pre-created signature image. Ideal if you have a scanned signature or company logo." title = "Upload Signature Image" + [workspace] title = "Workspace" diff --git a/frontend/src/core/components/AppLayout.tsx b/frontend/src/core/components/AppLayout.tsx index ca2637434..1f0e96977 100644 --- a/frontend/src/core/components/AppLayout.tsx +++ b/frontend/src/core/components/AppLayout.tsx @@ -16,8 +16,7 @@ export function AppLayout({ children }: AppLayoutProps) { return ( <> diff --git a/frontend/src/core/components/AppProviders.tsx b/frontend/src/core/components/AppProviders.tsx index c32b00c40..4f6f5566a 100644 --- a/frontend/src/core/components/AppProviders.tsx +++ b/frontend/src/core/components/AppProviders.tsx @@ -17,7 +17,7 @@ import { AppConfigRetryOptions, useAppConfig, } from "@app/contexts/AppConfigContext"; -import { RightRailProvider } from "@app/contexts/RightRailContext"; +import { WorkbenchBarProvider } from "@app/contexts/WorkbenchBarContext"; import { ViewerProvider } from "@app/contexts/ViewerContext"; import { SignatureProvider } from "@app/contexts/SignatureContext"; import { AnnotationProvider } from "@app/contexts/AnnotationContext"; @@ -139,13 +139,13 @@ export function AppProviders({ - + {children} - + diff --git a/frontend/src/core/components/FileManager.tsx b/frontend/src/core/components/FileManager.tsx index 0b56ddbb6..ea54093dc 100644 --- a/frontend/src/core/components/FileManager.tsx +++ b/frontend/src/core/components/FileManager.tsx @@ -2,6 +2,7 @@ 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"; @@ -17,6 +18,7 @@ import { } 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; @@ -28,6 +30,7 @@ const FileManager: React.FC = ({ selectedTool }) => { closeFilesModal, onFileUpload, onRecentFileSelect, + maxSelectable, } = useFilesModalContext(); const { config } = useAppConfig(); const [recentFiles, setRecentFiles] = useState([]); @@ -35,6 +38,7 @@ const FileManager: React.FC = ({ selectedTool }) => { 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(); @@ -83,11 +87,24 @@ const FileManager: React.FC = ({ selectedTool }) => { const handleRemoveFileByIndex = useCallback( async (index: number) => { - await handleRemoveFile(index, recentFiles, setRecentFiles); + await handleRemoveFile( + index, + recentFiles, + setRecentFiles, + (fileId) => { + fileActions.removeFiles([fileId], false); + }, + refreshRecentFiles, + ); }, - [handleRemoveFile, recentFiles], + [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(); @@ -211,7 +228,9 @@ const FileManager: React.FC = ({ selectedTool }) => { onClose={closeFilesModal} isFileSupported={isFileSupported} isOpen={isFilesModalOpen} + maxSelectable={maxSelectable} onFileRemove={handleRemoveFileByIndex} + onBulkRemove={handleBulkRemove} modalHeight={modalHeight} refreshRecentFiles={refreshRecentFiles} isLoading={loading} diff --git a/frontend/src/core/components/fileEditor/AddFileCard.tsx b/frontend/src/core/components/fileEditor/AddFileCard.tsx index 7a566b032..5fc62680b 100644 --- a/frontend/src/core/components/fileEditor/AddFileCard.tsx +++ b/frontend/src/core/components/fileEditor/AddFileCard.tsx @@ -1,7 +1,6 @@ import React, { useRef, useState } from "react"; import { Button, Group } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import AddIcon from "@mui/icons-material/Add"; import { useFilesModalContext } from "@app/contexts/FilesModalContext"; import LocalIcon from "@app/components/shared/LocalIcon"; import { Wordmark } from "@app/components/shared/Wordmark"; @@ -69,7 +68,7 @@ const AddFileCard = ({ />
- {/* Header bar - matches FileEditorThumbnail structure */} -
-
- -
-
- {t("fileEditor.addFiles", "Add Files")} -
-
-
- {/* Main content area */}
{/* Stirling PDF Branding */} diff --git a/frontend/src/core/components/fileEditor/FileEditor.module.css b/frontend/src/core/components/fileEditor/FileEditor.module.css index 43ef0d1af..2e601f8f7 100644 --- a/frontend/src/core/components/fileEditor/FileEditor.module.css +++ b/frontend/src/core/components/fileEditor/FileEditor.module.css @@ -328,42 +328,33 @@ ========================= */ .addFileCard { + width: 260px; + max-width: 260px; + height: calc(310px - 0.5rem); + margin: 0.5rem auto 0; background: var(--file-card-bg); - border: 2px dashed var(--border-default); - border-radius: 0.0625rem; + border: 1.5px solid var(--border-default); + border-radius: 12px; + box-shadow: var(--shadow-md); cursor: pointer; - transition: all 0.18s ease; - max-width: 100%; - max-height: 100%; + transition: + box-shadow 0.18s ease, + transform 0.2s ease, + border-color 0.18s ease; overflow: hidden; - margin-left: 0.5rem; - margin-right: 0.5rem; - opacity: 0.7; } .addFileCard:hover { - opacity: 1; - border-color: var(--color-blue-500); - box-shadow: var(--shadow-md); - transform: translateY(-2px); + box-shadow: var(--shadow-lg); + transform: translateY(-1px); + border-color: var(--accent-interactive); } -.addFileCard:focus { - outline: 2px solid var(--color-blue-500); +.addFileCard:focus-visible { + outline: 2px solid var(--accent-interactive); outline-offset: 2px; } -.addFileHeader { - background: var(--bg-subtle); - color: var(--text-secondary); - border-bottom: 1px solid var(--border-default); -} - -.addFileCard:hover .addFileHeader { - background: var(--color-blue-500); - color: white; -} - .addFileContent { flex: 1; display: flex; diff --git a/frontend/src/core/components/fileEditor/FileEditor.tsx b/frontend/src/core/components/fileEditor/FileEditor.tsx index c24f632b3..3ddd5ca40 100644 --- a/frontend/src/core/components/fileEditor/FileEditor.tsx +++ b/frontend/src/core/components/fileEditor/FileEditor.tsx @@ -1,13 +1,12 @@ -import { useState, useCallback, useRef, useMemo, useEffect } from "react"; +import { useState, useCallback, useMemo, useEffect } from "react"; import { flushSync } from "react-dom"; -import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core"; +import { Center, Box, LoadingOverlay } from "@mantine/core"; import { Dropzone } from "@mantine/dropzone"; import { useFileSelection, useFileState, useFileManagement, useFileActions, - useFileContext, } from "@app/contexts/FileContext"; import { useNavigationActions } from "@app/contexts/NavigationContext"; import { useViewer } from "@app/contexts/ViewerContext"; @@ -19,7 +18,6 @@ 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 { useFileEditorRightRailButtons } from "@app/components/fileEditor/fileEditorRightRailButtons"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; interface FileEditorProps { @@ -46,17 +44,13 @@ const FileEditor = ({ const { state, selectors } = useFileState(); const { addFiles, removeFiles, reorderFiles } = useFileManagement(); const { actions: fileActions } = useFileActions(); - const { actions: fileContextActions } = useFileContext(); - const { clearAllFileErrors } = fileContextActions; + 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], ); - const selectedFileIds = state.ui.selectedFileIds; - const totalItems = state.files.ids.length; - const selectedCount = selectedFileIds.length; // Get navigation actions const { actions: navActions } = useNavigationActions(); @@ -64,9 +58,6 @@ const FileEditor = ({ // Get viewer context for setting active file index and ID const { setActiveFileIndex, setActiveFileId } = useViewer(); - // Get file selection context - const { setSelectedFiles } = useFileSelection(); - const [_status, _setStatus] = useState(null); const [_error, _setError] = useState(null); @@ -93,7 +84,6 @@ const FileEditor = ({ expandable: true, }); }, []); - const [selectionMode, setSelectionMode] = useState(toolMode); // Current tool (for enforcing maxFiles limits) const { selectedTool } = useToolWorkflow(); @@ -104,65 +94,7 @@ const FileEditor = ({ return !toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax; }, [selectedTool?.maxFiles, toolMode]); - // Enable selection mode automatically in tool mode - useEffect(() => { - if (toolMode) { - setSelectionMode(true); - } - }, [toolMode]); const [showFilePickerModal, setShowFilePickerModal] = useState(false); - // Get selected file IDs from context (defensive programming) - const contextSelectedIds = Array.isArray(selectedFileIds) - ? selectedFileIds - : []; - - // Create refs for frequently changing values to stabilize callbacks - const contextSelectedIdsRef = useRef([]); - contextSelectedIdsRef.current = contextSelectedIds; - - // Use activeStirlingFileStubs directly - no conversion needed - const localSelectedIds = contextSelectedIds; - - const handleSelectAllFiles = useCallback(() => { - // Respect maxAllowed: if limited, select the last N files - const allIds = state.files.ids; - const idsToSelect = Number.isFinite(maxAllowed) - ? allIds.slice(-maxAllowed) - : allIds; - setSelectedFiles(idsToSelect); - try { - clearAllFileErrors(); - } catch (error) { - if (process.env.NODE_ENV === "development") { - console.warn("Failed to clear file errors on select all:", error); - } - } - }, [state.files.ids, setSelectedFiles, clearAllFileErrors, maxAllowed]); - - const handleDeselectAllFiles = useCallback(() => { - setSelectedFiles([]); - try { - clearAllFileErrors(); - } catch (error) { - if (process.env.NODE_ENV === "development") { - console.warn("Failed to clear file errors on deselect:", error); - } - } - }, [setSelectedFiles, clearAllFileErrors]); - - const handleCloseSelectedFiles = useCallback(() => { - if (selectedFileIds.length === 0) return; - void removeFiles(selectedFileIds, false); - setSelectedFiles([]); - }, [selectedFileIds, removeFiles, setSelectedFiles]); - - useFileEditorRightRailButtons({ - totalItems, - selectedCount, - onSelectAll: handleSelectAllFiles, - onDeselectAll: handleDeselectAllFiles, - onCloseSelected: handleCloseSelectedFiles, - }); // Process uploaded files using context // ZIP extraction is now handled automatically in FileContext based on user preferences @@ -199,56 +131,6 @@ const FileEditor = ({ [addFiles, showStatus, showError, selectors, maxAllowed, setSelectedFiles], ); - const toggleFile = useCallback( - (fileId: FileId) => { - const currentSelectedIds = contextSelectedIdsRef.current; - - const targetRecord = activeStirlingFileStubs.find((r) => r.id === fileId); - if (!targetRecord) return; - - const contextFileId = fileId; // No need to create a new ID - const isSelected = currentSelectedIds.includes(contextFileId); - - let newSelection: FileId[]; - - if (isSelected) { - // Remove file from selection - newSelection = currentSelectedIds.filter((id) => id !== contextFileId); - } else { - // Add file to selection - // Determine max files allowed from the active tool (negative or undefined means unlimited) - const rawMax = selectedTool?.maxFiles; - const maxAllowed = - !toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax; - - if (maxAllowed === 1) { - // Only one file allowed -> replace selection with the new file - newSelection = [contextFileId]; - } else { - // If at capacity, drop the oldest selected and append the new one - if ( - Number.isFinite(maxAllowed) && - currentSelectedIds.length >= maxAllowed - ) { - newSelection = [...currentSelectedIds.slice(1), contextFileId]; - } else { - newSelection = [...currentSelectedIds, contextFileId]; - } - } - } - - // Update context (this automatically updates tool selection since they use the same action) - setSelectedFiles(newSelection); - }, - [ - setSelectedFiles, - toolMode, - _setStatus, - activeStirlingFileStubs, - selectedTool?.maxFiles, - ], - ); - // Enforce maxAllowed when tool changes or when an external action sets too many selected files useEffect(() => { if (Number.isFinite(maxAllowed) && selectedFileIds.length > maxAllowed) { @@ -495,16 +377,7 @@ const FileEditor = ({ {activeStirlingFileStubs.length === 0 ? (
- - - 📁 - - No files loaded - - Upload PDF files, ZIP archives, or load from storage to get - started - - +
) : (
void; onCloseFile: (fileId: FileId) => void; onViewFile: (fileId: FileId) => void; - _onSetStatus: (status: string) => void; onReorderFiles?: ( sourceFileId: FileId, targetFileId: FileId, @@ -70,12 +65,8 @@ interface FileEditorThumbnailProps { const FileEditorThumbnail = ({ file, - index, - selectedFiles, - onToggleFile, onCloseFile, onViewFile, - _onSetStatus, onReorderFiles, onDownloadFile, onUnzipFile, @@ -95,41 +86,41 @@ const FileEditorThumbnail = ({ openEncryptedUnlockPrompt, } = useFileContext(); const { state, selectors } = useFileState(); - const hasError = state.ui.errorFileIds.includes(file.id); - - // ---- Drag state ---- - const [isDragging, setIsDragging] = useState(false); - const [isDragOver, setIsDragOver] = useState(false); - const dragElementRef = useRef(null); - const [showHoverMenu, setShowHoverMenu] = useState(false); const isMobile = useIsMobile(); - const [showCloseModal, setShowCloseModal] = useState(false); - const [showUploadModal, setShowUploadModal] = useState(false); - const [showShareModal, setShowShareModal] = useState(false); - const [showSharedEditNotice, setShowSharedEditNotice] = useState(false); - const sharedEditNoticeShownRef = useRef(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 actualFile = useMemo( + () => activeFiles.find((f) => f.fileId === file.id), + [activeFiles, file.id], + ); const isPinned = actualFile ? isFilePinned(actualFile) : false; - // Check if this is a ZIP file const isZipFile = zipFileService.isZipFileStub(file); + const hasError = state.ui.errorFileIds.includes(file.id); const pageCount = file.processedFile?.totalPages || 0; - const isEncrypted = Boolean(file.processedFile?.isEncrypted); + 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(null); - - // ---- Selection ---- - const isSelected = selectedFiles.includes(file.id); - - // ---- Meta formatting ---- - const prettySize = useMemo(() => { - return formatFileSize(file.size); - }, [file.size]); + const dragElementRef = useRef(null); const extUpper = useMemo(() => { const m = /\.([a-z0-9]+)$/i.exec(file.name ?? ""); @@ -143,6 +134,7 @@ const FileEditorThumbnail = ({ const isCBZ = extLower === "cbz"; const isCBR = extLower === "cbr"; + const uploadEnabled = config?.storageEnabled === true; const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true; @@ -178,7 +170,13 @@ const FileEditorThumbnail = ({ }).format(d); }, [file.lastModified]); - // ---- Drag & drop wiring ---- + 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; @@ -191,14 +189,10 @@ const FileEditorThumbnail = ({ type: "file", fileId: file.id, fileName: file.name, - selectedFiles: [file.id], // Always drag only this file, ignore selection state + selectedFiles: [file.id], }), - onDragStart: () => { - setIsDragging(true); - }, - onDrop: () => { - setIsDragging(false); - }, + onDragStart: () => setIsDragging(true), + onDrop: () => setIsDragging(false), }); const dropCleanup = dropTargetForElements({ @@ -211,10 +205,7 @@ const FileEditorThumbnail = ({ const sourceData = source.data; return sourceData.type === "file" && sourceData.fileId !== file.id; }, - onDragEnter: () => setIsDragOver(true), - onDragLeave: () => setIsDragOver(false), onDrop: ({ source }) => { - setIsDragOver(false); const sourceData = source.data; if (sourceData.type === "file" && onReorderFiles) { const sourceFileId = sourceData.fileId as FileId; @@ -229,13 +220,14 @@ const FileEditorThumbnail = ({ dropCleanup(); }; }, - [file.id, file.name, selectedFiles, onReorderFiles], + [file.id, file.name, onReorderFiles], ); - // Handle close with confirmation - const handleCloseWithConfirmation = useCallback(() => { - setShowCloseModal(true); - }, []); + const handleCloseWithConfirmation = useCallback( + () => setShowCloseModal(true), + [], + ); + const handleCancelClose = useCallback(() => setShowCloseModal(false), []); const handleConfirmClose = useCallback(() => { onCloseFile(file.id); @@ -278,7 +270,6 @@ const FileEditorThumbnail = ({ return; } } - // Then close onCloseFile(file.id); alert({ alertType: "success", @@ -296,11 +287,6 @@ const FileEditorThumbnail = ({ fileActions, ]); - const handleCancelClose = useCallback(() => { - setShowCloseModal(false); - }, []); - - // Build hover menu actions const hoverActions = useMemo( () => [ { @@ -312,6 +298,36 @@ const FileEditorThumbnail = ({ onViewFile(file.id); }, }, + { + id: "pin", + icon: , + 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: , @@ -321,36 +337,32 @@ const FileEditorThumbnail = ({ onDownloadFile(file.id); }, }, - ...(canUpload || canShare + ...(canUpload ? [ - ...(canUpload - ? [ - { - id: "upload", - icon: , - 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: , - label: t("fileManager.share", "Share"), - onClick: (e: React.MouseEvent) => { - e.stopPropagation(); - setShowShareModal(true); - }, - }, - ] - : []), + { + id: "upload", + icon: , + 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: , + label: t("fileManager.share", "Share"), + onClick: (e: React.MouseEvent) => { + e.stopPropagation(); + setShowShareModal(true); + }, + }, ] : []), { @@ -389,7 +401,10 @@ const FileEditorThumbnail = ({ isZipFile, isCBZ, isCBR, + isPinned, + actualFile, terminology, + DownloadOutlinedIcon, onViewFile, onDownloadFile, onUnzipFile, @@ -397,13 +412,13 @@ const FileEditorThumbnail = ({ canUpload, canShare, isUploaded, + pinFile, + unpinFile, ], ); - // ---- Card interactions ---- const handleCardClick = () => { if (!isSupported) return; - // Clear error state if file has an error (click to clear error) if (hasError) { try { fileActions.clearFileError(file.id); @@ -415,7 +430,6 @@ const FileEditorThumbnail = ({ sharedEditNoticeShownRef.current = true; setShowSharedEditNotice(true); } - onToggleFile(file.id); }; const handleCardDoubleClick = () => { @@ -423,12 +437,9 @@ const FileEditorThumbnail = ({ onViewFile(file.id); }; - // ---- Style helpers ---- - const getHeaderClassName = () => { - if (hasError) return styles.headerError; - if (!isSupported) return styles.headerUnsupported; - return isSelected ? styles.headerSelected : styles.headerResting; - }; + const metaLine = [dateLabel, extUpper ? `${extUpper} file` : "", pageLabel] + .filter(Boolean) + .join(" - "); return (
setShowHoverMenu(true)} - onMouseLeave={() => setShowHoverMenu(false)} onDoubleClick={handleCardDoubleClick} > - {/* Header bar */} -
- {/* Logo/checkbox area */} -
- {hasError ? ( -
- {t("error._value", "Error")} -
- ) : isSupported ? ( - onToggleFile(file.id)} - color="var(--checkbox-checked-bg)" - /> - ) : ( -
- {t("unsupported", "Unsupported")} -
- )} -
- - {/* Centered index */} -
- {index + 1} -
- - {/* Action buttons group */} -
- {isEncrypted && ( - + {/* Thumbnail area */} +
+ {/* thumbUnit groups toolchain + card so they center together, keeping text tight above the card */} +
+ {/* Tool chain bar — always rendered for consistent height, content only when history exists */} +
+ {file.toolHistory && file.toolHistory.length > 0 && ( + )} +
+ +
- { - e.stopPropagation(); - openEncryptedUnlockPrompt(file.id); - }} - > - - - - )} - {/* Pin/Unpin icon */} - - { - 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, - }); - } + {/* Error overlay */} + {hasError && ( +
+ + {t("error._value", "Error")} + +
+ )} + + {/* Thumbnail image or loading state */} + - {isPinned ? ( - - ) : ( - - )} -
-
-
-
- - {/* Title + meta line */} -
- - - - - {/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */} - {`v${file.versionNumber} - `} - {dateLabel} - {extUpper ? ` - ${extUpper} file` : ""} - {pageLabel ? ` - ${pageLabel}` : ""} - -
- - {/* Preview area */} -
-
- {file.thumbnailUrl ? ( - - {file.name} { - const img = e.currentTarget; - img.style.display = "none"; - img.parentElement?.setAttribute("data-thumb-missing", "true"); - }} - 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", + iconSize="6rem" + imgClassName={styles.thumbImage} + onImageError={(e) => { + e.currentTarget.style.display = "none"; }} /> - - ) : file.type?.startsWith("application/pdf") ? ( - - - - Loading thumbnail... - - - ) : null} + + {/* Badges — top-left: version, pin, ownership, encrypted */} +
+ + v{file.versionNumber} + + {isPinned && ( + + + + )} + {isSharedFile && !isOwnedOrLocal && ( + + {t("fileManager.sharedWithYou", "Shared")} + + )} + {isEncrypted && ( + + { + e.stopPropagation(); + openEncryptedUnlockPrompt(file.id); + }} + style={{ pointerEvents: "auto" }} + > + + + + )} +
+ + +
+
+ {/* end thumbUnit */} + + {/* Drag handle */} + + +
- {/* Drag handle (span wrapper so we can attach a ref reliably) */} - - - - - {/* Tool chain display at bottom */} - {file.toolHistory && ( -
- -
- )} + {/* Hover action menu — visibility driven by CSS on desktop, always shown on mobile */} +
- {/* Hover Menu */} - + {/* File name + meta */} +
+

+ {truncateCenter(file.name, 40)} +

+

{metaLine}

+
{/* Close Confirmation Modal */} - {file.name} + {file.name} ); diff --git a/frontend/src/core/components/fileManager/FileDetails.tsx b/frontend/src/core/components/fileManager/FileDetails.tsx index dd6b3e714..4621559ae 100644 --- a/frontend/src/core/components/fileManager/FileDetails.tsx +++ b/frontend/src/core/components/fileManager/FileDetails.tsx @@ -12,7 +12,8 @@ interface FileDetailsProps { } const FileDetails: React.FC = ({ compact = false }) => { - const { selectedFiles, onOpenFiles, modalHeight } = useFileManagerContext(); + const { selectedFiles, onOpenFiles, modalHeight, activeFileIds } = + useFileManagerContext(); const { t } = useTranslation(); const [currentFileIndex, setCurrentFileIndex] = useState(0); const [isAnimating, setIsAnimating] = useState(false); @@ -21,6 +22,9 @@ const FileDetails: React.FC = ({ compact = false }) => { 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); @@ -71,6 +75,7 @@ const FileDetails: React.FC = ({ compact = false }) => { onPrevious={handlePrevious} onNext={handleNext} onOpenFiles={onOpenFiles} + canCloseAll={canCloseAll} /> ); } @@ -104,18 +109,21 @@ const FileDetails: React.FC = ({ compact = false }) => { ); diff --git a/frontend/src/core/components/fileManager/FileHistoryGroup.tsx b/frontend/src/core/components/fileManager/FileHistoryGroup.tsx index cda54edf7..a405bd37a 100644 --- a/frontend/src/core/components/fileManager/FileHistoryGroup.tsx +++ b/frontend/src/core/components/fileManager/FileHistoryGroup.tsx @@ -11,7 +11,6 @@ interface FileHistoryGroupProps { onDownloadSingle: (file: StirlingFileStub) => void; onFileDoubleClick: (file: StirlingFileStub) => void; onHistoryFileRemove: (file: StirlingFileStub) => void; - isFileSupported: (fileName: string) => boolean; } const FileHistoryGroup: React.FC = ({ @@ -21,7 +20,6 @@ const FileHistoryGroup: React.FC = ({ onDownloadSingle, onFileDoubleClick, onHistoryFileRemove, - isFileSupported, }) => { const { t } = useTranslation(); @@ -50,7 +48,6 @@ const FileHistoryGroup: React.FC = ({ key={`history-${historyFile.id}-${historyFile.versionNumber || 1}`} file={historyFile} isSelected={false} // History files are not selectable - isSupported={isFileSupported(historyFile.name)} onSelect={() => {}} // No selection for history files onRemove={() => onHistoryFileRemove(historyFile)} // Remove specific history file onDownload={() => onDownloadSingle(historyFile)} diff --git a/frontend/src/core/components/fileManager/FileListArea.tsx b/frontend/src/core/components/fileManager/FileListArea.tsx index 467ac398e..4ee5d7f2b 100644 --- a/frontend/src/core/components/fileManager/FileListArea.tsx +++ b/frontend/src/core/components/fileManager/FileListArea.tsx @@ -28,7 +28,6 @@ const FileListArea: React.FC = ({ onHistoryFileRemove, onFileDoubleClick, onDownloadSingle, - isFileSupported, isLoading, activeFileIds, } = useFileManagerContext(); @@ -65,7 +64,6 @@ const FileListArea: React.FC = ({ onFileSelect(file, index, shiftKey)} onRemove={() => onFileRemove(index)} onDownload={() => onDownloadSingle(file)} @@ -82,7 +80,6 @@ const FileListArea: React.FC = ({ onDownloadSingle={onDownloadSingle} onFileDoubleClick={onFileDoubleClick} onHistoryFileRemove={onHistoryFileRemove} - isFileSupported={isFileSupported} /> ); diff --git a/frontend/src/core/components/fileManager/FileListItem.tsx b/frontend/src/core/components/fileManager/FileListItem.tsx index 88b2b4e58..6cf1251d6 100644 --- a/frontend/src/core/components/fileManager/FileListItem.tsx +++ b/frontend/src/core/components/fileManager/FileListItem.tsx @@ -39,7 +39,6 @@ import { alert } from "@app/components/toast"; interface FileListItemProps { file: StirlingFileStub; isSelected: boolean; - isSupported: boolean; onSelect: (shiftKey?: boolean) => void; onRemove: () => void; onDownload?: () => void; @@ -53,7 +52,6 @@ interface FileListItemProps { const FileListItem: React.FC = ({ file, isSelected, - isSupported, onSelect, onRemove, onDownload, @@ -187,15 +185,12 @@ const FileListItem: React.FC = ({ = ({ ? "3px solid var(--mantine-color-blue-4)" : "none", // Visual indicator for history }} - onClick={ - isHistoryFile || isActive ? undefined : (e) => onSelect(e.shiftKey) - } + onClick={isHistoryFile ? undefined : (e) => onSelect(e.shiftKey)} onDoubleClick={onDoubleClick} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} @@ -218,16 +211,14 @@ const FileListItem: React.FC = ({ {/* Checkbox for regular files only */} {}} // Handled by parent onClick size="sm" pl="sm" pr="xs" - disabled={isActive} - color={isActive ? "green" : undefined} styles={{ input: { - cursor: isActive ? "not-allowed" : "pointer", + cursor: "pointer", }, }} /> diff --git a/frontend/src/core/components/fileManager/FileSourceButtons.tsx b/frontend/src/core/components/fileManager/FileSourceButtons.tsx index f6551132c..6905f1bc7 100644 --- a/frontend/src/core/components/fileManager/FileSourceButtons.tsx +++ b/frontend/src/core/components/fileManager/FileSourceButtons.tsx @@ -10,28 +10,12 @@ 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; } -/** - * Google Drive icon component - displays the branded SVG icon - * Shows grayscale when disabled - */ -const GoogleDriveIcon: React.FC<{ disabled?: boolean }> = ({ disabled }) => ( - Google Drive -); - const FileSourceButtons: React.FC = ({ horizontal = false, }) => { @@ -144,7 +128,7 @@ const FileSourceButtons: React.FC = ({ + + +
+
+ +
+ + +
+
+ +
+ setSearchQuery(e.target.value)} + autoComplete="off" + spellCheck={false} + /> +
+ + + {savedLoading ? ( +
+ +
+ ) : displayStubs.length === 0 ? ( +
+ + {t("fileSelectorPicker.empty", "No files available")} + +
+ ) : ( + displayStubs.map((stub) => { + const meta = buildMeta(stub); + const isItemLoading = loadingId === stub.id; + return ( + + ); + }) + )} +
+ + {hoveredStub && + hoveredThumbnail && + createPortal( +
+ +
, + document.body, + )} + + + {/* Hidden file input lives outside the Popover so it is always in the DOM. + Tests can target it directly with setInputFiles without opening the popover. */} + { + const file = e.target.files?.[0] ?? null; + void handleUpload(file); + e.target.value = ""; + }} + /> + + ); +} diff --git a/frontend/src/core/components/shared/FileSidebar.css b/frontend/src/core/components/shared/FileSidebar.css new file mode 100644 index 000000000..76681c635 --- /dev/null +++ b/frontend/src/core/components/shared/FileSidebar.css @@ -0,0 +1,408 @@ +/* ========== FILE SIDEBAR ========== */ + +.file-sidebar { + background-color: var(--bg-toolbar); + border-right: 1px solid var(--border-subtle); + display: flex; + flex-direction: column; + height: 100%; + position: relative; + z-index: 10; + transition: + width 0.22s cubic-bezier(0.4, 0, 0.2, 1), + min-width 0.22s cubic-bezier(0.4, 0, 0.2, 1), + max-width 0.22s cubic-bezier(0.4, 0, 0.2, 1); + flex-shrink: 0; +} + +.file-sidebar-inner { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} + +/* ---- Header ---- */ +.file-sidebar-header { + display: flex; + align-items: center; + height: 48px; + padding: 0 14px; + gap: 10px; + cursor: pointer; + border-radius: 4px; + margin: 4px 4px 0 4px; + flex-shrink: 0; + transition: background-color 0.15s ease; +} + +/* Icons stay left-aligned during animation; overflow:hidden on inner clips text naturally */ + +.file-sidebar-header:hover { + background-color: var(--bg-hover); +} + +.file-sidebar-menu-icon { + color: var(--text-muted) !important; + font-size: 18px !important; + flex-shrink: 0; +} + +.file-sidebar-brand-text { + height: 22px; + width: auto; + flex-shrink: 0; +} + +/* ---- Search row ---- */ +.file-sidebar-search-row { + display: flex; + align-items: center; + min-height: 32px; + padding: 0 14px; + gap: 0; + cursor: pointer; + border-radius: 4px; + margin: 0 4px; + flex-shrink: 0; + transition: background-color 0.15s ease; +} + +.file-sidebar-search-row:not(.active):hover { + background-color: var(--bg-hover); +} + +.file-sidebar-search-icon { + color: var(--text-muted) !important; + font-size: 18px !important; + flex-shrink: 0; +} + +.file-sidebar-search-close { + cursor: pointer; +} + +.file-sidebar-search-input { + flex: 1; + background: transparent; + border: none; + outline: none; + font-size: 14px; + color: var(--text-primary); + margin-left: 12px; + min-width: 0; +} + +.file-sidebar-search-input::placeholder { + color: var(--text-muted); +} + +.file-sidebar-search-label { + margin-left: 12px; + font-size: 14px; + color: var(--text-secondary); +} + +/* ---- Scrollable content ---- */ +/* This is a flex column — action rows are fixed, only the file list scrolls */ +.file-sidebar-scroll { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.file-sidebar-scroll::-webkit-scrollbar { + width: 4px; +} +.file-sidebar-scroll::-webkit-scrollbar-track { + background: transparent; +} +.file-sidebar-scroll::-webkit-scrollbar-thumb { + background: rgb(var(--border)); + border-radius: 2px; +} + +/* ---- Action rows (Open from Computer, etc.) ---- */ +.file-sidebar-action-row { + display: flex; + align-items: center; + height: 32px; + padding: 0 14px; + cursor: pointer; + border-radius: 4px; + margin: 0 4px; + gap: 0; + transition: background-color 0.15s ease; + flex-shrink: 0; +} + +.file-sidebar-action-row:hover { + background-color: var(--bg-hover); +} + +.file-sidebar-action-icon { + color: var(--text-muted) !important; + font-size: 18px !important; + flex-shrink: 0; +} + +.file-sidebar-action-label { + margin-left: 12px; + font-size: 14px; + color: var(--text-secondary); + white-space: nowrap; +} + +/* ---- Cloud storage rows ---- */ +.file-sidebar-cloud-row { + display: flex; + align-items: center; + height: 32px; + padding: 0 14px; + cursor: pointer; + border-radius: 4px; + margin: 0 4px; + gap: 0; + transition: background-color 0.15s ease; + flex-shrink: 0; +} + +.file-sidebar-cloud-row:not(.disabled):hover { + background-color: var(--bg-hover); +} + +.file-sidebar-cloud-row.disabled { + cursor: default; + opacity: 0.5; +} + +.file-sidebar-cloud-icon-wrapper { + position: relative; + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.file-sidebar-cloud-icon-gray { + position: absolute; + inset: 0; + transition: opacity 0.2s ease; + opacity: 1; +} + +.file-sidebar-cloud-icon-color { + position: absolute; + inset: 0; + transition: opacity 0.2s ease; + opacity: 0; +} + +.file-sidebar-cloud-row:not(.disabled):hover .file-sidebar-cloud-icon-gray { + opacity: 0; +} + +.file-sidebar-cloud-row:not(.disabled):hover .file-sidebar-cloud-icon-color { + opacity: 1; +} + +/* ---- Files section ---- */ +.file-sidebar-files-section { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + margin-top: 4px; +} + +.file-sidebar-file-list { + flex: 1; + min-height: 0; + overflow-y: scroll; +} + +.file-sidebar-file-list::-webkit-scrollbar { + width: 4px; +} +.file-sidebar-file-list::-webkit-scrollbar-track { + background: transparent; +} +.file-sidebar-file-list::-webkit-scrollbar-thumb { + background: rgb(var(--border)); + border-radius: 2px; +} +.file-sidebar-file-list::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +.file-sidebar-section-divider { + border-top: 1px solid var(--border-subtle); + margin: 10px 14px 6px 14px; +} + +.file-sidebar-section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px 6px 14px; + margin: 4px 0 0 0; +} + +.file-sidebar-section-header { + position: relative; + border-top: 1px solid var(--border-subtle); + padding-top: 10px; + margin-top: 4px; + flex-shrink: 0; +} + +.file-sidebar-section-label { + font-size: 13px; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--text-muted); + text-transform: uppercase; +} + +.file-sidebar-section-count { + font-size: 11px; + color: var(--text-muted); +} + +.file-sidebar-section-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + background: transparent; + border-radius: 4px; + cursor: pointer; + color: var(--text-muted); + padding: 0; + outline: none; + transition: + background-color 0.12s ease, + color 0.12s ease, + opacity 0.12s ease; +} + +.file-sidebar-section-btn:focus-visible { + outline: 2px solid #3b82f6; + outline-offset: 1px; +} + +.file-sidebar-section-btn:hover { + background-color: var(--bg-hover); + color: var(--text-primary); +} + +.file-sidebar-section-btn-external { + opacity: 0; + pointer-events: none; +} + +.file-sidebar-section-header:hover .file-sidebar-section-btn-external { + opacity: 1; + pointer-events: auto; +} + +.file-sidebar-section-btn-add { + margin-left: auto; +} + +/* ---- Sidebar content fade (for collapsed state) ---- */ +/* Elements are conditionally rendered so use animation (not transition) to fade in from 0. + Delay until the width animation is nearly done (0.18s of 0.22s) to avoid squashed-text effect. */ +@keyframes sidebar-content-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.sidebar-content-fade { + animation: sidebar-content-in 0.12s ease 0.18s both; +} + +.file-sidebar[data-collapsed="true"] .sidebar-content-fade { + opacity: 0; + pointer-events: none; + animation: none; +} + +/* ---- Bottom bar (user + settings) ---- */ +.file-sidebar-bottom-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-top: 1px solid var(--border-subtle); + flex-shrink: 0; + min-height: 48px; +} + +/* Bottom bar settings icon tracks the right edge during collapse animation */ + +.file-sidebar-bottom-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + background-color: var(--mantine-color-blue-6, #3b82f6); + color: #fff; + font-size: 12px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + user-select: none; +} + +.file-sidebar-bottom-name { + flex: 1; + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.file-sidebar-bottom-bar[role="button"]:hover { + background-color: var(--bg-hover); +} + +.file-sidebar-bottom-bar[role="button"]:focus-visible { + outline: 2px solid #3b82f6; + outline-offset: -2px; +} + +.file-sidebar-bottom-settings { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 6px; + color: var(--text-muted); + padding: 0; + flex-shrink: 0; + margin-left: auto; +} + +.file-sidebar[data-collapsed="true"] .file-sidebar-bottom-settings { + width: 32px; + height: 32px; +} + +.file-sidebar[data-collapsed="true"] .file-sidebar-bottom-bar { + justify-content: center; + padding: 8px 0; +} diff --git a/frontend/src/core/components/shared/FileSidebar.tsx b/frontend/src/core/components/shared/FileSidebar.tsx new file mode 100644 index 000000000..6ddf6f49d --- /dev/null +++ b/frontend/src/core/components/shared/FileSidebar.tsx @@ -0,0 +1,594 @@ +import React, { + useState, + useCallback, + useRef, + useEffect, + forwardRef, +} from "react"; +import { Loader } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { useFileState, useFileActions } from "@app/contexts/file/fileHooks"; +import { useFilesModalContext } from "@app/contexts/FilesModalContext"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useGoogleDrivePicker } from "@app/hooks/useGoogleDrivePicker"; +import { + useNavigationState, + useNavigationActions, + useNavigationGuard, +} from "@app/contexts/NavigationContext"; +import { useViewer } from "@app/contexts/ViewerContext"; +import { useFileHandler } from "@app/hooks/useFileHandler"; +import { useIndexedDB } from "@app/contexts/IndexedDBContext"; +import { accountService } from "@app/services/accountService"; +import { GoogleDriveIcon } from "@app/components/shared/CloudStorageIcons"; +import { Wordmark } from "@app/components/shared/Wordmark"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import MenuIcon from "@mui/icons-material/Menu"; +import SearchIcon from "@mui/icons-material/Search"; +import FolderOpenIcon from "@mui/icons-material/FolderOpen"; +import CloseIcon from "@mui/icons-material/Close"; +import AddIcon from "@mui/icons-material/Add"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import SettingsIcon from "@mui/icons-material/Settings"; +import type { FileId } from "@app/types/file"; +import { FileItem } from "@app/components/shared/FileSidebarFileItem"; +import "@app/components/shared/FileSidebar.css"; + +const COLLAPSED_WIDTH = "3.5rem"; +const EXPANDED_WIDTH = "16.25rem"; // ~260px + +export interface FileSidebarProps { + collapsed?: boolean; + onToggleCollapse?: () => void; + onOpenSettings?: () => void; +} + +const FileSidebar = forwardRef( + function FileSidebar( + { collapsed = false, onToggleCollapse, onOpenSettings }, + ref, + ) { + const { t } = useTranslation(); + const [searchActive, setSearchActive] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const searchInputRef = useRef(null); + const nativeFileInputRef = useRef(null); + // State (not ref) so setting it triggers a re-render — avoids racing addFiles state updates. + const [pendingViewFileId, setPendingViewFileId] = useState( + null, + ); + + const { openFilesModal } = useFilesModalContext(); + const { config } = useAppConfig(); + const { + isEnabled: isGoogleDriveEnabled, + openPicker: openGoogleDrivePicker, + } = useGoogleDrivePicker(); + const { state } = useFileState(); + const { actions: fileActions } = useFileActions(); + const { actions: navActions } = useNavigationActions(); + const { workbench: currentWorkbench, selectedTool } = useNavigationState(); + const isMultiTool = + currentWorkbench === "pageEditor" && selectedTool === "multiTool"; + const { requestNavigation } = useNavigationGuard(); + const { activeFileId, setActiveFileId } = useViewer(); + const { addFiles } = useFileHandler(); + const indexedDB = useIndexedDB(); + const [displayName, setDisplayName] = useState("Guest"); + + useEffect(() => { + if (!config?.enableLogin) return; + accountService + .getAccountData() + .then((data) => { + if (data?.username) setDisplayName(data.username); + }) + .catch(() => { + /* not logged in or security disabled */ + }); + }, [config?.enableLogin]); + + // Leaf files = user-visible files (excludes intermediate tool outputs) + const [allFileStubs, setAllFileStubs] = useState([]); + const [stubsLoaded, setStubsLoaded] = useState(false); + + const refreshStubs = useCallback(async () => { + // Leaf files from IDB — same source as the file selection modal. + const stubs = await indexedDB.loadLeafMetadata(); + const idbIds = new Set(stubs.map((s) => s.id as string)); + + // Also include workbench files not yet flushed to IDB. + const pendingStubs = state.files.ids + .map((id) => state.files.byId[id]) + .filter( + (stub): stub is NonNullable => + !!stub && stub.isLeaf !== false && !idbIds.has(stub.id as string), + ); + + const allStubs = [...stubs, ...pendingStubs]; + setAllFileStubs( + allStubs.sort((a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0)), + ); + setStubsLoaded(true); + }, [indexedDB, state.files.ids, state.files.byId]); + + // Refresh on mount, workbench changes, or external IndexedDB writes + useEffect(() => { + refreshStubs(); + }, [refreshStubs, indexedDB.revision]); + + // Once a pending file lands in state, open it in the viewer. + useEffect(() => { + if (!pendingViewFileId) return; + const isInWorkbench = state.files.ids.some( + (id) => (id as string) === pendingViewFileId, + ); + if (isInWorkbench) { + setPendingViewFileId(null); + setActiveFileId(pendingViewFileId); + navActions.setWorkbench("viewer"); + } + }, [pendingViewFileId, state.files.ids, setActiveFileId, navActions]); + + const filteredFileStubs = searchQuery.trim() + ? allFileStubs.filter((stub) => + stub.name.toLowerCase().includes(searchQuery.toLowerCase()), + ) + : allFileStubs; + + // Handle search activation + const handleSearchClick = useCallback(() => { + if (collapsed && onToggleCollapse) { + onToggleCollapse(); + } + setSearchActive(true); + }, [collapsed, onToggleCollapse]); + + const handleSearchClose = useCallback(() => { + setSearchActive(false); + setSearchQuery(""); + }, []); + + useEffect(() => { + if (searchActive && searchInputRef.current) { + searchInputRef.current.focus(); + } + }, [searchActive]); + + // Handle Google Drive + const handleGoogleDriveClick = useCallback(async () => { + if (!isGoogleDriveEnabled) return; + const files = await openGoogleDrivePicker({ multiple: true }); + if (files.length > 0) { + await addFiles(files); + if (!isMultiTool) { + navActions.setWorkbench(files.length === 1 ? "viewer" : "fileEditor"); + } + } + }, [ + isGoogleDriveEnabled, + openGoogleDrivePicker, + addFiles, + navActions, + isMultiTool, + ]); + + // Toggle file in/out of workbench + const handleFileClick = useCallback( + async (fileId: FileId) => { + const stub = allFileStubs.find((s) => s.id === fileId); + if (!stub) return; + + const workbenchFileId = state.files.ids.find( + (id) => (id as string) === (stub.id as string), + ); + + if (workbenchFileId) { + // If this is the file currently open in the viewer, route through the + // navigation guard so the save modal fires when there are unsaved changes. + const isCurrentlyViewed = workbenchFileId === viewedWorkbenchId; + if (isCurrentlyViewed) { + requestNavigation(() => { + void fileActions.removeFiles([workbenchFileId], false); + }); + return; + } + await fileActions.removeFiles([workbenchFileId], false); + } else { + // Re-add by stub to preserve its ID — addFiles() would create a new UUID + IDB entry. + const workbenchCount = state.files.ids.length; + + if (workbenchCount > 0 && currentWorkbench === "viewer") { + navActions.setWorkbench("fileEditor"); + } + + await fileActions.addStirlingFileStubs([stub]); + + if (isMultiTool) { + fileActions.setSelectedFiles([ + ...state.ui.selectedFileIds, + stub.id, + ]); + } else { + if (workbenchCount === 0) { + navActions.setWorkbench("viewer"); + } else { + navActions.setWorkbench("fileEditor"); + } + } + } + }, + [ + allFileStubs, + state.files.ids, + state.ui.selectedFileIds, + fileActions, + navActions, + currentWorkbench, + activeFileId, + requestNavigation, + isMultiTool, + ], + ); + + // Which file is currently open in the viewer — stable ID, never index-derived. + const viewedWorkbenchId = + currentWorkbench === "viewer" ? activeFileId : null; + + const handleEyeClick = useCallback( + async (fileId: FileId, _e: React.MouseEvent) => { + const stub = allFileStubs.find((s) => s.id === fileId); + if (!stub) return; + + const isCurrentlyViewed = !!( + viewedWorkbenchId && + (viewedWorkbenchId as string) === (stub.id as string) + ); + + if (isCurrentlyViewed) { + // Closing the currently-viewed file — guard against unsaved changes. + navActions.setWorkbench("fileEditor"); + return; + } + + // Switching to a different file while viewer is open — guard against unsaved changes. + const performSwitch = async () => { + const alreadyInWorkbench = state.files.ids.some( + (id) => (id as string) === (stub.id as string), + ); + + if (!alreadyInWorkbench) { + // Leave viewer before mutating workbench (prevents PSPDFKit crash). + if (state.files.ids.length > 0 && currentWorkbench === "viewer") { + navActions.setWorkbench("fileEditor"); + } + await fileActions.addStirlingFileStubs([stub]); + } + + // Route through pendingViewFileId so both setActiveFileIndex + setWorkbench fire together. + setPendingViewFileId(stub.id as string); + }; + + if (currentWorkbench === "viewer" && viewedWorkbenchId) { + requestNavigation(() => { + void performSwitch(); + }); + } else { + await performSwitch(); + } + }, + [ + allFileStubs, + viewedWorkbenchId, + state.files.ids, + fileActions, + navActions, + currentWorkbench, + setPendingViewFileId, + requestNavigation, + ], + ); + + const handleNativeFilePick = useCallback( + async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files ?? []); + if (files.length > 0) { + await addFiles(files); + if (!isMultiTool) { + navActions.setWorkbench( + files.length === 1 ? "viewer" : "fileEditor", + ); + } + } + e.target.value = ""; + }, + [addFiles, navActions, isMultiTool], + ); + + const shouldHideGoogleDrive = + !isGoogleDriveEnabled && config?.hideDisabledToolsGoogleDrive; + + const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH; + + return ( +
+
+ {/* Header: hamburger + branding */} +
onToggleCollapse?.()} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === "Enter" && onToggleCollapse?.()} + aria-label={ + collapsed + ? t("fileSidebar.expand", "Expand sidebar") + : t("fileSidebar.collapse", "Collapse sidebar") + } + > + + {!collapsed && ( + + )} +
+ + {/* Search row */} + { +
e.key === "Enter" && handleSearchClick() + : undefined + } + > + {searchActive && !collapsed ? ( + { + e.stopPropagation(); + handleSearchClose(); + }} + /> + ) : ( + + )} + {!collapsed && + (searchActive ? ( + setSearchQuery(e.target.value)} + placeholder={t( + "fileSidebar.searchPlaceholder", + "Search files...", + )} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + + {t("fileSidebar.search", "Search")} + + ))} +
+ } + + {/* Scrollable content */} +
+ {/* Open from Computer + Google Drive */} + { + <> +
{ + if (collapsed && onToggleCollapse) onToggleCollapse(); + openFilesModal(); + }} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === "Enter" && openFilesModal()} + > + + {!collapsed && ( + + {t("fileSidebar.openFromComputer", "Open from computer")} + + )} +
+ + {!shouldHideGoogleDrive && ( +
+
+ + {isGoogleDriveEnabled && ( + + )} +
+ {!collapsed && ( + + {t("fileSidebar.googleDrive", "Google Drive")} + + )} +
+ )} + + } + + {/* Files section - always visible when expanded */} + {!collapsed && ( +
+
+ + {t("fileSidebar.files", "Files")} + + + + +
+ + {!stubsLoaded ? ( +
+ +
+ ) : filteredFileStubs.length > 0 ? ( +
+ {filteredFileStubs.map((stub) => { + const workbenchFileId = state.files.ids.find( + (id) => (id as string) === (stub.id as string), + ); + const isInWorkbench = !!workbenchFileId; + // Both active and viewed-in-viewer are ID-based — never index-based. + const isViewedInViewer = !!( + viewedWorkbenchId && + viewedWorkbenchId === (stub.id as string) + ); + const isActive = isViewedInViewer; + // In-memory thumbnail may be fresher than the IndexedDB stub. + // Encrypted files never get a raster thumbnail — use undefined + // so the sidebar icon is shown instead of a stale canvas thumbnail. + const isEncryptedFile = + stub.processedFile?.isEncrypted === true; + const thumbnailUrl = isEncryptedFile + ? undefined + : (workbenchFileId + ? state.files.byId[workbenchFileId]?.thumbnailUrl + : undefined) || stub.thumbnailUrl; + return ( + + ); + })} +
+ ) : ( + !searchActive && ( +
+

+ {t("fileSidebar.noFiles", "No files yet")} +

+

+ {t("fileSidebar.dropHint", "Open files to get started")} +

+
+ ) + )} +
+ )} +
+
+ + {/* Bottom bar: user name + settings */} +
e.key === "Enter" && onOpenSettings() + : undefined + } + data-testid={onOpenSettings ? "config-button" : undefined} + data-tour={onOpenSettings ? "config-button" : undefined} + aria-label={ + onOpenSettings + ? t("fileSidebar.openSettings", "Open settings") + : undefined + } + title={ + onOpenSettings + ? t("fileSidebar.openSettings", "Open settings") + : undefined + } + style={onOpenSettings ? { cursor: "pointer" } : undefined} + > +
+ {displayName.charAt(0).toUpperCase()} +
+ {!collapsed && ( + + {displayName} + + )} + {onOpenSettings && !collapsed && ( +
+ +
+ )} +
+
+ ); + }, +); + +export default FileSidebar; diff --git a/frontend/src/core/components/shared/FileSidebarFileItem.css b/frontend/src/core/components/shared/FileSidebarFileItem.css new file mode 100644 index 000000000..0b6497823 --- /dev/null +++ b/frontend/src/core/components/shared/FileSidebarFileItem.css @@ -0,0 +1,283 @@ +/* ---- File list ---- */ +.file-sidebar-file-list { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 0 8px 0 8px; + display: flex; + flex-direction: column; + gap: 3px; +} + +.file-sidebar-file-list::-webkit-scrollbar { + width: 4px; +} +.file-sidebar-file-list::-webkit-scrollbar-track { + background: transparent; +} +.file-sidebar-file-list::-webkit-scrollbar-thumb { + background: rgb(var(--border)); + border-radius: 2px; +} + +/* ---- File item ---- */ +.file-sidebar-file-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 8px; + cursor: pointer; + transition: background-color 0.12s ease; +} + +.file-sidebar-file-item:hover:not(.selected) { + background-color: rgba(59, 130, 246, 0.06); +} + +.file-sidebar-file-item.selected { + background-color: rgba(59, 130, 246, 0.12); +} + +.file-sidebar-file-item.active:not(.selected) { + background-color: rgba(59, 130, 246, 0.06); +} + +/* Icon wrapper */ +.file-sidebar-file-icon-wrapper { + position: relative; + width: 20px; + height: 20px; + flex-shrink: 0; +} + +.file-sidebar-file-icon-hover-hide { + display: block; +} + +.file-sidebar-file-item:hover .file-sidebar-file-icon-hover-hide { + display: none; +} + +.file-sidebar-file-checkbox-hover { + display: none; + width: 20px; + height: 20px; + border-radius: 5px; + border: 1.5px solid var(--border-hover, #52525b); +} + +.file-sidebar-file-item:hover .file-sidebar-file-checkbox-hover { + display: block; +} + +.file-sidebar-file-item.selected .file-sidebar-file-checkbox-hover { + display: none; +} + +.file-sidebar-file-icon { + position: absolute; + inset: 0; +} + +/* Checked state */ +.file-sidebar-file-check { + width: 20px; + height: 20px; + border-radius: 5px; + background-color: #3b82f6; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.file-sidebar-check-svg { + color: white; + flex-shrink: 0; +} + +/* File info */ +.file-sidebar-file-info { + flex: 1; + min-width: 0; +} + +.file-sidebar-file-name { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-sidebar-file-item.selected .file-sidebar-file-name { + color: #3b82f6; +} + +.file-sidebar-file-meta { + display: block; + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} + +/* ---- Eye button (open in viewer) ---- */ +.file-sidebar-eye-btn { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 24px; + height: 24px; + border: none; + background: transparent; + border-radius: 4px; + cursor: pointer; + color: var(--text-muted); + padding: 0; + opacity: 0; + transition: + opacity 0.12s ease, + color 0.12s ease; +} + +/* Show eye on hover (for any non-viewed file) */ +.file-sidebar-file-item:hover .file-sidebar-eye-btn { + opacity: 1; +} + +/* Pin the viewed item when scrolled out of view in either direction */ +.file-sidebar-file-item.viewed { + position: sticky; + top: 0; + bottom: 0; + z-index: 2; + /* solid base + green tint overlay so content behind doesn't bleed through */ + background-color: var(--bg-toolbar); + background-image: linear-gradient( + rgba(34, 197, 94, 0.1), + rgba(34, 197, 94, 0.1) + ); +} + +.file-sidebar-file-item.viewed .file-sidebar-file-name { + color: #22c55e; +} + +.file-sidebar-file-item.viewed .file-sidebar-file-check { + background-color: #22c55e; +} + +/* viewed takes precedence over selected */ +.file-sidebar-file-item.viewed.selected { + background-color: var(--bg-toolbar); + background-image: linear-gradient( + rgba(34, 197, 94, 0.1), + rgba(34, 197, 94, 0.1) + ); +} + +/* Always show eye for the currently viewed file */ +.file-sidebar-file-item.viewed .file-sidebar-eye-btn { + opacity: 1; + color: #22c55e; +} + +.file-sidebar-eye-btn:hover { + color: var(--text-primary); +} + +/* Eye open: shown by default */ +.file-sidebar-eye-open { + display: block !important; +} +.file-sidebar-eye-closed { + display: none !important; +} + +/* On hover over a viewed item: swap to eye-closed */ +.file-sidebar-file-item.viewed:hover .file-sidebar-eye-open { + display: none !important; +} +.file-sidebar-file-item.viewed:hover .file-sidebar-eye-closed { + display: block !important; +} + +/* ---- Date group headers ---- */ +.file-sidebar-date-group-header { + font-size: 11px; + font-weight: 500; + color: var(--text-muted); + padding: 0 10px 2px 10px; + margin-top: 8px; + user-select: none; +} + +.file-sidebar-date-group-header:first-child { + margin-top: 0; +} + +/* ---- Loading state ---- */ +.file-sidebar-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 24px 0; + opacity: 0.5; +} + +/* ---- Empty state ---- */ +.file-sidebar-empty { + padding: 12px 16px 24px 16px; + text-align: center; +} + +.file-sidebar-empty-text { + font-size: 12px; + color: var(--text-muted); + margin: 0 0 2px 0; +} + +.file-sidebar-empty-hint { + font-size: 11px; + color: var(--text-muted); + opacity: 0.7; + margin: 0; +} + +/* ---- Thumbnail tooltip (hover preview) ---- */ +.file-sidebar-thumb-tooltip { + position: fixed; + z-index: 9999; + transform: translateY(-50%); + background: var(--bg-surface, #fff); + border: 1px solid var(--border-subtle); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); + padding: 4px; + width: 150px; + pointer-events: none; + animation: file-sidebar-thumb-in 0.12s ease; +} + +.file-sidebar-thumb-img { + width: 100%; + height: auto; + display: block; + border-radius: 4px; +} + +@keyframes file-sidebar-thumb-in { + from { + opacity: 0; + transform: translateY(-50%) scale(0.94); + } + to { + opacity: 1; + transform: translateY(-50%) scale(1); + } +} diff --git a/frontend/src/core/components/shared/FileSidebarFileItem.tsx b/frontend/src/core/components/shared/FileSidebarFileItem.tsx new file mode 100644 index 000000000..b0138ef2b --- /dev/null +++ b/frontend/src/core/components/shared/FileSidebarFileItem.tsx @@ -0,0 +1,297 @@ +import { useState, useCallback, useRef, useEffect } from "react"; +import { createPortal } from "react-dom"; +import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; +import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined"; +import type { FileId } from "@app/types/file"; +import { FileDocIcon } from "@app/components/shared/FileDocIcon"; +import { getFileDocVariant } from "@app/components/shared/filePreview/getFileTypeIcon"; +import { useIndexedDB } from "@app/contexts/IndexedDBContext"; +import { useFileManagement } from "@app/contexts/FileContext"; +import { generateThumbnailForFile } from "@app/utils/thumbnailUtils"; +import { IMAGE_EXTENSIONS } from "@app/utils/fileUtils"; +import "@app/components/shared/FileSidebarFileItem.css"; + +const THUMBNAIL_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB + +/** Generate + persist a thumbnail for a sidebar file that doesn't have one yet. */ +function useLazyThumbnail( + fileId: FileId, + size: number, + thumbnailUrl?: string, +): string | undefined { + const [thumb, setThumb] = useState(thumbnailUrl); + const attempted = useRef(false); + const indexedDB = useIndexedDB(); + const { updateStirlingFileStub } = useFileManagement(); + + // Sync prop changes (e.g. thumbnail arrives after TTL bump) + useEffect(() => { + if (thumbnailUrl) setThumb(thumbnailUrl); + }, [thumbnailUrl]); + + useEffect(() => { + if (thumbnailUrl || attempted.current || size >= THUMBNAIL_SIZE_LIMIT) + return; + attempted.current = true; + let cancelled = false; + + (async () => { + try { + const file = await indexedDB.loadFile(fileId); + if (!file || cancelled) return; + const thumbnail = await generateThumbnailForFile(file); + if (cancelled || !thumbnail) return; + setThumb(thumbnail); + void indexedDB.updateThumbnail(fileId, thumbnail); + updateStirlingFileStub(fileId, { thumbnailUrl: thumbnail }); + } catch { + // non-critical + } + })(); + + return () => { + cancelled = true; + }; + }, [fileId, size, thumbnailUrl, indexedDB, updateStirlingFileStub]); + + return thumb; +} + +export function getFileExtension(name: string): string { + const parts = name.split("."); + return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ""; +} + +export type DateGroup = + | "today" + | "yesterday" + | "thisWeek" + | "thisMonth" + | "older"; + +export const DATE_GROUP_ORDER: DateGroup[] = [ + "today", + "yesterday", + "thisWeek", + "thisMonth", + "older", +]; + +export function getDateGroup(lastModified: number | undefined): DateGroup { + if (!lastModified) return "older"; + const now = new Date(); + const today = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + ).getTime(); + const d = new Date(lastModified); + const fileDay = new Date( + d.getFullYear(), + d.getMonth(), + d.getDate(), + ).getTime(); + const daysAgo = (today - fileDay) / 86400000; + if (daysAgo < 1) return "today"; + if (daysAgo < 2) return "yesterday"; + if (daysAgo < 7) return "thisWeek"; + if (daysAgo < 30) return "thisMonth"; + return "older"; +} + +export function formatFileDate(lastModifiedTs: number): string { + const lastModified = lastModifiedTs ? new Date(lastModifiedTs) : new Date(); + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const fileDay = new Date( + lastModified.getFullYear(), + lastModified.getMonth(), + lastModified.getDate(), + ); + + if (fileDay.getTime() === today.getTime()) { + return lastModified + .toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }) + .toLowerCase(); + } + if (fileDay.getTime() === yesterday.getTime()) { + return "Yesterday"; + } + const weekAgo = new Date(today); + weekAgo.setDate(weekAgo.getDate() - 7); + if (fileDay >= weekAgo) { + return lastModified.toLocaleDateString("en-US", { weekday: "long" }); + } + return lastModified.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); +} + +function CheckIcon({ + className, + style, +}: { + className?: string; + style?: React.CSSProperties; +}) { + return ( + + + + ); +} + +function getSidebarFileIcon(ext: string): React.ReactElement { + const cls = "file-sidebar-file-icon file-sidebar-file-icon-hover-hide"; + return ; +} + +export interface FileItemProps { + fileId: FileId; + name: string; + size?: number; + lastModified?: number; + isSelected: boolean; + isActive: boolean; + isViewedInViewer: boolean; + thumbnailUrl?: string; + onClick: (fileId: FileId) => void; + onEyeClick: (fileId: FileId, e: React.MouseEvent) => void; +} + +export function FileItem({ + fileId, + name, + size, + lastModified, + isSelected, + isActive, + isViewedInViewer, + thumbnailUrl, + onClick, + onEyeClick, +}: FileItemProps) { + const ext = getFileExtension(name); + const dateLabel = lastModified ? formatFileDate(lastModified) : ""; + const typeLabel = ext ? ext.toUpperCase() : "File"; + + // Only use raster thumbnails for PDFs and images — everything else uses scalable SVG icons + const useRasterThumb = ext === "pdf" || IMAGE_EXTENSIONS.has(ext); + const resolvedThumbnail = useLazyThumbnail( + fileId, + size ?? 0, + useRasterThumb ? thumbnailUrl : undefined, + ); + + const itemRef = useRef(null); + const [hoverRect, setHoverRect] = useState(null); + + const handleMouseEnter = useCallback(() => { + setHoverRect(itemRef.current?.getBoundingClientRect() ?? null); + }, []); + + const handleMouseLeave = useCallback(() => setHoverRect(null), []); + + // Reactive: tooltip appears as soon as both hover rect and thumbnail are ready + const thumbPos = + hoverRect && resolvedThumbnail + ? { + top: hoverRect.top + hoverRect.height / 2, + left: hoverRect.right + 10, + } + : null; + + return ( + <> +
onClick(fileId)} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === "Enter" && onClick(fileId)} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + > +
+ {isSelected ? ( +
+ +
+ ) : ( + <> +
+ {getSidebarFileIcon(ext)} + + )} +
+
+ + {name} + + + {dateLabel} + {dateLabel && typeLabel ? " · " : ""} + {typeLabel} + +
+ +
+ + {useRasterThumb && + thumbPos && + resolvedThumbnail && + createPortal( +
+ +
, + document.body, + )} + + ); +} diff --git a/frontend/src/core/components/shared/HoverActionMenu.tsx b/frontend/src/core/components/shared/HoverActionMenu.tsx index c43225854..f4cfb247a 100644 --- a/frontend/src/core/components/shared/HoverActionMenu.tsx +++ b/frontend/src/core/components/shared/HoverActionMenu.tsx @@ -11,6 +11,7 @@ export interface HoverAction { disabled?: boolean; color?: string; hidden?: boolean; + dataTour?: string; } interface HoverActionMenuProps { @@ -18,6 +19,7 @@ interface HoverActionMenuProps { actions: HoverAction[]; position?: "inside" | "outside"; className?: string; + visibility?: "state" | "cssHover"; } const HoverActionMenu: React.FC = ({ @@ -25,6 +27,7 @@ const HoverActionMenu: React.FC = ({ actions, position = "inside", className = "", + visibility = "state", }) => { const visibleActions = actions.filter((action) => !action.hidden); @@ -32,10 +35,23 @@ const HoverActionMenu: React.FC = ({ return null; } + const style: React.CSSProperties = { zIndex: Z_INDEX_HOVER_ACTION_MENU }; + if (visibility === "state") { + style.opacity = show ? 1 : 0; + style.pointerEvents = show ? "auto" : "none"; + } else if (show) { + // Force visible (e.g. mobile) even when CSS-hover mode is active. + style.opacity = 1; + style.pointerEvents = "auto"; + } + return (
e.stopPropagation()} onMouseUp={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} @@ -48,7 +64,8 @@ const HoverActionMenu: React.FC = ({ disabled={action.disabled} onClick={action.onClick} c={action.color} - style={{ color: action.color || "var(--right-rail-icon)" }} + style={{ color: action.color || "var(--text-secondary)" }} + data-tour={action.dataTour} > {action.icon} diff --git a/frontend/src/core/components/shared/InfoBanner.tsx b/frontend/src/core/components/shared/InfoBanner.tsx index a9fde9ae4..50a1cba22 100644 --- a/frontend/src/core/components/shared/InfoBanner.tsx +++ b/frontend/src/core/components/shared/InfoBanner.tsx @@ -50,6 +50,7 @@ interface InfoBannerProps { buttonVariant?: "light" | "filled" | "white" | "outline" | "subtle"; minHeight?: number | string; closeIconColor?: string; + compact?: boolean; } /** @@ -75,6 +76,7 @@ export const InfoBanner: React.FC = ({ buttonVariant = "light", minHeight = 56, closeIconColor, + compact = false, }) => { if (!show) { return null; @@ -85,13 +87,21 @@ export const InfoBanner: React.FC = ({ onDismiss?.(); }; + const iconSize = compact ? "1rem" : "1.2rem"; + const textSize = compact ? "xs" : "sm"; + const buttonSize = compact ? "xs" : "xs"; + return ( = ({ style={{ width: "100%" }} > - + {title && ( {title} @@ -128,9 +138,9 @@ export const InfoBanner: React.FC = ({ )} {message} @@ -141,11 +151,15 @@ export const InfoBanner: React.FC = ({ {onAddToUpload && ( diff --git a/frontend/src/core/components/shared/PageEditorFileDropdown.tsx b/frontend/src/core/components/shared/PageEditorFileDropdown.tsx index 4cbd84b15..fbe44c35c 100644 --- a/frontend/src/core/components/shared/PageEditorFileDropdown.tsx +++ b/frontend/src/core/components/shared/PageEditorFileDropdown.tsx @@ -197,7 +197,7 @@ export const PageEditorFileDropdown: React.FC = ({ -
{node}
- - ); -} - -export default function RightRail() { - const { sidebarRefs } = useSidebarContext(); - const { position: tooltipPosition, offset: tooltipOffset } = - useRightRailTooltipSide(sidebarRefs); - const { t } = useTranslation(); - const terminology = useFileActionTerminology(); - const icons = useFileActionIcons(); - const viewerContext = React.useContext(ViewerContext); - const { toggleTheme, themeMode } = useRainbowThemeContext(); - const { buttons, actions, allButtonsDisabled } = useRightRail(); - - const { pageEditorFunctions, toolPanelMode, leftPanelView } = - useToolWorkflow(); - const disableForFullscreen = - toolPanelMode === "fullscreen" && leftPanelView === "toolPicker"; - - const { workbench: currentView } = useNavigationState(); - - const { selectors } = useFileState(); - const { selectedFiles, selectedFileIds } = useFileSelection(); - const { actions: fileActions } = useFileActions(); - const activeFiles = selectors.getFiles(); - const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0; - const pageEditorSelectedCount = - pageEditorFunctions?.selectedPageIds?.length ?? 0; - - const totalItems = useMemo(() => { - if (currentView === "pageEditor") return pageEditorTotalPages; - return activeFiles.length; - }, [currentView, pageEditorTotalPages, activeFiles.length]); - - const selectedCount = useMemo(() => { - if (currentView === "pageEditor") { - return pageEditorSelectedCount; - } - return selectedFileIds.length; - }, [currentView, pageEditorSelectedCount, selectedFileIds.length]); - - const sectionsWithButtons = useMemo(() => { - return SECTION_ORDER.map((section) => { - const sectionButtons = buttons.filter( - (btn) => (btn.section ?? "top") === section && (btn.visible ?? true), - ); - return { section, buttons: sectionButtons }; - }).filter((entry) => entry.buttons.length > 0); - }, [buttons]); - - const renderButton = useCallback( - (btn: RightRailButtonConfig) => { - const action = actions[btn.id]; - const disabled = Boolean( - btn.disabled || allButtonsDisabled || disableForFullscreen, - ); - const isActive = Boolean(btn.active); - - const triggerAction = () => { - if (!disabled) action?.(); - }; - - if (btn.render) { - const context: RightRailRenderContext = { - id: btn.id, - disabled, - allButtonsDisabled, - action, - triggerAction, - active: isActive, - }; - return btn.render(context) ?? null; - } - - if (!btn.icon) return null; - - const ariaLabel = - btn.ariaLabel || - (typeof btn.tooltip === "string" ? (btn.tooltip as string) : undefined); - const className = ["right-rail-icon", btn.className] - .filter(Boolean) - .join(" "); - const buttonNode = ( - - {btn.icon} - - ); - - return renderWithTooltip( - buttonNode, - btn.tooltip, - tooltipPosition, - tooltipOffset, - ); - }, - [ - actions, - allButtonsDisabled, - disableForFullscreen, - tooltipPosition, - tooltipOffset, - ], - ); - - const handleExportAll = useCallback( - async (forceNewFile = false) => { - if (currentView === "viewer") { - const buffer = await viewerContext?.exportActions?.saveAsCopy?.(); - if (!buffer) return; - const fileToExport = - selectedFiles.length > 0 ? selectedFiles[0] : activeFiles[0]; - if (!fileToExport) return; - const stub = isStirlingFile(fileToExport) - ? selectors.getStirlingFileStub(fileToExport.fileId) - : undefined; - try { - const result = await downloadFile({ - data: new Blob([buffer], { type: "application/pdf" }), - filename: fileToExport.name, - localPath: forceNewFile ? undefined : stub?.localFilePath, - }); - if (!forceNewFile && !result.cancelled && stub && result.savedPath) { - fileActions.updateStirlingFileStub(stub.id, { - localFilePath: stub.localFilePath ?? result.savedPath, - isDirty: false, - }); - } - } catch (error) { - console.error("[RightRail] Failed to export viewer file:", error); - } - return; - } - - if (currentView === "pageEditor") { - pageEditorFunctions?.onExportAll?.(); - return; - } - - const filesToExport = - selectedFiles.length > 0 ? selectedFiles : activeFiles; - - if (filesToExport.length > 0) { - for (const file of filesToExport) { - const stub = isStirlingFile(file) - ? selectors.getStirlingFileStub(file.fileId) - : undefined; - try { - const result = await downloadFile({ - data: file, - filename: file.name, - localPath: forceNewFile ? undefined : stub?.localFilePath, - }); - if (result.cancelled) continue; - if (!forceNewFile && stub && result.savedPath) { - fileActions.updateStirlingFileStub(stub.id, { - localFilePath: stub.localFilePath ?? result.savedPath, - isDirty: false, - }); - } - } catch (error) { - console.error( - "[RightRail] Failed to export file:", - file.name, - error, - ); - } - } - } - }, - [ - currentView, - selectedFiles, - activeFiles, - pageEditorFunctions, - viewerContext, - selectors, - fileActions, - ], - ); - - const downloadTooltip = useMemo(() => { - if (currentView === "pageEditor") { - return t("rightRail.exportAll", "Export PDF"); - } - if (currentView === "viewer") { - return terminology.download; - } - if (selectedCount > 0) { - return terminology.downloadSelected; - } - return terminology.downloadAll; - }, [currentView, selectedCount, t, terminology]); - - return ( -
-
- {sectionsWithButtons.map(({ section, buttons: sectionButtons }) => ( - -
- {sectionButtons.map((btn, index) => { - const content = renderButton(btn); - if (!content) return null; - return ( -
- {content} -
- ); - })} -
- -
- ))} -
- {renderWithTooltip( - - {themeMode === "dark" ? ( - - ) : ( - - )} - , - t("rightRail.toggleTheme", "Toggle Theme"), - tooltipPosition, - tooltipOffset, - )} - - - - {renderWithTooltip( - handleExportAll()} - disabled={ - disableForFullscreen || - (currentView !== "viewer" && - (totalItems === 0 || allButtonsDisabled)) - } - > - - , - downloadTooltip, - tooltipPosition, - tooltipOffset, - )} - {icons.saveAsIconName && - renderWithTooltip( - handleExportAll(true)} - disabled={ - disableForFullscreen || - (currentView !== "viewer" && - (totalItems === 0 || allButtonsDisabled)) - } - > - - , - t("rightRail.saveAs", "Save As"), - tooltipPosition, - tooltipOffset, - )} -
- -
- - -
-
- ); -} diff --git a/frontend/src/core/components/shared/TextInput.tsx b/frontend/src/core/components/shared/TextInput.tsx index 12725f44e..e13471052 100644 --- a/frontend/src/core/components/shared/TextInput.tsx +++ b/frontend/src/core/components/shared/TextInput.tsx @@ -36,6 +36,8 @@ export interface TextInputProps { "aria-label"?: string; /** Focus event handler */ onFocus?: () => void; + /** Allow the icon to receive pointer events (e.g. when icon is a clickable button) */ + iconClickable?: boolean; } export const TextInput = forwardRef( @@ -56,6 +58,7 @@ export const TextInput = forwardRef( readOnly = false, "aria-label": ariaLabel, onFocus, + iconClickable = false, ...props }, ref, @@ -76,7 +79,10 @@ export const TextInput = forwardRef( {icon && ( {icon} @@ -96,8 +102,6 @@ export const TextInput = forwardRef( aria-label={ariaLabel} onFocus={onFocus} style={{ - backgroundColor: "var(--input-bg)", - color: "var(--search-text-and-icon-color)", paddingRight: shouldShowClearButton ? "40px" : "12px", paddingLeft: icon ? "40px" : "12px", }} @@ -108,7 +112,6 @@ export const TextInput = forwardRef( type="button" className={styles.clearButton} onClick={handleClear} - style={{ color: "var(--search-text-and-icon-color)" }} aria-label="Clear input" > diff --git a/frontend/src/core/components/shared/ViewerInlineControls.tsx b/frontend/src/core/components/shared/ViewerInlineControls.tsx new file mode 100644 index 000000000..96a9d2f06 --- /dev/null +++ b/frontend/src/core/components/shared/ViewerInlineControls.tsx @@ -0,0 +1,81 @@ +import React, { useState, useEffect } from "react"; +import { ActionIcon, Slider } from "@mantine/core"; +import { useViewer } from "@app/contexts/ViewerContext"; +import { useNavigationState } from "@app/contexts/NavigationContext"; +import ZoomInIcon from "@mui/icons-material/ZoomIn"; +import ZoomOutIcon from "@mui/icons-material/ZoomOut"; + +/** + * Compact zoom controls rendered inline in the WorkbenchBar when the current workbench is "viewer". + */ +export function ViewerInlineControls() { + const { workbench } = useNavigationState(); + const viewer = useViewer(); + + const [zoomPercent, setZoomPercent] = useState(100); + + useEffect(() => { + const zoomState = viewer.getZoomState(); + setZoomPercent(zoomState.zoomPercent || 100); + + const unregister = viewer.registerImmediateZoomUpdate((pct) => { + setZoomPercent(pct); + }); + return () => unregister?.(); + }, [viewer.registerImmediateZoomUpdate]); + + if (workbench !== "viewer") return null; + + const sliderValue = Math.min(Math.max(zoomPercent, 20), 500); + + return ( +
+ {/* Divider */} +
+ + {/* Zoom controls */} + viewer.zoomActions.zoomOut()} + aria-label="Zoom out" + > + + + +
+ { + viewer.zoomActions.setZoomLevel?.(val / 100); + }} + size="xs" + styles={{ + root: { width: "6rem" }, + thumb: { width: 14, height: 14 }, + track: { height: 3 }, + }} + label={null} + /> +
+ + viewer.zoomActions.zoomIn()} + aria-label="Zoom in" + > + + + + + {Math.round(zoomPercent)}% + +
+ ); +} diff --git a/frontend/src/core/components/shared/WorkbenchBar.css b/frontend/src/core/components/shared/WorkbenchBar.css new file mode 100644 index 000000000..ced97dcff --- /dev/null +++ b/frontend/src/core/components/shared/WorkbenchBar.css @@ -0,0 +1,187 @@ +/* ========== WORKBENCH BAR ========== */ +/* Horizontal toolbar at the top of the workbench area. */ + +.workbench-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + align-content: flex-start; + min-height: 48px; + padding: 0 8px; + background-color: var(--bg-toolbar); + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; + z-index: 50; +} + +/* ---- View switcher (left) ---- */ +.workbench-bar-views { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; + order: 1; + height: 48px; + z-index: 0; +} + +.workbench-bar-view-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-secondary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: + background-color 0.15s ease, + color 0.15s ease; + white-space: nowrap; +} + +.workbench-bar-view-btn:hover { + background-color: var(--bg-hover); + color: var(--text-primary); +} + +.workbench-bar-view-btn.active { + background-color: var(--mantine-color-blue-light, rgba(59, 130, 246, 0.12)); + color: var(--mantine-color-blue-6, #3b82f6); +} + +.workbench-bar-view-btn svg { + font-size: 16px !important; + width: 16px; + height: 16px; +} + +.workbench-bar-view-label { + line-height: 1; +} + +/* ---- Center: tool buttons ---- */ + +/* Single-row: center sits between views and globals */ +.workbench-bar-center { + order: 2; + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 2px; + height: 48px; + overflow: hidden; +} + +/* Two-row: center drops below views+globals */ +.workbench-bar[data-wrapped="true"] .workbench-bar-center { + order: 3; + flex: 0 0 100%; + height: auto; + overflow: visible; + padding: 4px 0; + border-top: 1px solid var(--border-subtle); +} + +/* ---- Right: global buttons (theme / language / download) ---- */ +.workbench-bar-globals { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; + order: 3; + height: 48px; + margin-left: auto; +} + +/* In two-row mode globals moves to row 1 right side */ +.workbench-bar[data-wrapped="true"] .workbench-bar-globals { + order: 2; + margin-left: auto; +} + +/* Shared action icon style — applies to both center and global buttons */ +.workbench-bar-action-icon { + color: var(--text-secondary) !important; + width: 28px !important; + height: 28px !important; + background: transparent !important; +} + +.workbench-bar-action-icon svg, +.workbench-bar-action-icon img { + color: inherit !important; + fill: currentColor; +} + +.workbench-bar-action-icon:hover { + color: var(--text-primary) !important; + background-color: var(--bg-hover) !important; +} + +.workbench-bar-action-icon[data-variant="filled"] { + background: var(--mantine-color-blue-6, #3b82f6) !important; + color: #fff !important; +} + +.workbench-bar-action-icon[data-variant="filled"]:hover { + background: var(--mantine-color-blue-7, #2563eb) !important; + color: #fff !important; +} + +.workbench-bar-action-wrapper { + display: flex; + align-items: center; +} + +.workbench-bar-tooltip-wrapper { + display: inline-flex; + align-items: center; +} + +.workbench-bar-divider { + width: 1px; + height: 16px; + background-color: var(--border-subtle); + margin: 0 4px; +} + +/* Extra breathing room around the export/close separator */ +.workbench-bar-globals-sep { + margin: 0 8px; +} + +/* ---- Inline viewer controls (page nav + zoom) ---- */ +.viewer-inline-controls { + display: flex; + align-items: center; + gap: 2px; +} + +.viewer-inline-controls__page { + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + min-width: 3rem; + text-align: center; + white-space: nowrap; +} + +.viewer-inline-controls__slider-wrap { + display: flex; + align-items: center; + padding: 0 4px; +} + +.viewer-inline-controls__zoom-pct { + font-size: 12px; + color: var(--text-muted); + min-width: 2.5rem; + text-align: right; + white-space: nowrap; +} diff --git a/frontend/src/core/components/shared/WorkbenchBar.tsx b/frontend/src/core/components/shared/WorkbenchBar.tsx new file mode 100644 index 000000000..b50e8ae47 --- /dev/null +++ b/frontend/src/core/components/shared/WorkbenchBar.tsx @@ -0,0 +1,490 @@ +import React, { useCallback, useEffect, useMemo, useRef } from "react"; +import { ActionIcon } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext"; +import { + useFileState, + useFileSelection, + useFileActions, +} from "@app/contexts/FileContext"; +import { isStirlingFile } from "@app/types/fileContext"; +import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology"; +import { useFileActionIcons } from "@app/hooks/useFileActionIcons"; +import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; +import { useNavigationState } from "@app/contexts/NavigationContext"; +import { ViewerContext, useViewer } from "@app/contexts/ViewerContext"; +import { WorkbenchType, isBaseWorkbench } from "@app/types/workbench"; +import { Tooltip } from "@app/components/shared/Tooltip"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { downloadFile } from "@app/services/downloadService"; +import { + WorkbenchBarButtonConfig, + WorkbenchBarRenderContext, + WorkbenchBarSection, +} from "@app/types/workbenchBar"; +import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; +import FolderIcon from "@mui/icons-material/Folder"; +import CloseIcon from "@mui/icons-material/Close"; +import PrintIcon from "@mui/icons-material/Print"; +import "@app/components/shared/WorkbenchBar.css"; + +const SECTION_ORDER: WorkbenchBarSection[] = ["top", "middle", "bottom"]; + +interface ViewOption { + value: WorkbenchType; + label: string; + icon: React.ReactNode; +} + +interface WorkbenchBarProps { + currentView: WorkbenchType; + setCurrentView: (view: WorkbenchType) => void; + hasFiles: boolean; +} + +function renderWithTooltip( + node: React.ReactNode, + tooltip: React.ReactNode | undefined, +) { + if (!tooltip) return node; + return ( + +
{node}
+
+ ); +} + +export default function WorkbenchBar({ + currentView, + setCurrentView, + hasFiles, +}: WorkbenchBarProps) { + const { t } = useTranslation(); + const { buttons, actions, allButtonsDisabled } = useWorkbenchBar(); + const { + pageEditorFunctions, + toolPanelMode, + leftPanelView, + customWorkbenchViews, + } = useToolWorkflow(); + const { selectedTool } = useNavigationState(); + const isCustomView = !isBaseWorkbench(currentView); + const disableForFullscreen = + toolPanelMode === "fullscreen" && leftPanelView === "toolPicker"; + const terminology = useFileActionTerminology(); + const icons = useFileActionIcons(); + const viewerContext = React.useContext(ViewerContext); + + const { selectors } = useFileState(); + const { selectedFiles, selectedFileIds } = useFileSelection(); + const { actions: fileActions } = useFileActions(); + const activeFiles = selectors.getFiles(); + const { activeFileId, setActiveFileId } = useViewer(); + const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0; + const pageEditorSelectedCount = + pageEditorFunctions?.selectedPageIds?.length ?? 0; + + const totalItems = useMemo(() => { + if (currentView === "pageEditor") return pageEditorTotalPages; + return activeFiles.length; + }, [currentView, pageEditorTotalPages, activeFiles.length]); + + const selectedCount = useMemo(() => { + if (currentView === "pageEditor") return pageEditorSelectedCount; + return selectedFileIds.length; + }, [currentView, pageEditorSelectedCount, selectedFileIds.length]); + + const sectionsWithButtons = useMemo(() => { + return SECTION_ORDER.map((section) => { + const sectionButtons = buttons.filter( + (btn) => (btn.section ?? "top") === section && (btn.visible ?? true), + ); + return { section, buttons: sectionButtons }; + }).filter((entry) => entry.buttons.length > 0); + }, [buttons]); + + const handleExportAll = useCallback( + async (forceNewFile = false) => { + if (currentView === "viewer") { + const buffer = await viewerContext?.exportActions?.saveAsCopy?.(); + if (!buffer) return; + const fileToExport = + selectedFiles.length > 0 ? selectedFiles[0] : activeFiles[0]; + if (!fileToExport) return; + const stub = isStirlingFile(fileToExport) + ? selectors.getStirlingFileStub(fileToExport.fileId) + : undefined; + try { + const result = await downloadFile({ + data: new Blob([buffer], { type: "application/pdf" }), + filename: fileToExport.name, + localPath: forceNewFile ? undefined : stub?.localFilePath, + }); + if (!forceNewFile && !result.cancelled && stub && result.savedPath) { + fileActions.updateStirlingFileStub(stub.id, { + localFilePath: stub.localFilePath ?? result.savedPath, + isDirty: false, + }); + } + } catch (error) { + console.error("[WorkbenchBar] Failed to export viewer file:", error); + } + return; + } + + if (currentView === "pageEditor") { + pageEditorFunctions?.onExportAll?.(); + return; + } + + const filesToExport = + selectedFiles.length > 0 ? selectedFiles : activeFiles; + for (const file of filesToExport) { + const stub = isStirlingFile(file) + ? selectors.getStirlingFileStub(file.fileId) + : undefined; + try { + const result = await downloadFile({ + data: file, + filename: file.name, + localPath: forceNewFile ? undefined : stub?.localFilePath, + }); + if (result.cancelled) continue; + if (!forceNewFile && stub && result.savedPath) { + fileActions.updateStirlingFileStub(stub.id, { + localFilePath: stub.localFilePath ?? result.savedPath, + isDirty: false, + }); + } + } catch (error) { + console.error( + "[WorkbenchBar] Failed to export file:", + file.name, + error, + ); + } + } + }, + [ + currentView, + selectedFiles, + activeFiles, + pageEditorFunctions, + viewerContext, + selectors, + fileActions, + ], + ); + + const handlePrint = useCallback(() => { + viewerContext?.printActions?.print?.(); + }, [viewerContext]); + + const handleClose = useCallback(async () => { + if (currentView === "fileEditor") { + await fileActions.clearAllFiles(); + } else if (currentView === "viewer") { + const file = + (activeFileId + ? activeFiles.find( + (f) => isStirlingFile(f) && f.fileId === activeFileId, + ) + : null) ?? activeFiles[0]; + const countBeforeRemove = activeFiles.length; + if (file && isStirlingFile(file)) { + // Pick the next file to show before removing, so the sidebar stays in sync. + const remaining = activeFiles.filter( + (f) => isStirlingFile(f) && f.fileId !== file.fileId, + ); + const nextFile = remaining.find(isStirlingFile) ?? null; + await fileActions.removeFiles([file.fileId], false); + if (countBeforeRemove <= 1) { + setCurrentView("fileEditor"); + } else if (nextFile) { + setActiveFileId(nextFile.fileId); + } + } else if (countBeforeRemove <= 1) { + setCurrentView("fileEditor"); + } + } else if (currentView === "pageEditor") { + pageEditorFunctions?.closePdf?.(); + } + }, [ + currentView, + fileActions, + activeFiles, + activeFileId, + setActiveFileId, + pageEditorFunctions, + setCurrentView, + ]); + + const downloadTooltip = useMemo(() => { + if (currentView === "pageEditor") + return t("workbenchBar.exportAll", "Export PDF"); + if (currentView === "viewer") return terminology.download; + if (selectedCount > 0) return terminology.downloadSelected; + return terminology.downloadAll; + }, [currentView, selectedCount, t, terminology]); + + const renderButton = useCallback( + (btn: WorkbenchBarButtonConfig) => { + const action = actions[btn.id]; + const disabled = Boolean( + btn.disabled || allButtonsDisabled || disableForFullscreen, + ); + const isActive = Boolean(btn.active); + + const triggerAction = () => { + if (!disabled) action?.(); + }; + + if (btn.render) { + const context: WorkbenchBarRenderContext = { + id: btn.id, + disabled, + allButtonsDisabled, + action, + triggerAction, + active: isActive, + }; + return btn.render(context) ?? null; + } + + if (!btn.icon) return null; + + const ariaLabel = + btn.ariaLabel || + (typeof btn.tooltip === "string" ? (btn.tooltip as string) : undefined); + const buttonNode = ( + + {btn.icon} + + ); + return renderWithTooltip(buttonNode, btn.tooltip); + }, + [actions, allButtonsDisabled, disableForFullscreen], + ); + + // View options + const viewOptions: ViewOption[] = [ + { + value: "viewer", + label: t("workbenchBar.viewer", "Viewer"), + icon: , + }, + { + value: "fileEditor", + label: t("workbenchBar.activeFiles", "Active Files"), + icon: , + }, + ...(selectedTool === "multiTool" + ? [ + { + value: "pageEditor" as WorkbenchType, + label: t("workbenchBar.multiTool", "Multi-Tool"), + icon: ( + + ), + }, + ] + : []), + ...customWorkbenchViews + .filter((v) => v.data != null) + .map((v) => ({ + value: v.workbenchId, + label: v.label, + icon: v.icon ?? , + })), + ]; + + const barRef = useRef(null); + + useEffect(() => { + const bar = barRef.current; + if (!bar) return; + + const measure = () => { + const viewsEl = bar.querySelector(".workbench-bar-views"); + const globalsEl = bar.querySelector( + ".workbench-bar-globals", + ); + const centerEl = bar.querySelector(".workbench-bar-center"); + + const viewsWidth = viewsEl?.offsetWidth ?? 0; + const globalsWidth = globalsEl?.offsetWidth ?? 0; + const centerChildren = centerEl + ? (Array.from(centerEl.children) as HTMLElement[]) + : []; + const centerWidth = + centerChildren.reduce((sum, el) => sum + el.offsetWidth, 0) + + Math.max(0, centerChildren.length - 1) * 2; // gap: 2px + + const needed = viewsWidth + centerWidth + globalsWidth + 24; // 24px bar padding + bar.dataset.wrapped = String(needed > bar.clientWidth); + }; + + const ro = new ResizeObserver(measure); + ro.observe(bar); + measure(); + return () => ro.disconnect(); + }, []); + + return ( +
+ {/* Left: View switcher */} +
+ {hasFiles && + viewOptions.map((opt) => ( + + ))} +
+ + {/* Tool buttons — second row, only rendered when buttons exist */} + {sectionsWithButtons.length > 0 && ( +
+ {sectionsWithButtons.map( + ({ section, buttons: sectionButtons }, idx) => ( + + {idx > 0 &&
} + {sectionButtons.map((btn) => { + const content = renderButton(btn); + if (!content) return null; + return ( +
+ {content} +
+ ); + })} + + ), + )} +
+ )} + + {/* Right: Global buttons — export group left, close anchored right */} +
+ {/* Print */} + {currentView === "viewer" && + renderWithTooltip( + + + , + t("workbenchBar.print", "Print PDF"), + )} + + {/* Download */} + {renderWithTooltip( + handleExportAll()} + disabled={ + disableForFullscreen || totalItems === 0 || allButtonsDisabled + } + > + + , + downloadTooltip, + )} + + {/* Save As */} + {icons.saveAsIconName && + renderWithTooltip( + handleExportAll(true)} + disabled={ + disableForFullscreen || totalItems === 0 || allButtonsDisabled + } + > + + , + t("workbenchBar.saveAs", "Save As"), + )} + + {/* Separator: export group | close */} + {!isCustomView && ( +
+ )} + + {/* Close (context-aware: close all / close viewer file / close page editor) */} + {!isCustomView && + renderWithTooltip( + + + , + currentView === "fileEditor" + ? t("workbenchBar.closeAll", "Close All") + : t("workbenchBar.closePdf", "Close PDF"), + )} +
+
+ ); +} diff --git a/frontend/src/core/components/shared/config/configNavSections.tsx b/frontend/src/core/components/shared/config/configNavSections.tsx index d9b9f56d8..68ed7285d 100644 --- a/frontend/src/core/components/shared/config/configNavSections.tsx +++ b/frontend/src/core/components/shared/config/configNavSections.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { NavKey } from "@app/components/shared/config/types"; import HotkeysSection from "@app/components/shared/config/configSections/HotkeysSection"; import GeneralSection from "@app/components/shared/config/configSections/GeneralSection"; +import HelpSection from "@app/components/shared/config/configSections/HelpSection"; export interface ConfigNavItem { key: NavKey; @@ -34,6 +35,7 @@ export const useConfigNavSections = ( _isAdmin: boolean = false, _runningEE: boolean = false, _loginEnabled: boolean = false, + onRequestClose: () => void = () => {}, ): ConfigNavSection[] => { const { t } = useTranslation(); @@ -55,6 +57,19 @@ export const useConfigNavSections = ( }, ], }, + { + title: t("settings.help.title", "Help"), + items: [ + { + key: "help", + label: t("settings.help.label", "Tours"), + icon: "help-rounded", + component: ( + + ), + }, + ], + }, ]; return sections; diff --git a/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx b/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx index 34d7fcbe5..f1bfdbdca 100644 --- a/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx +++ b/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx @@ -19,6 +19,8 @@ import { import { useTranslation } from "react-i18next"; import { usePreferences } from "@app/contexts/PreferencesContext"; import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider"; +import LanguageSelector from "@app/components/shared/LanguageSelector"; import type { ToolPanelMode } from "@app/constants/toolPanel"; import type { StartupView, @@ -47,6 +49,7 @@ const GeneralSection: React.FC = ({ const { t } = useTranslation(); const { preferences, updatePreference } = usePreferences(); const { config } = useAppConfig(); + const { toggleTheme, themeMode } = useRainbowThemeContext(); const [fileLimitInput, setFileLimitInput] = useState( preferences.autoUnzipFileLimit, ); @@ -355,6 +358,67 @@ const GeneralSection: React.FC = ({ )} + {/* Appearance */} + + +
+
+ + {t("settings.general.theme", "Theme")} + + + {t( + "settings.general.themeDescription", + "Switch between light and dark mode", + )} + +
+ { + if ((themeMode === "dark") !== (val === "dark")) toggleTheme(); + }} + data={[ + { + label: t("settings.general.themeLight", "Light"), + value: "light", + }, + { + label: t("settings.general.themeDark", "Dark"), + value: "dark", + }, + ]} + /> +
+
+
+ + {t("settings.general.language", "Language")} + + + {t( + "settings.general.languageDescription", + "Choose the display language", + )} + +
+ +
+
+
+
= ({ {t( "settings.general.defaultStartupViewDescription", - "Choose which tab is active in the left column when the app starts", + "Choose which view is active when the app starts", )}
diff --git a/frontend/src/core/components/shared/config/configSections/HelpSection.tsx b/frontend/src/core/components/shared/config/configSections/HelpSection.tsx new file mode 100644 index 000000000..89299fe3d --- /dev/null +++ b/frontend/src/core/components/shared/config/configSections/HelpSection.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import { Button, Group, Paper, Stack, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { requestStartTour } from "@app/constants/events"; + +interface HelpSectionProps { + isAdmin: boolean; + onRequestClose: () => void; +} + +const HelpSection: React.FC = ({ + isAdmin, + onRequestClose, +}) => { + const { t } = useTranslation(); + + const startTour = (tourType: "tools" | "admin") => { + onRequestClose(); + setTimeout(() => requestStartTour(tourType), 300); + }; + + return ( + + + + +
+ + {t("settings.help.toolsTour.title", "Tools Tour")} + + + {t( + "settings.help.toolsTour.description", + "Walk through uploading files, picking a tool, and reviewing results.", + )} + +
+ +
+ + {isAdmin && ( + +
+ + {t("settings.help.adminTour.title", "Admin Tour")} + + + {t( + "settings.help.adminTour.description", + "Explore team management, system settings, and enterprise features.", + )} + +
+ +
+ )} +
+
+
+ ); +}; + +export default HelpSection; diff --git a/frontend/src/core/components/shared/config/types.ts b/frontend/src/core/components/shared/config/types.ts index 6a22ed311..110c7801e 100644 --- a/frontend/src/core/components/shared/config/types.ts +++ b/frontend/src/core/components/shared/config/types.ts @@ -30,6 +30,7 @@ export const VALID_NAV_KEYS = [ "adminUsage", "adminEndpoints", "adminStorageSharing", + "help", ] as const; // Derive the type from the array diff --git a/frontend/src/core/components/shared/filePreview/DocumentThumbnail.tsx b/frontend/src/core/components/shared/filePreview/DocumentThumbnail.tsx index e0b61d6a4..9b5ce16ea 100644 --- a/frontend/src/core/components/shared/filePreview/DocumentThumbnail.tsx +++ b/frontend/src/core/components/shared/filePreview/DocumentThumbnail.tsx @@ -1,12 +1,19 @@ import React from "react"; -import { Box, Center, Image } from "@mantine/core"; +import { Box, Center, Loader, Stack, Text } from "@mantine/core"; +import LockIcon from "@mui/icons-material/Lock"; import { getFileTypeIcon } from "@app/components/shared/filePreview/getFileTypeIcon"; import { StirlingFileStub } from "@app/types/fileContext"; import { PrivateContent } from "@app/components/shared/PrivateContent"; +import { detectFileExtension } from "@app/utils/fileUtils"; export interface DocumentThumbnailProps { file: File | StirlingFileStub | null; thumbnail?: string | null; + isEncrypted?: boolean; + isLoading?: boolean; + iconSize?: string | number; + imgClassName?: string; + onImageError?: React.ReactEventHandler; style?: React.CSSProperties; onClick?: () => void; children?: React.ReactNode; @@ -15,14 +22,19 @@ export interface DocumentThumbnailProps { const DocumentThumbnail: React.FC = ({ file, thumbnail, + isEncrypted = false, + isLoading = false, + iconSize = "4rem", + imgClassName, + onImageError, style = {}, onClick, children, }) => { if (!file) return null; - const containerStyle = { - position: "relative" as const, + const containerStyle: React.CSSProperties = { + position: "relative", cursor: onClick ? "pointer" : "default", transition: "opacity 0.2s ease", width: "100%", @@ -33,20 +45,29 @@ const DocumentThumbnail: React.FC = ({ ...style, }; - if (thumbnail) { + if (thumbnail && !isEncrypted) { return ( - {`Preview {children} @@ -54,17 +75,93 @@ const DocumentThumbnail: React.FC = ({ ); } + if (isEncrypted) { + return ( + +
+ + + Locked + +
+ {children} +
+ ); + } + + if (isLoading) { + return ( + + + + + Loading thumbnail... + + + {children} + + ); + } + + const ext = detectFileExtension(file.name ?? "").toUpperCase(); + return (
- {getFileTypeIcon(file)} + {getFileTypeIcon(file, iconSize)} + {ext && ( + + {ext} + + )}
{children}
diff --git a/frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx b/frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx index 2666f0660..6be3d3516 100644 --- a/frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx +++ b/frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx @@ -1,17 +1,84 @@ import React from "react"; -import JavascriptIcon from "@mui/icons-material/Javascript"; -import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; -import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; +import { FileDocIcon } from "@app/components/shared/FileDocIcon"; +import type { FileDocVariant } from "@app/components/shared/FileDocIcon"; import type { StirlingFileStub } from "@app/types/fileContext"; import { detectFileExtension } from "@app/utils/fileUtils"; +export const SPREADSHEET_EXTS = new Set(["csv", "xls", "xlsx", "ods"]); +export const DOC_EXTS = new Set([ + "md", + "markdown", + "txt", + "doc", + "docx", + "odt", + "rtf", +]); +export const IMAGE_EXTS = new Set([ + "png", + "jpg", + "jpeg", + "gif", + "webp", + "svg", + "bmp", + "tiff", + "tif", +]); +export const ARCHIVE_EXTS = new Set([ + "zip", + "tar", + "gz", + "rar", + "7z", + "cbz", + "cbr", +]); +export const CODE_EXTS = new Set([ + "js", + "ts", + "jsx", + "tsx", + "html", + "css", + "json", + "xml", + "yaml", + "yml", +]); + type FileLike = File | StirlingFileStub; -/** - * Returns an appropriate file type icon for the provided file. - * - Uses the real file type and extension to decide the icon. - * - No any-casts; accepts File or StirlingFileStub. - */ +export function getFileDocVariant(ext: string, mime = ""): FileDocVariant { + if (ext === "pdf" || mime === "application/pdf") return "pdf"; + if ( + SPREADSHEET_EXTS.has(ext) || + mime.includes("spreadsheet") || + mime === "text/csv" + ) + return "spreadsheet"; + if ( + DOC_EXTS.has(ext) || + mime === "text/markdown" || + mime === "text/plain" || + mime.includes("word") || + mime.includes("opendocument.text") + ) + return "doc"; + if (IMAGE_EXTS.has(ext) || mime.startsWith("image/")) return "image"; + if (ARCHIVE_EXTS.has(ext) || mime.includes("zip") || mime.includes("archive")) + return "archive"; + if ( + CODE_EXTS.has(ext) || + mime.includes("javascript") || + mime.includes("json") || + mime.includes("xml") || + mime === "text/html" + ) + return "code"; + return "generic"; +} + export function getFileTypeIcon( file: FileLike, size: number | string = "2rem", @@ -19,29 +86,10 @@ export function getFileTypeIcon( const name = (file?.name ?? "").toLowerCase(); const mime = (file?.type ?? "").toLowerCase(); const ext = detectFileExtension(name); - - // JavaScript - if (ext === "js" || mime.includes("javascript")) { - return ( - - ); - } - - // PDF - if (ext === "pdf" || mime === "application/pdf") { - return ( - - ); - } - - // Fallback generic return ( - ); } diff --git a/frontend/src/core/components/shared/rightRail/RightRail.README.md b/frontend/src/core/components/shared/rightRail/RightRail.README.md deleted file mode 100644 index 1c804daaa..000000000 --- a/frontend/src/core/components/shared/rightRail/RightRail.README.md +++ /dev/null @@ -1,145 +0,0 @@ -# RightRail Component - -A dynamic vertical toolbar on the right side of the application that supports both static buttons (Undo/Redo, Save, Print, Share) and dynamic buttons registered by tools. - -## Structure - -- **Top Section**: Dynamic buttons from tools (empty when none) -- **Middle Section**: Grid, Cut, Undo, Redo -- **Bottom Section**: Save, Print, Share - -## Usage - -### For Tools (Recommended) - -```tsx -import { useRightRailButtons } from '../hooks/useRightRailButtons'; -import PlayArrowIcon from '@mui/icons-material/PlayArrow'; - -function MyTool() { - const handleAction = useCallback(() => { - // Your action here - }, []); - - useRightRailButtons([ - { - id: 'my-action', - icon: , - tooltip: 'Execute Action', - onClick: handleAction, - }, - ]); - - return
My Tool
; -} -``` - -### Multiple Buttons - -```tsx -useRightRailButtons([ - { - id: 'primary', - icon: , - tooltip: 'Primary Action', - order: 1, - onClick: handlePrimary, - }, - { - id: 'secondary', - icon: , - tooltip: 'Secondary Action', - order: 2, - onClick: handleSecondary, - }, -]); -``` - -### Conditional Buttons - -```tsx -useRightRailButtons([ - // Always show - { - id: 'process', - icon: , - tooltip: 'Process', - disabled: isProcessing, - onClick: handleProcess, - }, - // Only show when condition met - ...(hasResults ? [{ - id: 'export', - icon: , - tooltip: 'Export', - onClick: handleExport, - }] : []), -]); -``` - -## API - -### Button Config - -```typescript -interface RightRailButtonWithAction { - id: string; // Unique identifier - icon?: React.ReactNode; // Icon component (omit when using render) - tooltip?: React.ReactNode; // Hover tooltip / description - active?: boolean; // Optional active state for highlight - section?: 'top' | 'middle' | 'bottom'; // Section (default: 'top') - order?: number; // Sort order (default: 0) - disabled?: boolean; // Disabled state (default: false) - visible?: boolean; // Visibility (default: true) - render?: (ctx: RightRailRenderContext) => React.ReactNode; // Custom renderer - onClick?: () => void; // Click handler (optional if using render) -} - -interface RightRailRenderContext { - id: string; - disabled: boolean; - allButtonsDisabled: boolean; - action?: () => void; - triggerAction: () => void; - active: boolean; -} -``` - -### Custom Rendering (Popovers, Multi-button Blocks) - -```tsx -useRightRailButtons([ - { - id: 'viewer-search', - tooltip: t('rightRail.search', 'Search PDF'), - render: ({ disabled }) => ( - - - - - - - - - - - - - ), - }, -]); -``` - -## Built-in Features - -- **Undo/Redo**: Automatically integrates with Page Editor -- **Theme Support**: Light/dark mode with CSS variables -- **Auto Cleanup**: Buttons unregister when tool unmounts - -## Best Practices - -- Use descriptive IDs: `'compress-optimize'`, `'ocr-process'` -- Choose appropriate Material-UI icons -- Keep tooltips concise: `'Compress PDF'`, `'Process with OCR'` -- Use `useCallback` for click handlers to prevent re-registration -- Reach for `render` when you need popovers or multi-control groups inside the rail diff --git a/frontend/src/core/components/shared/rightRail/RightRail.css b/frontend/src/core/components/shared/rightRail/RightRail.css deleted file mode 100644 index f8fd76b35..000000000 --- a/frontend/src/core/components/shared/rightRail/RightRail.css +++ /dev/null @@ -1,170 +0,0 @@ -.right-rail { - background-color: var(--right-rail-bg); - width: 3.5rem; - min-width: 3.5rem; - max-width: 3.5rem; - position: relative; - z-index: 10; - display: flex; - flex-direction: column; - height: 100vh; - border-left: 1px solid var(--border-subtle); -} - -.right-rail-inner { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.75rem; - padding: 1rem 0.5rem; - width: 100%; - height: 100%; - flex: 1; -} - -.right-rail-section { - background-color: var(--right-rail-foreground); - border-radius: 12px; - padding: 0.5rem; - display: flex; - flex-direction: column; - align-items: center; - gap: 0.75rem; -} - -.right-rail-button-wrapper { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.75rem; - width: 100%; - animation: rightRailButtonReveal 200ms ease forwards; - transform-origin: top center; - opacity: 0; -} - -.right-rail-tooltip-wrapper { - display: inline-flex; - width: 100%; - justify-content: center; -} - -@keyframes rightRailButtonReveal { - 0% { - opacity: 0; - transform: scaleY(0.6) translateY(-6px); - } - 100% { - opacity: 1; - transform: scaleY(1) translateY(0); - } -} - -.right-rail-divider { - width: 2.75rem; - border: none; - border-top: 1px solid var(--tool-subcategory-rule-color); - margin: 0.25rem 0; -} - -.right-rail-icon { - color: var(--right-rail-icon); -} - -.right-rail-icon[data-active="true"] { - color: #fff; -} - -.right-rail-icon[aria-disabled="true"], -.right-rail-icon[disabled] { - color: var(--right-rail-icon-disabled) !important; - background-color: transparent !important; -} - -/* When all buttons are disabled via context */ -.right-rail--all-disabled .right-rail-icon { - color: var(--right-rail-icon-disabled) !important; - background-color: transparent !important; - pointer-events: none !important; -} - -.right-rail-spacer { - flex: 1; -} - -/* Animated grow-down slot for buttons (mirrors current-tool-slot behavior) */ -.right-rail-slot { - overflow: hidden; - max-height: 0; - opacity: 0; - transition: - max-height 450ms ease-out, - opacity 300ms ease-out; -} - -.right-rail-enter { - animation: rightRailGrowDown 450ms ease-out; -} - -.right-rail-exit { - animation: rightRailShrinkUp 450ms ease-out; -} - -.right-rail-slot.visible { - max-height: 40rem; /* increased to fit additional controls + divider */ - opacity: 1; -} - -@keyframes rightRailGrowDown { - 0% { - max-height: 0; - opacity: 0; - } - 100% { - max-height: 40rem; - opacity: 1; - } -} - -@keyframes rightRailShrinkUp { - 0% { - max-height: 40rem; - opacity: 1; - } - 100% { - max-height: 0; - opacity: 0; - } -} - -/* Remove bottom margin from close icon */ -.right-rail-slot .right-rail-icon { - margin-bottom: 0; -} - -/* Inline appear/disappear animation for page-number selector button */ -.right-rail-fade { - transition-property: opacity, transform, max-height, visibility; - transition-duration: 220ms, 220ms, 220ms, 0s; - transition-timing-function: ease, ease, ease, linear; - transition-delay: 0s, 0s, 0s, 0s; - transform-origin: top center; - overflow: hidden; -} - -.right-rail-fade.enter { - opacity: 1; - transform: scale(1); - max-height: 3rem; - visibility: visible; -} - -.right-rail-fade.exit { - opacity: 0; - transform: scale(0.85); - max-height: 0; - visibility: hidden; - /* delay visibility change so opacity/max-height can finish */ - transition-delay: 0s, 0s, 0s, 220ms; - pointer-events: none; -} diff --git a/frontend/src/core/components/shared/signing/CreateSessionPanel.tsx b/frontend/src/core/components/shared/signing/CreateSessionPanel.tsx index 671de36b7..dfbb1c0f7 100644 --- a/frontend/src/core/components/shared/signing/CreateSessionPanel.tsx +++ b/frontend/src/core/components/shared/signing/CreateSessionPanel.tsx @@ -43,11 +43,11 @@ const CreateSessionPanel = ({ <>
- {t("quickAccess.selectedFile", "Selected file")} + {t("quickAccess.selectedFile", "File")}
{selectedFiles[0]?.name || - t("quickAccess.noFile", "No file selected")} + t("quickAccess.noFile", "No file loaded")}
diff --git a/frontend/src/core/components/shared/textInput/TextInput.module.css b/frontend/src/core/components/shared/textInput/TextInput.module.css index 7ccf30063..72fa8abb7 100644 --- a/frontend/src/core/components/shared/textInput/TextInput.module.css +++ b/frontend/src/core/components/shared/textInput/TextInput.module.css @@ -15,6 +15,7 @@ display: flex; align-items: center; justify-content: center; + color: var(--search-text-and-icon-color); } .input { @@ -26,6 +27,8 @@ outline: none; box-shadow: none; transition: box-shadow 0.2s ease; + background-color: var(--input-bg); + color: var(--search-text-and-icon-color); } .input::placeholder { @@ -62,6 +65,7 @@ justify-content: center; font-size: 16px; transition: background-color 0.2s ease; + color: var(--search-text-and-icon-color); } .clearButton:hover { diff --git a/frontend/src/core/components/tools/FullscreenToolSurface.tsx b/frontend/src/core/components/tools/FullscreenToolSurface.tsx index 99683f392..264f00c35 100644 --- a/frontend/src/core/components/tools/FullscreenToolSurface.tsx +++ b/frontend/src/core/components/tools/FullscreenToolSurface.tsx @@ -1,6 +1,5 @@ -import { useState, useRef } from "react"; -import { ActionIcon, ScrollArea, Switch } from "@mantine/core"; -import DoubleArrowIcon from "@mui/icons-material/DoubleArrow"; +import { useRef } from "react"; +import { ScrollArea, Switch } from "@mantine/core"; import { useTranslation } from "react-i18next"; import ToolSearch from "@app/components/tools/toolPicker/ToolSearch"; import FullscreenToolList from "@app/components/tools/FullscreenToolList"; @@ -9,10 +8,8 @@ import { ToolId } from "@app/types/toolId"; import { useFocusTrap } from "@app/hooks/useFocusTrap"; import { LogoIcon } from "@app/components/shared/LogoIcon"; import { Wordmark } from "@app/components/shared/Wordmark"; -import { Tooltip } from "@app/components/shared/Tooltip"; import "@app/components/tools/ToolPanel.css"; import { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry"; -import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; interface FullscreenToolSurfaceProps { searchQuery: string; @@ -28,7 +25,6 @@ interface FullscreenToolSurfaceProps { onSelect: (id: ToolId) => void; onToggleDescriptions: () => void; onExitFullscreenMode: () => void; - toggleLabel: string; geometry: ToolPanelGeometry | null; } @@ -42,47 +38,17 @@ const FullscreenToolSurface = ({ onSearchChange, onSelect, onToggleDescriptions, - onExitFullscreenMode, - toggleLabel, + onExitFullscreenMode: _onExitFullscreenMode, geometry, }: FullscreenToolSurfaceProps) => { const { t } = useTranslation(); - const [isExiting, setIsExiting] = useState(false); const surfaceRef = useRef(null); - const isRTL = - typeof document !== "undefined" && document.documentElement.dir === "rtl"; // Enable focus trap when surface is active - useFocusTrap(surfaceRef, !isExiting); + useFocusTrap(surfaceRef, true); const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo"); - const handleExit = () => { - const prefersReducedMotion = window.matchMedia( - "(prefers-reduced-motion: reduce)", - ).matches; - - if (prefersReducedMotion) { - onExitFullscreenMode(); - return; - } - - setIsExiting(true); - const el = surfaceRef.current; - if (!el) { - onExitFullscreenMode(); - return; - } - // Rely on CSS animation end rather than duplicating timing in JS - el.addEventListener( - "animationend", - () => { - onExitFullscreenMode(); - }, - { once: true }, - ); - }; - const style = geometry ? { left: `${geometry.left}px`, @@ -103,10 +69,7 @@ const FullscreenToolSurface = ({ )} data-tour="tool-panel" > -
+
@@ -115,29 +78,6 @@ const FullscreenToolSurface = ({ className="tool-panel__fullscreen-brand-text" />
-
- - - - - -
diff --git a/frontend/src/core/components/tools/ToolPanel.css b/frontend/src/core/components/tools/ToolPanel.css index dd5c3c15e..0e52f93ab 100644 --- a/frontend/src/core/components/tools/ToolPanel.css +++ b/frontend/src/core/components/tools/ToolPanel.css @@ -1,3 +1,14 @@ +/* ---- Viewer mode mini-toolbar (top of ToolPanel) ---- */ +.tool-panel-viewer-bar { + display: flex; + align-items: center; + gap: 2px; + padding: 4px 8px; + border-bottom: 1px solid var(--border-subtle); + background-color: var(--bg-toolbar); + flex-shrink: 0; +} + /* CSS Custom Properties for Fullscreen Mode */ .tool-panel__fullscreen-surface-inner { --fullscreen-bg-surface-1: color-mix( @@ -109,16 +120,8 @@ var(--text-primary) 20%, var(--border-subtle) ); - --fullscreen-text-icon: color-mix( - in srgb, - var(--text-primary) 90%, - var(--text-muted) - ); - --fullscreen-text-icon-compact: color-mix( - in srgb, - var(--text-primary) 88%, - var(--text-muted) - ); + --fullscreen-text-icon: var(--text-primary); + --fullscreen-text-icon-compact: var(--text-primary); } .tool-panel { @@ -128,6 +131,49 @@ max-width 0.3s ease; } +.tool-panel__collapsed-strip { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + padding-top: 10px; + gap: 8px; + height: 100%; + width: 100%; +} + +.tool-panel__expand-btn { + flex-shrink: 0; + color: var(--text-secondary) !important; + border-color: var(--border-subtle) !important; +} + +.tool-panel__expand-btn svg { + color: var(--text-secondary) !important; +} + +.tool-panel__expand-btn:hover { + color: var(--text-primary) !important; + border-color: var(--border) !important; +} + +.tool-panel__collapsed-search-btn { + flex-shrink: 0; + color: var(--text-secondary) !important; +} + +.tool-panel__collapsed-search-btn svg { + color: var(--text-secondary) !important; +} + +.tool-panel__collapsed-search-btn:hover { + color: var(--text-primary) !important; +} + +.tool-panel__back-btn { + flex-shrink: 0; +} + .tool-panel--fullscreen-active { overflow: visible !important; } @@ -136,7 +182,9 @@ display: flex; align-items: center; gap: 0.75rem; - padding: 0.75rem 1rem; + height: 48px; + padding: 0 0.5rem; + box-sizing: border-box; } .tool-panel__search-row .search-input-container { @@ -469,6 +517,8 @@ .tool-panel__fullscreen-name { color: var(--text-primary); + font-size: 13px !important; + font-weight: 500 !important; } .tool-panel__fullscreen-description { @@ -604,7 +654,7 @@ @keyframes tool-panel-fullscreen-slide-in { from { - transform: translateX(-6%) scaleX(0.85); + transform: translateX(6%) scaleX(0.85); opacity: 0; } to { @@ -619,14 +669,14 @@ opacity: 1; } to { - transform: translateX(-6%) scaleX(0.85); + transform: translateX(6%) scaleX(0.85); opacity: 0; } } @keyframes tool-panel-fullscreen-slide-in-rtl { from { - transform: translateX(6%) scaleX(0.85); + transform: translateX(-6%) scaleX(0.85); opacity: 0; } to { @@ -641,7 +691,7 @@ opacity: 1; } to { - transform: translateX(6%) scaleX(0.85); + transform: translateX(-6%) scaleX(0.85); opacity: 0; } } diff --git a/frontend/src/core/components/tools/ToolPanel.tsx b/frontend/src/core/components/tools/ToolPanel.tsx index 8be8d84c4..ab31a4e8f 100644 --- a/frontend/src/core/components/tools/ToolPanel.tsx +++ b/frontend/src/core/components/tools/ToolPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { usePreferences } from "@app/contexts/PreferencesContext"; @@ -8,15 +8,18 @@ import ToolRenderer from "@app/components/tools/ToolRenderer"; import ToolSearch from "@app/components/tools/toolPicker/ToolSearch"; import { useSidebarContext } from "@app/contexts/SidebarContext"; import rainbowStyles from "@app/styles/rainbow.module.css"; -import { ActionIcon, ScrollArea } from "@mantine/core"; +import { ActionIcon, Button, ScrollArea } from "@mantine/core"; import { ToolId } from "@app/types/toolId"; import { useIsMobile } from "@app/hooks/useIsMobile"; -import DoubleArrowIcon from "@mui/icons-material/DoubleArrow"; import { useTranslation } from "react-i18next"; import FullscreenToolSurface from "@app/components/tools/FullscreenToolSurface"; +import { ToolPanelViewerBar } from "@app/components/tools/ToolPanelViewerBar"; import { useToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry"; -import { useRightRail } from "@app/contexts/RightRailContext"; -import { Tooltip } from "@app/components/shared/Tooltip"; +import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext"; +import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import SearchIcon from "@mui/icons-material/Search"; import "@app/components/tools/ToolPanel.css"; // No props needed - component uses context @@ -25,7 +28,7 @@ export default function ToolPanel() { const { t } = useTranslation(); const { isRainbowMode } = useRainbowThemeContext(); const { sidebarRefs } = useSidebarContext(); - const { toolPanelRef, quickAccessRef, rightRailRef } = sidebarRefs; + const { toolPanelRef, quickAccessRef } = sidebarRefs; const isMobile = useIsMobile(); const { @@ -37,14 +40,18 @@ export default function ToolPanel() { setSearchQuery, selectedToolKey, handleToolSelect, + handleBackToTools, setPreviewFile, toolPanelMode, setToolPanelMode, setLeftPanelView, + setReaderMode, + setSidebarsVisible, + sidebarsVisible, readerMode, } = useToolWorkflow(); - const { setAllRightRailButtonsDisabled } = useRightRail(); + const { setAllButtonsDisabled } = useWorkbenchBar(); const { preferences, updatePreference } = usePreferences(); const isFullscreenMode = toolPanelMode === "fullscreen"; @@ -54,41 +61,54 @@ export default function ToolPanel() { leftPanelView === "toolPicker" && !isMobile && toolPickerVisible; - const isRTL = - typeof document !== "undefined" && document.documentElement.dir === "rtl"; - // Disable right rail buttons when fullscreen mode is active + // Disable workbench bar buttons when fullscreen mode is active useEffect(() => { - setAllRightRailButtonsDisabled(fullscreenExpanded); - }, [fullscreenExpanded, setAllRightRailButtonsDisabled]); + setAllButtonsDisabled(fullscreenExpanded); + }, [fullscreenExpanded, setAllButtonsDisabled]); const fullscreenGeometry = useToolPanelGeometry({ enabled: fullscreenExpanded, toolPanelRef, quickAccessRef, - rightRailRef, }); - const toggleLabel = isFullscreenMode - ? t("toolPanel.toggle.sidebar", "Switch to sidebar mode") - : t("toolPanel.toggle.fullscreen", "Switch to fullscreen mode"); - - const handleModeToggle = () => { - const nextMode = isFullscreenMode ? "sidebar" : "fullscreen"; - setToolPanelMode(nextMode); - - if (nextMode === "fullscreen" && leftPanelView !== "toolPicker") { - setLeftPanelView("toolPicker"); - } + const handleExpand = () => { + if (readerMode) setReaderMode(false); + if (leftPanelView === "hidden") setLeftPanelView("toolPicker"); + if (!sidebarsVisible) setSidebarsVisible(true); }; + const handleCollapse = () => { + setLeftPanelView("hidden"); + }; + + const [focusSearch, setFocusSearch] = useState(false); + const focusSearchOnNextOpen = useRef(false); + + const handleExpandAndSearch = () => { + focusSearchOnNextOpen.current = true; + handleExpand(); + }; + + // Once the panel becomes visible, consume the focus-search request + useEffect(() => { + if (isPanelVisible && focusSearchOnNextOpen.current) { + focusSearchOnNextOpen.current = false; + setFocusSearch(true); + // Reset after one render so autoFocus doesn't re-fire on subsequent renders + const id = setTimeout(() => setFocusSearch(false), 100); + return () => clearTimeout(id); + } + }, [isPanelVisible]); + const computedWidth = () => { if (isMobile) { return "100%"; } if (!isPanelVisible) { - return "0"; + return "3.5rem"; } return "18.5rem"; @@ -109,7 +129,7 @@ export default function ToolPanel() { ref={toolPanelRef} data-sidebar="tool-panel" data-tour={fullscreenExpanded ? undefined : "tool-panel"} - className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-r border-[var(--border-subtle)] transition-all duration-300 ease-out ${ + className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${ isRainbowMode ? rainbowStyles.rainbowPaper : "" } ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`} style={{ @@ -117,21 +137,52 @@ export default function ToolPanel() { padding: "0", }} > - {!fullscreenExpanded && ( + {!fullscreenExpanded && !isPanelVisible && !isMobile && ( +
+ + + + + + +
+ )} + + {!fullscreenExpanded && isPanelVisible && (
+ {/* Viewer mode tools — annotate, redact, form fill */} + +
- {!isMobile && leftPanelView === "toolPicker" && ( - - - - - - )} + + +
{searchQuery.trim().length > 0 ? ( @@ -185,6 +226,26 @@ export default function ToolPanel() {
) : (
+
+ +
{selectedToolKey ? ( @@ -224,7 +285,6 @@ export default function ToolPanel() { ) } onExitFullscreenMode={() => setToolPanelMode("sidebar")} - toggleLabel={toggleLabel} geometry={fullscreenGeometry} /> )} diff --git a/frontend/src/core/components/tools/ToolPanelViewerBar.tsx b/frontend/src/core/components/tools/ToolPanelViewerBar.tsx new file mode 100644 index 000000000..5e01b4f71 --- /dev/null +++ b/frontend/src/core/components/tools/ToolPanelViewerBar.tsx @@ -0,0 +1,99 @@ +import React, { useCallback } from "react"; +import { ActionIcon } from "@mantine/core"; +import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext"; +import { useNavigationState } from "@app/contexts/NavigationContext"; +import { Tooltip } from "@app/components/shared/Tooltip"; +import type { + WorkbenchBarButtonConfig, + WorkbenchBarRenderContext, +} from "@app/types/workbenchBar"; + +/** + * Mini toolbar rendered at the top of the ToolPanel when in viewer mode. + * Shows "tool-panel" section buttons — viewer mode tools like annotate, redact, form fill. + */ +export function ToolPanelViewerBar() { + const { workbench } = useNavigationState(); + const { buttons, actions, allButtonsDisabled } = useWorkbenchBar(); + + const toolPanelButtons = buttons + .filter((btn) => btn.section === "tool-panel" && (btn.visible ?? true)) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + + const renderButton = useCallback( + (btn: WorkbenchBarButtonConfig) => { + const action = actions[btn.id]; + const disabled = Boolean(btn.disabled || allButtonsDisabled); + const isActive = Boolean(btn.active); + + const triggerAction = () => { + if (!disabled) action?.(); + }; + + if (btn.render) { + const context: WorkbenchBarRenderContext = { + id: btn.id, + disabled, + allButtonsDisabled, + action, + triggerAction, + active: isActive, + }; + return btn.render(context) ?? null; + } + + if (!btn.icon) return null; + + const ariaLabel = + btn.ariaLabel || + (typeof btn.tooltip === "string" ? btn.tooltip : undefined); + const buttonNode = ( + + {btn.icon} + + ); + + if (!btn.tooltip) return buttonNode; + return ( + +
{buttonNode}
+
+ ); + }, + [actions, allButtonsDisabled], + ); + + if (workbench !== "viewer" || toolPanelButtons.length === 0) return null; + + return ( +
+ {toolPanelButtons.map((btn) => { + const content = renderButton(btn); + if (!content) return null; + return ( +
+ {content} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/core/components/tools/ToolPicker.tsx b/frontend/src/core/components/tools/ToolPicker.tsx index 02ba7625a..f60475493 100644 --- a/frontend/src/core/components/tools/ToolPicker.tsx +++ b/frontend/src/core/components/tools/ToolPicker.tsx @@ -65,12 +65,12 @@ const ToolPicker = ({ isSearching ? filteredTools : emptyFilteredTools; const { searchGroups } = useToolSections(effectiveFilteredForSearch); const headerTextStyle: React.CSSProperties = { - fontSize: "0.75rem", - fontWeight: 500, - padding: "0.5rem 0 0.25rem 0.5rem", - textTransform: "none", - color: "var(--text-secondary, rgba(0, 0, 0, 0.6))", - opacity: 0.7, + fontSize: "0.68rem", + fontWeight: 600, + padding: "1rem 0 0.35rem 0.5rem", + textTransform: "uppercase", + letterSpacing: "0.06em", + color: "var(--text-muted)", }; const toTitleCase = (s: string) => s.replace( diff --git a/frontend/src/core/components/tools/automate/AutomationRun.tsx b/frontend/src/core/components/tools/automate/AutomationRun.tsx index d988f1a4a..3e3a4c075 100644 --- a/frontend/src/core/components/tools/automate/AutomationRun.tsx +++ b/frontend/src/core/components/tools/automate/AutomationRun.tsx @@ -11,7 +11,7 @@ import { } from "@mantine/core"; import PlayArrowIcon from "@mui/icons-material/PlayArrow"; import CheckIcon from "@mui/icons-material/Check"; -import { useFileSelection } from "@app/contexts/FileContext"; +import { useViewScopedFiles } from "@app/hooks/tools/shared/useViewScopedFiles"; import { useToolRegistry } from "@app/contexts/ToolRegistryContext"; import { AutomationConfig, ExecutionStep } from "@app/types/automation"; import { EXECUTION_STATUS } from "@app/constants/automation"; @@ -29,7 +29,7 @@ export default function AutomationRun({ automateOperation, }: AutomationRunProps) { const { t } = useTranslation(); - const { selectedFiles } = useFileSelection(); + const selectedFiles = useViewScopedFiles(); const { regularTools } = useToolRegistry(); const toolRegistry = regularTools; const cleanup = useResourceCleanup(); diff --git a/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx b/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx index cbb33539a..d3a13a6fd 100644 --- a/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx +++ b/frontend/src/core/components/tools/compare/CompareDocumentPane.tsx @@ -13,6 +13,9 @@ import { } from "@app/components/tools/compare/compare"; import CompareNavigationDropdown from "@app/components/tools/compare/CompareNavigationDropdown"; import { useIsMobile } from "@app/hooks/useIsMobile"; +import { truncateCenter } from "@app/utils/textUtils"; + +const COMPARE_PANE_TITLE_MAX_LEN = 48; // utilities moved to compare.ts @@ -107,9 +110,14 @@ const CompareDocumentPane = ({ return (
- - - {title} + + + {title ? truncateCenter(title, COMPARE_PANE_TITLE_MAX_LEN) : title} {(changes.length > 0 || Boolean(dropdownPlaceholder)) && ( diff --git a/frontend/src/core/components/tools/compare/CompareWorkbenchView.tsx b/frontend/src/core/components/tools/compare/CompareWorkbenchView.tsx index 5d2842cd6..2034dcaf7 100644 --- a/frontend/src/core/components/tools/compare/CompareWorkbenchView.tsx +++ b/frontend/src/core/components/tools/compare/CompareWorkbenchView.tsx @@ -14,14 +14,14 @@ import { import { CompareResultData, CompareWorkbenchData } from "@app/types/compare"; import ComparePixelWorkbenchView from "@app/components/tools/compare/ComparePixelWorkbenchView"; import { useFileContext } from "@app/contexts/file/fileHooks"; -import { useRightRailButtons } from "@app/hooks/useRightRailButtons"; +import { useWorkbenchBarButtons } from "@app/hooks/useWorkbenchBarButtons"; import CompareDocumentPane from "@app/components/tools/compare/CompareDocumentPane"; import { useComparePagePreviews } from "@app/components/tools/compare/hooks/useComparePagePreviews"; import { useComparePanZoom } from "@app/components/tools/compare/hooks/useComparePanZoom"; import { useCompareHighlights } from "@app/components/tools/compare/hooks/useCompareHighlights"; import { useCompareChangeNavigation } from "@app/components/tools/compare/hooks/useCompareChangeNavigation"; import "@app/components/tools/compare/compareView.css"; -import { useCompareRightRailButtons } from "@app/components/tools/compare/hooks/useCompareRightRailButtons"; +import { useCompareWorkbenchBarButtons } from "@app/components/tools/compare/hooks/useCompareWorkbenchBarButtons"; import { alert, updateToast, @@ -209,7 +209,7 @@ const CompareTextWorkbenchView = ({ data }: CompareTextWorkbenchViewProps) => { }) ); - const rightRailButtons = useCompareRightRailButtons({ + const workbenchBarButtons = useCompareWorkbenchBarButtons({ layout, toggleLayout, isPanMode, @@ -230,7 +230,7 @@ const CompareTextWorkbenchView = ({ data }: CompareTextWorkbenchViewProps) => { comparisonScrollRef, }); - useRightRailButtons(rightRailButtons); + useWorkbenchBarButtons(workbenchBarButtons); // Rendering progress toast for very large PDFs const LARGE_PAGE_THRESHOLD = 400; // show banner when one or both exceed threshold diff --git a/frontend/src/core/components/tools/compare/compareView.css b/frontend/src/core/components/tools/compare/compareView.css index c4c4278f0..d65c5fa86 100644 --- a/frontend/src/core/components/tools/compare/compareView.css +++ b/frontend/src/core/components/tools/compare/compareView.css @@ -23,10 +23,9 @@ flex-direction: column; gap: 1.5rem; padding: 1rem; - padding-top: 3rem; height: 100%; min-height: 0; - /* Allow the custom workbench to shrink within flex parents (prevents pushing right rail off-screen) */ + /* Allow the custom workbench to shrink within flex parents */ min-width: 0; } @@ -522,6 +521,124 @@ } } +/* Compare tool — file selection step (Original / Edited + swap column) */ +.compare-step-selection { + width: 100%; +} + +.compare-step-selection__clear-row { + display: flex; + justify-content: flex-end; + width: 100%; +} + +/* Thumbnails + swap only — swap top aligns with first thumbnail, not “Original PDF” heading */ +.compare-step-selection__thumbs-row { + display: flex; + flex-direction: row; + align-items: stretch; + gap: 0.75rem; + width: 100%; + min-width: 0; +} + +.compare-step-selection__thumbs-col { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* Full-height light bar to the right of both cards (reference UI) */ +.compare-step-selection__swap { + flex-shrink: 0; + width: 36px; + min-width: 36px; + align-self: stretch; + margin: 0; + padding: 0.5rem 0.2rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.35rem; + border-radius: var(--radius-md); + border: 1px solid + color-mix(in srgb, var(--mantine-color-blue-4) 28%, var(--border-default)); + background: color-mix( + in srgb, + var(--mantine-color-blue-0) 92%, + var(--mantine-color-body) + ); + color: var(--mantine-color-blue-filled); + cursor: pointer; + font: inherit; + transition: + background 0.12s ease, + border-color 0.12s ease; +} + +.compare-step-selection__swap:hover:not(:disabled) { + background: color-mix( + in srgb, + var(--mantine-color-blue-1) 75%, + var(--mantine-color-body) + ); + border-color: color-mix( + in srgb, + var(--mantine-color-blue-5) 35%, + var(--border-default) + ); +} + +.compare-step-selection__swap:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.compare-step-selection__swap-icon { + font-size: 1.35rem !important; + color: var(--mantine-color-blue-filled); +} + +.compare-step-selection__swap-label { + font-size: 0.7rem; + font-weight: 600; + line-height: 1.15; + text-align: center; + color: var(--mantine-color-blue-filled); + writing-mode: horizontal-tb; +} + +[data-mantine-color-scheme="”dark”"] .compare-step-selection__swap { + background: color-mix( + in srgb, + var(--mantine-color-blue-9) 22%, + var(--mantine-color-dark-6) + ); + border-color: color-mix( + in srgb, + var(--mantine-color-blue-4) 40%, + var(--border-default) + ); + color: var(--mantine-color-blue-3); +} + +[data-mantine-color-scheme="”dark”"] + .compare-step-selection__swap:hover:not(:disabled) { + background: color-mix( + in srgb, + var(--mantine-color-blue-9) 32%, + var(--mantine-color-dark-6) + ); +} + +[data-mantine-color-scheme="”dark”"] .compare-step-selection__swap-icon, +[data-mantine-color-scheme="”dark”"] .compare-step-selection__swap-label { + color: var(--mantine-color-blue-3); +} + /* Pixel compare mode */ .compare-pixel-workbench { padding: 16px 24px 32px; diff --git a/frontend/src/core/components/tools/compare/hooks/useCompareRightRailButtons.tsx b/frontend/src/core/components/tools/compare/hooks/useCompareWorkbenchBarButtons.tsx similarity index 93% rename from frontend/src/core/components/tools/compare/hooks/useCompareRightRailButtons.tsx rename to frontend/src/core/components/tools/compare/hooks/useCompareWorkbenchBarButtons.tsx index 273535a05..3ddbbbf3d 100644 --- a/frontend/src/core/components/tools/compare/hooks/useCompareRightRailButtons.tsx +++ b/frontend/src/core/components/tools/compare/hooks/useCompareWorkbenchBarButtons.tsx @@ -4,12 +4,12 @@ import { useTranslation } from "react-i18next"; import LocalIcon from "@app/components/shared/LocalIcon"; import { alert } from "@app/components/toast"; import type { ToastLocation } from "@app/components/toast/types"; -import type { RightRailButtonWithAction } from "@app/hooks/useRightRailButtons"; +import type { WorkbenchBarButtonWithAction } from "@app/hooks/useWorkbenchBarButtons"; import { useIsMobile } from "@app/hooks/useIsMobile"; type Pane = "base" | "comparison"; -export interface UseCompareRightRailButtonsOptions { +export interface UseCompareWorkbenchBarButtonsOptions { layout: "side-by-side" | "stacked"; toggleLayout: () => void; isPanMode: boolean; @@ -30,7 +30,7 @@ export interface UseCompareRightRailButtonsOptions { comparisonScrollRef?: React.RefObject; } -export const useCompareRightRailButtons = ({ +export const useCompareWorkbenchBarButtons = ({ layout, toggleLayout, isPanMode, @@ -49,11 +49,11 @@ export const useCompareRightRailButtons = ({ zoomLimits, baseScrollRef, comparisonScrollRef, -}: UseCompareRightRailButtonsOptions): RightRailButtonWithAction[] => { +}: UseCompareWorkbenchBarButtonsOptions): WorkbenchBarButtonWithAction[] => { const { t, i18n } = useTranslation(); const isMobile = useIsMobile(); - return useMemo( + return useMemo( () => [ { id: "compare-toggle-layout", @@ -212,6 +212,6 @@ export const useCompareRightRailButtons = ({ ); }; -export type UseCompareRightRailButtonsReturn = ReturnType< - typeof useCompareRightRailButtons +export type UseCompareWorkbenchBarButtonsReturn = ReturnType< + typeof useCompareWorkbenchBarButtons >; diff --git a/frontend/src/core/components/tools/crop/CropSettings.tsx b/frontend/src/core/components/tools/crop/CropSettings.tsx index 8525d44c4..65b0e8f0f 100644 --- a/frontend/src/core/components/tools/crop/CropSettings.tsx +++ b/frontend/src/core/components/tools/crop/CropSettings.tsx @@ -12,7 +12,7 @@ import { import { useTranslation } from "react-i18next"; import RestartAltIcon from "@mui/icons-material/RestartAlt"; import { CropParametersHook } from "@app/hooks/tools/crop/useCropParameters"; -import { useSelectedFiles } from "@app/contexts/file/fileHooks"; +import { useAllFiles } from "@app/contexts/FileContext"; import CropAreaSelector from "@app/components/tools/crop/CropAreaSelector"; import CropCoordinateInputs from "@app/components/tools/crop/CropCoordinateInputs"; import { DEFAULT_CROP_AREA } from "@app/constants/cropConstants"; @@ -34,17 +34,17 @@ const CONTAINER_SIZE = 250; // Fit within actual pane width const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => { const { t } = useTranslation(); - const { selectedFiles, selectedFileStubs } = useSelectedFiles(); + const { files, fileStubs } = useAllFiles(); - // Get the first selected file for preview + // Get the first file for preview const selectedStub = useMemo(() => { - return selectedFileStubs.length > 0 ? selectedFileStubs[0] : null; - }, [selectedFileStubs]); + return fileStubs.length > 0 ? fileStubs[0] : null; + }, [fileStubs]); - // Get the first selected file for PDF processing + // Get the first file for PDF processing const selectedFile = useMemo(() => { - return selectedFiles.length > 0 ? selectedFiles[0] : null; - }, [selectedFiles]); + return files.length > 0 ? files[0] : null; + }, [files]); // Get thumbnail for the selected file const [thumbnail, setThumbnail] = useState(null); diff --git a/frontend/src/core/components/tools/fullscreen/CompactToolItem.tsx b/frontend/src/core/components/tools/fullscreen/CompactToolItem.tsx index 858e1fd57..a6f4d257b 100644 --- a/frontend/src/core/components/tools/fullscreen/CompactToolItem.tsx +++ b/frontend/src/core/components/tools/fullscreen/CompactToolItem.tsx @@ -76,9 +76,7 @@ const CompactToolItem: React.FC = ({ ) : null}
- - {tool.name} - + {tool.name} {tool.versionStatus === "alpha" && ( {t("toolPanel.alpha", "Alpha")} diff --git a/frontend/src/core/components/tools/fullscreen/DetailedToolItem.tsx b/frontend/src/core/components/tools/fullscreen/DetailedToolItem.tsx index 61372fad0..32fa181a1 100644 --- a/frontend/src/core/components/tools/fullscreen/DetailedToolItem.tsx +++ b/frontend/src/core/components/tools/fullscreen/DetailedToolItem.tsx @@ -78,9 +78,7 @@ const DetailedToolItem: React.FC = ({ ) : null}
- - {tool.name} - + {tool.name} {tool.versionStatus === "alpha" && ( {/* we can add more translations for different badges in future, like beta, etc. */} diff --git a/frontend/src/core/components/tools/rotate/RotateSettings.tsx b/frontend/src/core/components/tools/rotate/RotateSettings.tsx index 134bf97b4..cae25c5a6 100644 --- a/frontend/src/core/components/tools/rotate/RotateSettings.tsx +++ b/frontend/src/core/components/tools/rotate/RotateSettings.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import RotateLeftIcon from "@mui/icons-material/RotateLeft"; import RotateRightIcon from "@mui/icons-material/RotateRight"; import { RotateParametersHook } from "@app/hooks/tools/rotate/useRotateParameters"; -import { useSelectedFiles } from "@app/contexts/file/fileHooks"; +import { useAllFiles } from "@app/contexts/FileContext"; import DocumentThumbnail from "@app/components/shared/filePreview/DocumentThumbnail"; interface RotateSettingsProps { @@ -17,12 +17,12 @@ const RotateSettings = ({ disabled = false, }: RotateSettingsProps) => { const { t } = useTranslation(); - const { selectedFileStubs } = useSelectedFiles(); + const { fileStubs } = useAllFiles(); - // Get the first selected file for preview + // Get the first file for preview const selectedStub = useMemo(() => { - return selectedFileStubs.length > 0 ? selectedFileStubs[0] : null; - }, [selectedFileStubs]); + return fileStubs.length > 0 ? fileStubs[0] : null; + }, [fileStubs]); // Get thumbnail for the selected file const [thumbnail, setThumbnail] = useState(null); diff --git a/frontend/src/core/components/tools/shared/FileStatusIndicator.tsx b/frontend/src/core/components/tools/shared/FileStatusIndicator.tsx index 90c24253f..08a0d0ed9 100644 --- a/frontend/src/core/components/tools/shared/FileStatusIndicator.tsx +++ b/frontend/src/core/components/tools/shared/FileStatusIndicator.tsx @@ -59,17 +59,12 @@ const FileStatusIndicator = ({ const getPlaceholder = () => { if (minFiles === undefined || minFiles === 1) { - return t( - "files.selectFromWorkbench", - "Select files from the workbench or ", - ); + return t("files.selectFromWorkbench", "Add files to the workbench or "); } else { return t( "files.selectMultipleFromWorkbench", - "Select at least {{count}} files from the workbench or ", - { - count: minFiles, - }, + "Add at least {{count}} files to the workbench or ", + { count: minFiles }, ); } }; @@ -171,14 +166,12 @@ const FileStatusIndicator = ({ ✓{" "} {selectedFiles.length === 1 ? ( - {t("fileSelected", "Selected: {{filename}}", { + {t("fileSelected", "{{filename}}", { filename: selectedFiles[0]?.name, })} ) : ( - t("filesSelected", "{{count}} files selected", { - count: selectedFiles.length, - }) + t("filesSelected", "{{count}} files", { count: selectedFiles.length }) )} ); diff --git a/frontend/src/core/components/tools/shared/OperationButton.tsx b/frontend/src/core/components/tools/shared/OperationButton.tsx index e6db1478e..3993953ea 100644 --- a/frontend/src/core/components/tools/shared/OperationButton.tsx +++ b/frontend/src/core/components/tools/shared/OperationButton.tsx @@ -58,6 +58,10 @@ const OperationButton = ({ "tool.endpointUnavailable", "This tool is unavailable on your server.", ), + filesLoading: t( + "tool.filesLoading", + "Files are still loading, please wait.", + ), noFiles: t("tool.noFiles", "Add a file to get started."), invalidParams: t("tool.invalidParams", "Fill in the required settings."), viewerMode: t( diff --git a/frontend/src/core/components/tools/shared/ReviewToolStep.tsx b/frontend/src/core/components/tools/shared/ReviewToolStep.tsx index 70bb9408d..82e936678 100644 --- a/frontend/src/core/components/tools/shared/ReviewToolStep.tsx +++ b/frontend/src/core/components/tools/shared/ReviewToolStep.tsx @@ -120,6 +120,7 @@ function ReviewStepContent({ {onUndo && ( 1 selected files: appends "(N files)" to button text. - * - File-editor mode with 0 selected files: shows a hint to select files. + * - N>1 files loaded: appends "(N files)" to button text. * - All other cases: no change to button text or layout. */ export function ScopedOperationButton({ @@ -30,7 +29,14 @@ export function ScopedOperationButton({ const { t } = useTranslation(); const { workbench } = useNavigationState(); const { activeFileIndex } = useViewer(); - const { files: allFiles } = useAllFiles(); + const { files: allFiles, fileIds } = useAllFiles(); + + // Disable until all files are hydrated — running early would silently skip unloaded files. + const isFilesHydrating = fileIds.length > allFiles.length; + const effectiveDisabledReason = + isFilesHydrating && props.disabledReason !== "endpointUnavailable" + ? "filesLoading" + : props.disabledReason; const isViewerMode = workbench === "viewer"; const isFileEditorMode = workbench === "fileEditor"; @@ -56,6 +62,11 @@ export function ScopedOperationButton({ ? allFiles[activeFileIndex]?.name : null; + const pendingCount = fileIds.length - allFiles.length; + const isBulkLoading = pendingCount > 1; + const loadProgress = isBulkLoading + ? (allFiles.length / fileIds.length) * 100 + : 0; const showSelectFilesHint = !disableScopeHints && isFileEditorMode && @@ -64,7 +75,54 @@ export function ScopedOperationButton({ return ( <> - + {isFilesHydrating ? ( + + + + ) : ( + + )} {viewerFileName && ( {t("tool.singleFileScope", "Only applying to: {{fileName}}", { diff --git a/frontend/src/core/components/tools/showJS/ShowJSView.css b/frontend/src/core/components/tools/showJS/ShowJSView.css index 3121c2e29..301589a93 100644 --- a/frontend/src/core/components/tools/showJS/ShowJSView.css +++ b/frontend/src/core/components/tools/showJS/ShowJSView.css @@ -92,7 +92,7 @@ border: 1px solid var(--mantine-color-gray-4); border-radius: 8px; overflow: hidden; - background: var(--right-rail-bg); + background: var(--bg-file-manager); } .showjs-toolbar { diff --git a/frontend/src/core/components/tools/toolPicker/ToolButton.tsx b/frontend/src/core/components/tools/toolPicker/ToolButton.tsx index 198316336..d4021d794 100644 --- a/frontend/src/core/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/src/core/components/tools/toolPicker/ToolButton.tsx @@ -201,13 +201,17 @@ const ToolButton: React.FC = ({ handleUnlessSpecialClick(e, () => handleClick(id)); }; + const selectedStyles = isSelected + ? { backgroundColor: "#EAEAEA", color: "var(--tools-text-and-icon-color)" } + : {}; + const buttonElement = navProps ? ( // For internal tools with URLs, render Button as an anchor for proper link behavior @@ -1329,7 +1297,7 @@ const EmbedPdfViewerContent = ({ { if (file) { const objectUrl = URL.createObjectURL(file); @@ -171,7 +174,17 @@ export function LocalEmbedPDF({ } else if (url) { setPdfUrl(url); } - }, [file, url]); + // When file is present, use the stable key to avoid blob URL churn from FileContext + // re-renders. When only url is provided, depend on url directly so changes are picked up. + }, [file ? fileStableKey : url]); + + // Keyed by fileStableKey to avoid recomputing on every FileContext re-render. + const exportFileName = useMemo(() => { + if (fileName) return fileName; + if (file && "name" in file) return (file as File).name; + if (url) return url.split("/").pop()?.split("?")[0] || "document.pdf"; + return "document.pdf"; + }, [fileStableKey, fileName, url]); // Create plugins configuration const plugins = useMemo(() => { @@ -183,17 +196,6 @@ export function LocalEmbedPDF({ ); const viewportGap = rootFontSize * 3.5; - // Determine export filename - use provided fileName, or extract from file/url - let exportFileName = "document.pdf"; - if (fileName) { - exportFileName = fileName; - } else if (file && "name" in file) { - exportFileName = file.name; - } else if (url) { - const urlPath = url.split("/").pop() || "document.pdf"; - exportFileName = urlPath.split("?")[0]; // Remove query params - } - return [ createPluginRegistration(DocumentManagerPluginPackage, { initialDocuments: [ @@ -290,7 +292,7 @@ export function LocalEmbedPDF({ // Register print plugin for printing PDFs createPluginRegistration(PrintPluginPackage), ]; - }, [pdfUrl, enableAnnotations, fileName, file, url]); + }, [pdfUrl, enableAnnotations, exportFileName]); // Initialize the engine with the React hook - use local WASM for offline support const { engine, isLoading, error } = usePdfiumEngine({ diff --git a/frontend/src/core/components/viewer/NonPdfViewer.tsx b/frontend/src/core/components/viewer/NonPdfViewer.tsx index ec24efe56..1b54fa168 100644 --- a/frontend/src/core/components/viewer/NonPdfViewer.tsx +++ b/frontend/src/core/components/viewer/NonPdfViewer.tsx @@ -4,6 +4,7 @@ import ArticleIcon from "@mui/icons-material/Article"; import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; import { useFileState } from "@app/contexts/FileContext"; +import { useViewer } from "@app/contexts/ViewerContext"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { detectFileExtension, @@ -12,7 +13,6 @@ import { import { CONVERSION_MATRIX } from "@app/constants/convertConstants"; import { NonPdfBanner } from "@app/components/viewer/nonpdf/NonPdfBanner"; -import { getFileTypeMeta } from "@app/components/viewer/nonpdf/types"; import { ImageViewer } from "@app/components/viewer/nonpdf/ImageViewer"; import { CsvViewer } from "@app/components/viewer/nonpdf/CsvViewer"; import { JsonViewer } from "@app/components/viewer/nonpdf/JsonViewer"; @@ -24,8 +24,6 @@ export interface ViewerProps { setSidebarsVisible: (v: boolean) => void; onClose?: () => void; previewFile?: File | null; - activeFileIndex?: number; - setActiveFileIndex?: (index: number) => void; } export interface NonPdfViewerProps extends ViewerProps { @@ -34,10 +32,6 @@ export interface NonPdfViewerProps extends ViewerProps { export function NonPdfViewer({ file }: NonPdfViewerProps) { const fileType = useMemo(() => detectNonPdfFileType(file), [file]); - const meta = useMemo( - () => getFileTypeMeta(fileType, file.name), - [fileType, file.name], - ); const { handleToolSelect, toolAvailability } = useToolWorkflow(); @@ -112,7 +106,6 @@ export function NonPdfViewer({ file }: NonPdfViewerProps) { }} > - No file selected + No file loaded ); diff --git a/frontend/src/core/components/viewer/PdfViewerToolbar.tsx b/frontend/src/core/components/viewer/PdfViewerToolbar.tsx index 7ce3b9918..ea71bb826 100644 --- a/frontend/src/core/components/viewer/PdfViewerToolbar.tsx +++ b/frontend/src/core/components/viewer/PdfViewerToolbar.tsx @@ -1,5 +1,12 @@ import { useState, useEffect } from "react"; -import { Button, Paper, Group, NumberInput } from "@mantine/core"; +import { + ActionIcon, + Button, + Paper, + Group, + NumberInput, + Slider, +} from "@mantine/core"; import { useTranslation } from "react-i18next"; import { useViewer } from "@app/contexts/ViewerContext"; import { Tooltip } from "@app/components/shared/Tooltip"; @@ -12,6 +19,8 @@ import ViewWeekIcon from "@mui/icons-material/ViewWeek"; import DarkModeIcon from "@mui/icons-material/DarkMode"; import WbSunnyIcon from "@mui/icons-material/WbSunny"; import WbTwilightIcon from "@mui/icons-material/WbTwilight"; +import ZoomInIcon from "@mui/icons-material/ZoomIn"; +import ZoomOutIcon from "@mui/icons-material/ZoomOut"; interface PdfViewerToolbarProps { // Page navigation props (placeholders for now) @@ -291,31 +300,48 @@ export function PdfViewerToolbar({ {/* Zoom Controls */} - - {displayZoomPercent}% - ); diff --git a/frontend/src/core/components/viewer/ThumbnailSidebar.tsx b/frontend/src/core/components/viewer/ThumbnailSidebar.tsx index 1876cf581..06a304336 100644 --- a/frontend/src/core/components/viewer/ThumbnailSidebar.tsx +++ b/frontend/src/core/components/viewer/ThumbnailSidebar.tsx @@ -6,13 +6,13 @@ import { PrivateContent } from "@app/components/shared/PrivateContent"; interface ThumbnailSidebarProps { visible: boolean; onToggle: () => void; - activeFileIndex?: number; + activeFileId?: string | null; } export function ThumbnailSidebar({ visible, onToggle: _onToggle, - activeFileIndex, + activeFileId, }: ThumbnailSidebarProps) { const { getScrollState, scrollActions, getThumbnailAPI } = useViewer(); const [thumbnails, setThumbnails] = useState<{ [key: number]: string }>({}); @@ -29,7 +29,7 @@ export function ThumbnailSidebar({ } }); setThumbnails({}); - }, [activeFileIndex]); + }, [activeFileId]); // Keep a ref to thumbnails for cleanup on unmount const thumbnailsRef = useRef(thumbnails); diff --git a/frontend/src/core/components/viewer/Viewer.tsx b/frontend/src/core/components/viewer/Viewer.tsx index b8fec6def..7052df4c6 100644 --- a/frontend/src/core/components/viewer/Viewer.tsx +++ b/frontend/src/core/components/viewer/Viewer.tsx @@ -5,6 +5,8 @@ import { type ViewerProps, } from "@app/components/viewer/NonPdfViewer"; import { useFileState } from "@app/contexts/FileContext"; +import { useViewer } from "@app/contexts/ViewerContext"; +import { isStirlingFile } from "@app/types/fileContext"; import { isPdfFile } from "@app/utils/fileUtils"; export type { ViewerProps }; @@ -12,13 +14,16 @@ export type { ViewerProps }; const Viewer = (props: ViewerProps) => { const { selectors } = useFileState(); const activeFiles = selectors.getFiles(); - const activeFileIndex = props.activeFileIndex ?? 0; + const { activeFileId } = useViewer(); - // Determine the active file — previewFile takes priority + // Determine the active file — previewFile takes priority, then look up by stable ID const activeFile = useMemo(() => { if (props.previewFile) return props.previewFile; - return activeFiles[activeFileIndex] ?? activeFiles[0] ?? null; - }, [props.previewFile, activeFiles, activeFileIndex]); + const byId = activeFileId + ? activeFiles.find((f) => isStirlingFile(f) && f.fileId === activeFileId) + : null; + return byId ?? activeFiles[0] ?? null; + }, [props.previewFile, activeFiles, activeFileId]); // Route to the appropriate viewer based on file type if (activeFile && !isPdfFile(activeFile)) { diff --git a/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx b/frontend/src/core/components/viewer/ViewerAnnotationControls.tsx similarity index 84% rename from frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx rename to frontend/src/core/components/viewer/ViewerAnnotationControls.tsx index c0383a58c..78888f418 100644 --- a/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx +++ b/frontend/src/core/components/viewer/ViewerAnnotationControls.tsx @@ -12,9 +12,7 @@ import { useNavigationGuard, useNavigationActions, } from "@app/contexts/NavigationContext"; -import { useSidebarContext } from "@app/contexts/SidebarContext"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; -import { useRightRailTooltipSide } from "@app/hooks/useRightRailTooltipSide"; import { useRedactionMode, useRedaction } from "@app/contexts/RedactionContext"; import { defaultParameters, @@ -32,10 +30,7 @@ export default function ViewerAnnotationControls({ disabled = false, }: ViewerAnnotationControlsProps) { const { t } = useTranslation(); - const { sidebarRefs } = useSidebarContext(); const { setLeftPanelView, setSidebarsVisible } = useToolWorkflow(); - const { position: tooltipPosition, offset: tooltipOffset } = - useRightRailTooltipSide(sidebarRefs); // Viewer context for PDF controls - safely handle when not available const viewerContext = React.useContext(ViewerContext); @@ -129,31 +124,25 @@ export default function ViewerAnnotationControls({ // Handle redaction mode toggle const handleRedactionToggle = async () => { if (isRedactMode) { - // Exit redaction mode exitRedactionMode(); } else { - // Check for unsaved annotation changes const hasAnnotationChanges = historyApiRef?.current?.canUndo() ?? false; const enterRedactionMode = async () => { await saveAnnotationsIfNeeded(); - // Set redaction config to manual mode when opening from viewer const manualConfig: RedactParameters = { ...defaultParameters, mode: "manual", }; setRedactionConfig(manualConfig); - // Set tool and keep viewer workbench navActions.setToolAndWorkbench("redact", "viewer"); - // Ensure sidebars are visible and open tool content setSidebarsVisible(true); setLeftPanelView("toolContent"); setRedactionMode(true); - // Activate unified redact mode after a short delay setTimeout(() => { const currentType = redactionApiRef.current?.getActiveType?.(); if (currentType !== RedactionMode.Redact) { @@ -171,7 +160,6 @@ export default function ViewerAnnotationControls({ }; const handleToggleAnnotationsVisibility = useCallback(() => { - // When going from visible → hidden with unsaved changes, prompt to save first if (!annotationsHidden && hasUnsavedChanges) { requestNavigation(() => viewerContext?.toggleAnnotationsVisibility()); } else { @@ -179,7 +167,6 @@ export default function ViewerAnnotationControls({ } }, [annotationsHidden, hasUnsavedChanges, requestNavigation, viewerContext]); - // Don't show any annotation controls in sign mode // NOTE: This early return is placed AFTER all hooks to satisfy React's rules of hooks if (isSignMode) { return null; @@ -187,15 +174,14 @@ export default function ViewerAnnotationControls({ return ( <> - {/* Redaction Mode Toggle */} @@ -203,26 +189,25 @@ export default function ViewerAnnotationControls({ variant={isRedactMode ? "filled" : "subtle"} color={isRedactMode ? "blue" : undefined} radius="md" - className="right-rail-icon" + className="workbench-bar-action-icon" onClick={handleRedactionToggle} disabled={disabled || currentView !== "viewer"} > - {/* Annotation Visibility Toggle */} @@ -230,7 +215,7 @@ export default function ViewerAnnotationControls({ variant={annotationsHidden ? "filled" : "subtle"} color={annotationsHidden ? "blue" : undefined} radius="md" - className="right-rail-icon" + className="workbench-bar-action-icon" onClick={handleToggleAnnotationsVisibility} disabled={ disabled || @@ -247,8 +232,8 @@ export default function ViewerAnnotationControls({ ? "visibility" : "preview-off-rounded" } - width="1.5rem" - height="1.5rem" + width="1.25rem" + height="1.25rem" /> diff --git a/frontend/src/core/components/viewer/nonpdf/NonPdfBanner.tsx b/frontend/src/core/components/viewer/nonpdf/NonPdfBanner.tsx index 91e73c79c..e1f169b85 100644 --- a/frontend/src/core/components/viewer/nonpdf/NonPdfBanner.tsx +++ b/frontend/src/core/components/viewer/nonpdf/NonPdfBanner.tsx @@ -1,59 +1,31 @@ -import { Badge, Box, Button, Group } from "@mantine/core"; +import { Button } from "@mantine/core"; import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; import { useTranslation } from "react-i18next"; -import type { FileTypeMeta } from "@app/components/viewer/nonpdf/types"; - interface NonPdfBannerProps { - meta: FileTypeMeta; onConvertToPdf?: () => void; } -export function NonPdfBanner({ meta, onConvertToPdf }: NonPdfBannerProps) { +export function NonPdfBanner({ onConvertToPdf }: NonPdfBannerProps) { const { t } = useTranslation(); + if (!onConvertToPdf) return null; + return ( - } + onClick={onConvertToPdf} style={{ position: "absolute", top: 8, right: 8, zIndex: 10, - pointerEvents: "auto", }} > - - {meta.icon} - - } - style={{ fontWeight: 600 }} - > - {t("viewer.nonPdf.fileTypeBadge", { type: meta.label })} - - {onConvertToPdf && ( - - )} - + {t("viewer.nonPdf.convertToPdf")} + ); } diff --git a/frontend/src/core/components/viewer/nonpdf/TextViewer.tsx b/frontend/src/core/components/viewer/nonpdf/TextViewer.tsx index 61c1674f3..869d09f9e 100644 --- a/frontend/src/core/components/viewer/nonpdf/TextViewer.tsx +++ b/frontend/src/core/components/viewer/nonpdf/TextViewer.tsx @@ -43,10 +43,13 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) { {/* Toolbar */} diff --git a/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx b/frontend/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx similarity index 83% rename from frontend/src/core/components/viewer/useViewerRightRailButtons.tsx rename to frontend/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx index 7d54437fb..25eacf3d9 100644 --- a/frontend/src/core/components/viewer/useViewerRightRailButtons.tsx +++ b/frontend/src/core/components/viewer/useViewerWorkbenchBarButtons.tsx @@ -4,15 +4,15 @@ import { useTranslation } from "react-i18next"; import { supportedLanguages } from "@app/i18n"; import { useViewer } from "@app/contexts/ViewerContext"; import { - useRightRailButtons, - RightRailButtonWithAction, -} from "@app/hooks/useRightRailButtons"; + useWorkbenchBarButtons, + WorkbenchBarButtonWithAction, +} from "@app/hooks/useWorkbenchBarButtons"; import LocalIcon from "@app/components/shared/LocalIcon"; import { Tooltip } from "@app/components/shared/Tooltip"; import { SearchInterface } from "@app/components/viewer/SearchInterface"; -import ViewerAnnotationControls from "@app/components/shared/rightRail/ViewerAnnotationControls"; +import ViewerAnnotationControls from "@app/components/viewer/ViewerAnnotationControls"; import { useSidebarContext } from "@app/contexts/SidebarContext"; -import { useRightRailTooltipSide } from "@app/hooks/useRightRailTooltipSide"; +import { useWorkbenchBarTooltipSide } from "@app/hooks/useWorkbenchBarTooltipSide"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { useNavigationState, @@ -27,7 +27,7 @@ import VolumeUpIcon from "@mui/icons-material/VolumeUp"; import StopIcon from "@mui/icons-material/Stop"; import { useViewerReadAloud } from "@app/components/viewer/useViewerReadAloud"; -export function useViewerRightRailButtons( +export function useViewerWorkbenchBarButtons( isRulerActive?: boolean, setIsRulerActive?: (v: boolean) => void, ) { @@ -46,7 +46,7 @@ export function useViewerRightRailButtons( } = viewer; const [isPanning, setIsPanning] = useState(false); const { sidebarRefs } = useSidebarContext(); - const { position: tooltipPosition } = useRightRailTooltipSide( + const { position: tooltipPosition } = useWorkbenchBarTooltipSide( sidebarRefs, 12, ); @@ -105,28 +105,27 @@ export function useViewerRightRailButtons( return () => window.removeEventListener("popstate", handlePopState); }, [isAnnotationsPath]); - const searchLabel = t("rightRail.search", "Search PDF"); - const panLabel = t("rightRail.panMode", "Pan Mode"); + const searchLabel = t("workbenchBar.search", "Search PDF"); + const panLabel = t("workbenchBar.panMode", "Pan Mode"); const applyRedactionsLabel = t( - "rightRail.applyRedactionsFirst", + "workbenchBar.applyRedactionsFirst", "Apply redactions first", ); - const rotateLeftLabel = t("rightRail.rotateLeft", "Rotate Left"); - const rotateRightLabel = t("rightRail.rotateRight", "Rotate Right"); - const sidebarLabel = t("rightRail.toggleSidebar", "Toggle Sidebar"); - const bookmarkLabel = t("rightRail.toggleBookmarks", "Toggle Bookmarks"); + const rotateLeftLabel = t("workbenchBar.rotateLeft", "Rotate Left"); + const rotateRightLabel = t("workbenchBar.rotateRight", "Rotate Right"); + const sidebarLabel = t("workbenchBar.toggleSidebar", "Toggle Sidebar"); + const bookmarkLabel = t("workbenchBar.toggleBookmarks", "Toggle Bookmarks"); const attachmentLabel = t( - "rightRail.toggleAttachments", + "workbenchBar.toggleAttachments", "Toggle Attachments", ); - const layersLabel = t("rightRail.toggleLayers", "Toggle Layers"); - const commentsLabel = t("rightRail.toggleComments", "Comments"); - const printLabel = t("rightRail.print", "Print PDF"); - const annotationsLabel = t("rightRail.annotations", "Annotations"); - const formFillLabel = t("rightRail.formFill", "Fill Form"); - const rulerLabel = t("rightRail.ruler", "Ruler / Measure"); - const readAloudLabel = t("rightRail.readAloud", "Read Aloud"); - const readAloudSpeedLabel = t("rightRail.readAloudSpeed", "Speed"); + const layersLabel = t("workbenchBar.toggleLayers", "Toggle Layers"); + const commentsLabel = t("workbenchBar.toggleComments", "Comments"); + const annotationsLabel = t("workbenchBar.annotations", "Annotations"); + const formFillLabel = t("workbenchBar.formFill", "Fill Form"); + const rulerLabel = t("workbenchBar.ruler", "Ruler / Measure"); + const readAloudLabel = t("workbenchBar.readAloud", "Read Aloud"); + const readAloudSpeedLabel = t("workbenchBar.readAloudSpeed", "Speed"); const isFormFillActive = (selectedTool as string) === "formFill"; @@ -150,8 +149,8 @@ export function useViewerRightRailButtons( const shouldShowLanguageSelector = supportedLanguageCodes.size === 0 || filteredLanguages.length > 1; - const viewerButtons = useMemo(() => { - const buttons: RightRailButtonWithAction[] = [ + const viewerButtons = useMemo(() => { + const buttons: WorkbenchBarButtonWithAction[] = [ { id: "viewer-search", tooltip: searchLabel, @@ -179,12 +178,12 @@ export function useViewerRightRailButtons( - +
@@ -203,7 +202,7 @@ export function useViewerRightRailButtons( { id: "viewer-pan-mode", icon: ( - + ), tooltip: !isPanning && pendingCount > 0 && redactionActiveType !== null @@ -220,12 +219,16 @@ export function useViewerRightRailButtons( !isPanning && pendingCount > 0 && redactionActiveType !== null, onClick: () => { viewer.panActions.togglePan(); - setIsPanning((prev) => !prev); + setIsPanning((prev) => { + const next = !prev; + if (next && isRulerActive) setIsRulerActive?.(false); + return next; + }); }, }, { id: "viewer-ruler", - icon: , + icon: , tooltip: rulerLabel, ariaLabel: rulerLabel, section: "top" as const, @@ -236,12 +239,13 @@ export function useViewerRightRailButtons( setIsRulerActive?.(next); if (next && isPanning) { viewer.panActions.disablePan(); + setIsPanning(false); } }, }, { id: "viewer-rotate-left", - icon: , + icon: , tooltip: rotateLeftLabel, ariaLabel: rotateLeftLabel, section: "top" as const, @@ -252,7 +256,9 @@ export function useViewerRightRailButtons( }, { id: "viewer-rotate-right", - icon: , + icon: ( + + ), tooltip: rotateRightLabel, ariaLabel: rotateRightLabel, section: "top" as const, @@ -263,7 +269,7 @@ export function useViewerRightRailButtons( }, { id: "viewer-toggle-sidebar", - icon: , + icon: , tooltip: sidebarLabel, ariaLabel: sidebarLabel, section: "top" as const, @@ -278,8 +284,8 @@ export function useViewerRightRailButtons( icon: ( ), tooltip: bookmarkLabel, @@ -294,7 +300,11 @@ export function useViewerRightRailButtons( { id: "viewer-toggle-attachments", icon: ( - + ), tooltip: attachmentLabel, ariaLabel: attachmentLabel, @@ -309,7 +319,7 @@ export function useViewerRightRailButtons( ? [ { id: "viewer-toggle-layers", - icon: , + icon: , tooltip: layersLabel, ariaLabel: layersLabel, section: "top" as const, @@ -323,7 +333,7 @@ export function useViewerRightRailButtons( : []), { id: "viewer-toggle-comments", - icon: , + icon: , tooltip: commentsLabel, ariaLabel: commentsLabel, section: "top" as const, @@ -333,17 +343,6 @@ export function useViewerRightRailButtons( toggleCommentsSidebar(); }, }, - { - id: "viewer-print", - icon: , - tooltip: printLabel, - ariaLabel: printLabel, - section: "top" as const, - order: 57, - onClick: () => { - viewer.printActions.print(); - }, - }, { id: "viewer-read-aloud", tooltip: readAloudLabel, @@ -373,7 +372,7 @@ export function useViewerRightRailButtons( {isReadingAloud ? ( - + ) : ( - + )} @@ -421,9 +420,9 @@ export function useViewerRightRailButtons( /> {shouldShowLanguageSelector && (