From a3e45bc18252f322d160b28aa992f61dfdbdd7bc Mon Sep 17 00:00:00 2001 From: James Brunton Date: Fri, 10 Apr 2026 17:41:19 +0100 Subject: [PATCH] Add frontend autoformatting and set CI to require formatted code for all languages (#6052) # Description of Changes Changes the strategy for autoformatting to reject PRs if they are not formatted correctly instead of allowing them to merge and then spawning a new PR to fix the formatting. The old strategy just caused more work for us because we'd have to manually approve the followup PR and get it merged, which required 2 reviewers so in practice it rarely got done and just meant everyone's PRs ended up containing reformatting for unrelated files, which makes code review unnecessarily difficult. If the PR's code is not formatted correctly after this PR, a comment will be added automatically to tell the author how to run the formatter script to fix their code so it can go in. This also enables autoformatting for the frontend code, using Prettier. I've enabled it for pretty much everything in the frontend folder, other than 3rd party files and files it doesn't make sense for. I also excluded Markdown because it sounds likely to be more annoying to have to autoformat the Markdown in the frontend folder but nowhere else. Open to changing this though if people disagree. > [!note] > > Advice to reviewers: The first commit contains all of the actual logic I've introduced (CI changes, Prettier config, etc.) > The second commit is just the reformatting of the entire frontend folder. > The first commit needs proper review, the second one just give it a spot-check that it's doing what you'd expect. --- .github/workflows/build.yml | 104 + .github/workflows/pre_commit.yml | 57 +- build.gradle | 4 +- frontend/.prettierignore | 10 + frontend/eslint.config.mjs | 86 +- frontend/index.html | 7 +- frontend/package-lock.json | 17 + frontend/package.json | 5 + frontend/playwright.config.ts | 34 +- frontend/postcss.config.js | 5 +- .../public/css/cookieconsentCustomisation.css | 241 +- frontend/public/manifest-classic.json | 1 - frontend/scripts/build-provisioner.mjs | 24 +- frontend/scripts/generate-icons.js | 60 +- frontend/scripts/generate-licenses.js | 766 +++---- frontend/scripts/sample-pdf/generate.mjs | 51 +- frontend/scripts/sample-pdf/styles.css | 5 +- frontend/scripts/sample-pdf/template.html | 410 ++-- frontend/scripts/setup-env.ts | 41 +- frontend/src-tauri/capabilities/default.json | 4 +- frontend/src-tauri/tauri.conf.json | 170 +- frontend/src/assets/3rdPartyLicenses.json | 650 +++--- frontend/src/core/App.tsx | 4 +- frontend/src/core/components/AppLayout.tsx | 12 +- frontend/src/core/components/AppProviders.tsx | 88 +- frontend/src/core/components/FileManager.tsx | 162 +- .../src/core/components/StorageStatsCard.tsx | 16 +- .../providers/PDFAnnotationProvider.tsx | 16 +- .../annotation/shared/BaseAnnotationTool.tsx | 32 +- .../annotation/shared/ColorControl.tsx | 48 +- .../annotation/shared/ColorPicker.tsx | 52 +- .../annotation/shared/DrawingCanvas.tsx | 116 +- .../annotation/shared/DrawingControls.tsx | 32 +- .../annotation/shared/ImageUploader.tsx | 128 +- .../annotation/shared/OpacityControl.tsx | 34 +- .../annotation/shared/PropertiesPopover.tsx | 89 +- .../annotation/shared/TextInputWithFont.tsx | 69 +- .../annotation/shared/WidthControl.tsx | 34 +- .../annotation/tools/DrawingTool.tsx | 27 +- .../components/annotation/tools/ImageTool.tsx | 27 +- .../components/fileEditor/AddFileCard.tsx | 135 +- .../fileEditor/FileEditor.module.css | 33 +- .../core/components/fileEditor/FileEditor.tsx | 588 ++--- .../fileEditor/FileEditorFileName.tsx | 10 +- .../fileEditor/FileEditorThumbnail.tsx | 548 +++-- .../fileEditor/fileEditorRightRailButtons.tsx | 81 +- .../fileManager/CompactFileDetails.tsx | 102 +- .../components/fileManager/DesktopLayout.tsx | 140 +- .../components/fileManager/DragOverlay.tsx | 26 +- .../fileManager/EmptyFilesState.tsx | 114 +- .../components/fileManager/FileActions.tsx | 20 +- .../components/fileManager/FileDetails.tsx | 40 +- .../fileManager/FileHistoryGroup.tsx | 14 +- .../components/fileManager/FileInfoCard.tsx | 152 +- .../components/fileManager/FileListArea.tsx | 39 +- .../components/fileManager/FileListItem.tsx | 226 +- .../fileManager/FileSourceButtons.tsx | 126 +- .../fileManager/HiddenFileInput.tsx | 6 +- .../components/fileManager/MobileLayout.tsx | 90 +- .../components/fileManager/SearchInput.tsx | 23 +- .../core/components/hotkeys/HotkeyDisplay.tsx | 50 +- .../src/core/components/layout/Workbench.tsx | 160 +- .../InitialOnboardingModal.module.css | 38 +- .../InitialOnboardingModal/renderButtons.tsx | 58 +- .../core/components/onboarding/Onboarding.tsx | 432 ++-- .../onboarding/OnboardingModalSlide.tsx | 73 +- .../onboarding/OnboardingStepper.tsx | 14 +- .../components/onboarding/OnboardingTour.css | 7 +- .../components/onboarding/OnboardingTour.tsx | 59 +- .../components/onboarding/adminStepsConfig.ts | 124 +- .../onboarding/onboardingFlowConfig.ts | 344 ++- .../orchestrator/onboardingConfig.ts | 100 +- .../orchestrator/onboardingStorage.ts | 34 +- .../orchestrator/useOnboardingOrchestrator.ts | 129 +- .../slides/AnalyticsChoiceSlide.tsx | 35 +- .../slides/AnimatedSlideBackground.tsx | 52 +- .../onboarding/slides/DesktopInstallSlide.tsx | 22 +- .../onboarding/slides/DesktopInstallTitle.tsx | 65 +- .../onboarding/slides/FirstLoginSlide.tsx | 88 +- .../onboarding/slides/MFASetupSlide.tsx | 22 +- .../onboarding/slides/PlanOverviewSlide.tsx | 42 +- .../onboarding/slides/SecurityCheckSlide.tsx | 46 +- .../onboarding/slides/ServerLicenseSlide.tsx | 20 +- .../onboarding/slides/TourOverviewSlide.tsx | 19 +- .../onboarding/slides/WelcomeSlide.tsx | 17 +- .../slides/unifiedBackgroundConfig.ts | 11 +- .../core/components/onboarding/tourGlow.ts | 9 +- .../onboarding/useBypassOnboarding.ts | 20 +- .../onboarding/useOnboardingDownload.ts | 49 +- .../onboarding/useOnboardingEffects.ts | 22 +- .../components/onboarding/userStepsConfig.ts | 103 +- .../onboarding/whatsNewStepsConfig.ts | 62 +- .../pageEditor/BulkSelectionPanel.tsx | 24 +- .../components/pageEditor/DragDropGrid.tsx | 455 ++-- .../components/pageEditor/FileThumbnail.tsx | 213 +- .../pageEditor/PageEditor.module.css | 21 +- .../core/components/pageEditor/PageEditor.tsx | 493 +++-- .../pageEditor/PageEditorControls.tsx | 86 +- .../pageEditor/PageSelectByNumberButton.tsx | 12 +- .../components/pageEditor/PageThumbnail.tsx | 492 +++-- .../AdvancedSelectionPanel.tsx | 63 +- .../BulkSelectionPanel.module.css | 55 +- .../bulkSelectionPanel/OperatorsSection.tsx | 74 +- .../bulkSelectionPanel/PageSelectionInput.tsx | 45 +- .../bulkSelectionPanel/SelectPages.tsx | 39 +- .../SelectedPagesDisplay.tsx | 29 +- .../pageEditor/commands/pageCommands.ts | 186 +- .../core/components/pageEditor/constants.ts | 8 +- .../core/components/pageEditor/fileColors.ts | 42 +- .../hooks/useEditedDocumentState.ts | 29 +- .../pageEditor/hooks/useEditorCommands.ts | 106 +- .../pageEditor/hooks/useFileColorMap.ts | 6 +- .../hooks/useInitialPageDocument.ts | 8 +- .../pageEditor/hooks/usePageDocument.ts | 170 +- .../hooks/usePageEditorDropdownState.ts | 45 +- .../pageEditor/hooks/usePageEditorExport.ts | 96 +- .../pageEditor/hooks/usePageEditorState.ts | 34 +- .../hooks/usePageSelectionManager.ts | 18 +- .../pageEditor/hooks/useUndoManagerState.ts | 6 +- .../pageEditor/pageEditorRightRailButtons.tsx | 52 +- .../pageEditor/utils/splitPositions.ts | 2 +- .../components/shared/AllToolsNavButton.tsx | 28 +- .../components/shared/AppConfigLoader.tsx | 6 +- .../core/components/shared/AppConfigModal.css | 2 +- .../core/components/shared/AppConfigModal.tsx | 132 +- frontend/src/core/components/shared/Badge.tsx | 58 +- .../core/components/shared/BulkShareModal.tsx | 147 +- .../shared/BulkUploadToServerModal.tsx | 81 +- .../components/shared/ButtonSelector.test.tsx | 185 +- .../core/components/shared/ButtonSelector.tsx | 54 +- .../core/components/shared/ButtonToggle.tsx | 44 +- .../core/components/shared/CardSelector.tsx | 103 +- .../shared/DismissAllErrorsButton.tsx | 32 +- .../shared/DropdownListWithFooter.tsx | 214 +- .../components/shared/EditableSecretField.tsx | 45 +- .../shared/EncryptedPdfUnlockModal.tsx | 28 +- .../core/components/shared/ErrorBoundary.tsx | 84 +- .../src/core/components/shared/FileCard.tsx | 72 +- .../components/shared/FileDropdownMenu.tsx | 52 +- .../src/core/components/shared/FileGrid.tsx | 92 +- .../components/shared/FilePickerModal.tsx | 111 +- .../core/components/shared/FilePreview.tsx | 83 +- .../components/shared/FileUploadButton.tsx | 14 +- .../components/shared/FirstLoginModal.tsx | 77 +- .../src/core/components/shared/FitText.tsx | 32 +- .../src/core/components/shared/Footer.tsx | 166 +- .../components/shared/HoverActionMenu.tsx | 23 +- .../src/core/components/shared/InfoBanner.tsx | 51 +- .../core/components/shared/LandingActions.tsx | 41 +- .../shared/LandingDocumentStack.tsx | 6 +- .../core/components/shared/LandingPage.tsx | 42 +- .../shared/LanguageSelector.module.css | 10 +- .../components/shared/LanguageSelector.tsx | 187 +- .../src/core/components/shared/LocalIcon.tsx | 22 +- .../components/shared/MobileUploadModal.tsx | 192 +- .../components/shared/MultiSelectControls.tsx | 36 +- .../shared/NavigationWarningModal.tsx | 74 +- .../components/shared/ObscuredOverlay.tsx | 12 +- .../shared/PageEditorFileDropdown.tsx | 143 +- .../shared/PageSelectionSyntaxHint.tsx | 30 +- .../core/components/shared/PrivateContent.tsx | 13 +- .../core/components/shared/QuickAccessBar.tsx | 954 ++++---- .../shared/RainbowThemeProvider.tsx | 33 +- .../src/core/components/shared/RightRail.tsx | 160 +- .../core/components/shared/ShareFileModal.tsx | 133 +- .../shared/ShareManagementModal.tsx | 634 +++--- .../core/components/shared/SkeletonLoader.tsx | 73 +- .../src/core/components/shared/TextInput.tsx | 160 +- .../src/core/components/shared/ToolChain.tsx | 107 +- .../src/core/components/shared/ToolIcon.tsx | 4 +- .../src/core/components/shared/Tooltip.tsx | 140 +- .../core/components/shared/TopControls.tsx | 173 +- .../core/components/shared/UpdateModal.tsx | 172 +- .../components/shared/UploadToServerModal.tsx | 72 +- .../core/components/shared/UserSelector.tsx | 63 +- .../components/shared/ZipWarningModal.tsx | 8 +- .../shared/config/LoginRequiredBanner.tsx | 22 +- .../shared/config/OverviewHeader.tsx | 10 +- .../components/shared/config/PendingBadge.tsx | 10 +- .../config/RestartConfirmationModal.tsx | 43 +- .../shared/config/SettingsSearchBar.tsx | 128 +- .../shared/config/SettingsStickyFooter.tsx | 18 +- .../shared/config/configNavSections.tsx | 52 +- .../config/configSections/GeneralSection.tsx | 6 +- .../config/configSections/HotkeysSection.tsx | 187 +- .../shared/config/configSections/Overview.tsx | 82 +- .../config/configSections/ProviderCard.tsx | 98 +- .../configSections/providerDefinitions.ts | 736 ++++--- .../core/components/shared/config/types.ts | 62 +- .../shared/config/useRestartServer.ts | 55 +- .../shared/filePreview/DocumentStack.tsx | 53 +- .../shared/filePreview/DocumentThumbnail.tsx | 54 +- .../shared/filePreview/HoverOverlay.tsx | 48 +- .../shared/filePreview/NavigationArrows.tsx | 37 +- .../core/components/shared/fitText/textFit.ts | 40 +- .../shared/pageEditor/useFileItemDragDrop.ts | 68 +- .../quickAccessBar/ActiveToolButton.tsx | 68 +- .../shared/quickAccessBar/QuickAccessBar.css | 36 +- .../shared/quickAccessBar/QuickAccessBar.ts | 46 +- .../quickAccessBar/QuickAccessButton.tsx | 65 +- .../shared/quickAccessBar/useToursTooltip.ts | 15 +- .../components/shared/rightRail/RightRail.css | 4 +- .../rightRail/ViewerAnnotationControls.tsx | 96 +- .../shared/signing/ActiveSessionsPanel.tsx | 59 +- .../shared/signing/CompletedSessionsPanel.tsx | 42 +- .../shared/signing/CreateSessionFlow.tsx | 127 +- .../shared/signing/CreateSessionPanel.tsx | 27 +- .../components/shared/signing/SignPopout.tsx | 572 +++-- .../steps/ConfigureSignatureDefaultsStep.tsx | 41 +- .../signing/steps/ReviewSessionStep.tsx | 66 +- .../signing/steps/SelectDocumentStep.tsx | 37 +- .../signing/steps/SelectParticipantsStep.tsx | 23 +- .../sliderWithInput/SliderWithInput.tsx | 10 +- .../shared/tooltip/Tooltip.module.css | 25 +- .../shared/tooltip/TooltipContent.tsx | 69 +- .../wetSignature/DrawSignatureCanvas.tsx | 61 +- .../wetSignature/SignatureTypeSelector.tsx | 24 +- .../shared/wetSignature/TypeSignatureText.tsx | 73 +- .../wetSignature/UploadSignatureImage.tsx | 55 +- .../core/components/toast/ToastContext.tsx | 179 +- .../core/components/toast/ToastRenderer.tsx | 95 +- frontend/src/core/components/toast/index.ts | 15 +- frontend/src/core/components/toast/types.ts | 10 +- .../components/tools/FullscreenToolList.tsx | 102 +- .../tools/FullscreenToolSurface.tsx | 79 +- .../core/components/tools/SearchResults.tsx | 38 +- .../src/core/components/tools/ToolPanel.css | 45 +- .../src/core/components/tools/ToolPanel.tsx | 151 +- .../components/tools/ToolPanelModePrompt.css | 18 +- .../components/tools/ToolPanelModePrompt.tsx | 78 +- .../src/core/components/tools/ToolPicker.tsx | 124 +- .../core/components/tools/ToolRenderer.tsx | 18 +- .../addAttachments/AddAttachmentsSettings.tsx | 55 +- .../AddPageNumbersAppearanceSettings.tsx | 60 +- .../AddPageNumbersAutomationSettings.tsx | 16 +- .../AddPageNumbersPositionSettings.tsx | 32 +- .../PageNumberPreview.module.css | 53 +- .../addPageNumbers/PageNumberPreview.tsx | 107 +- .../useAddPageNumbersOperation.ts | 34 +- .../useAddPageNumbersParameters.ts | 20 +- .../addPassword/AddPasswordSettings.test.tsx | 143 +- .../tools/addPassword/AddPasswordSettings.tsx | 23 +- .../addStamp/AddStampAutomationSettings.tsx | 6 +- .../StampPositionFormattingSettings.tsx | 115 +- .../tools/addStamp/StampPreview.tsx | 168 +- .../tools/addStamp/StampPreviewUtils.ts | 140 +- .../tools/addStamp/StampSetupSettings.tsx | 315 +-- .../tools/addStamp/useAddStampOperation.ts | 51 +- .../tools/addStamp/useAddStampParameters.ts | 40 +- .../AddWatermarkSingleStepSettings.tsx | 30 +- .../addWatermark/WatermarkFormatting.tsx | 26 +- .../tools/addWatermark/WatermarkImageFile.tsx | 4 +- .../addWatermark/WatermarkStyleSettings.tsx | 33 +- .../tools/addWatermark/WatermarkTextStyle.tsx | 1 - .../addWatermark/WatermarkTypeSettings.tsx | 12 +- .../tools/addWatermark/WatermarkWording.tsx | 4 +- .../AdjustContrastBasicSettings.tsx | 31 +- .../AdjustContrastColorSettings.tsx | 31 +- .../adjustContrast/AdjustContrastPreview.tsx | 62 +- .../AdjustContrastSingleStepSettings.tsx | 22 +- .../components/tools/adjustContrast/utils.ts | 48 +- .../AdjustPageScaleSettings.test.tsx | 46 +- .../AdjustPageScaleSettings.tsx | 28 +- .../tools/autoRename/AutoRenameSettings.tsx | 14 +- .../tools/automate/AutomationCreation.tsx | 130 +- .../tools/automate/AutomationEntry.tsx | 107 +- .../tools/automate/AutomationRun.tsx | 55 +- .../tools/automate/AutomationSelection.tsx | 98 +- .../tools/automate/IconSelector.tsx | 5 +- .../tools/automate/ToolConfigurationModal.tsx | 64 +- .../components/tools/automate/ToolList.tsx | 9 +- .../tools/automate/ToolSelector.tsx | 144 +- .../core/components/tools/automate/iconMap.ts | 116 +- .../BookletImpositionSettings.tsx | 98 +- .../certSign/CertSignAutomationSettings.tsx | 28 +- .../certSign/CertificateFilesSettings.tsx | 59 +- .../certSign/CertificateFormatSettings.tsx | 57 +- .../tools/certSign/CertificateSelector.tsx | 74 +- .../certSign/CertificateTypeSettings.tsx | 36 +- .../certSign/SessionDetailWorkbenchView.tsx | 173 +- .../certSign/SignControlsStrip.module.css | 8 +- .../tools/certSign/SignControlsStrip.tsx | 315 ++- .../certSign/SignRequestWorkbenchView.tsx | 185 +- .../certSign/SignatureAppearanceSettings.tsx | 80 +- .../certSign/SignatureSettingsDisplay.tsx | 43 +- .../tools/certSign/SignatureSettingsInput.tsx | 63 +- .../tools/certSign/WetSignatureInput.tsx | 119 +- .../certSign/modals/AddParticipantsFlow.tsx | 39 +- .../modals/CertificateConfigModal.tsx | 144 +- .../certSign/modals/SelectSignatureModal.tsx | 99 +- .../certSign/panels/ParticipantListPanel.tsx | 48 +- .../certSign/panels/SessionActionsPanel.tsx | 43 +- .../certSign/steps/AddSignaturesStep.tsx | 81 +- .../steps/CertificateSelectionStep.tsx | 17 +- .../certSign/steps/ReviewSignatureStep.tsx | 65 +- .../certSign/steps/SignatureCreationStep.tsx | 44 +- .../certSign/steps/SignaturePlacementStep.tsx | 22 +- .../ChangeMetadataSingleStep.tsx | 39 +- .../steps/AdvancedOptionsStep.tsx | 12 +- .../steps/CustomMetadataStep.tsx | 29 +- .../changeMetadata/steps/DeleteAllStep.tsx | 10 +- .../steps/DocumentDatesStep.tsx | 18 +- .../steps/StandardMetadataStep.tsx | 42 +- .../ChangePermissionsSettings.test.tsx | 181 +- .../tools/compare/CompareDocumentPane.tsx | 82 +- .../compare/CompareNavigationDropdown.tsx | 157 +- .../tools/compare/CompareWorkbenchView.tsx | 423 ++-- .../core/components/tools/compare/compare.ts | 52 +- .../components/tools/compare/compareView.css | 22 +- .../hooks/useCompareChangeNavigation.ts | 87 +- .../compare/hooks/useCompareHighlights.ts | 29 +- .../compare/hooks/useComparePagePreviews.ts | 40 +- .../tools/compare/hooks/useComparePanZoom.ts | 318 +-- .../hooks/useCompareRightRailButtons.tsx | 277 ++- .../tools/compress/CompressSettings.tsx | 111 +- .../tools/convert/ConvertFromCbrSettings.tsx | 26 +- .../tools/convert/ConvertFromCbzSettings.tsx | 28 +- .../convert/ConvertFromEbookSettings.tsx | 24 +- .../convert/ConvertFromEmailSettings.tsx | 60 +- .../convert/ConvertFromImageSettings.tsx | 57 +- .../tools/convert/ConvertFromSvgSettings.tsx | 32 +- .../tools/convert/ConvertFromWebSettings.tsx | 40 +- .../tools/convert/ConvertSettings.tsx | 210 +- .../tools/convert/ConvertToCbrSettings.tsx | 13 +- .../tools/convert/ConvertToCbzSettings.tsx | 29 +- .../tools/convert/ConvertToEpubSettings.tsx | 57 +- .../tools/convert/ConvertToImageSettings.tsx | 43 +- .../tools/convert/ConvertToPdfaSettings.tsx | 56 +- .../tools/convert/ConvertToPdfxSettings.tsx | 14 +- .../tools/convert/GroupedFormatDropdown.tsx | 83 +- .../tools/crop/CropAreaSelector.tsx | 238 +- .../tools/crop/CropAutomationSettings.tsx | 4 +- .../tools/crop/CropCoordinateInputs.tsx | 20 +- .../components/tools/crop/CropSettings.tsx | 52 +- .../editTableOfContents/BookmarkEditor.tsx | 125 +- .../EditTableOfContentsSettings.tsx | 97 +- .../EditTableOfContentsWorkbenchView.tsx | 87 +- .../extractImages/ExtractImagesSettings.tsx | 28 +- .../extractPages/ExtractPagesSettings.tsx | 12 +- .../tools/flatten/FlattenSettings.tsx | 22 +- .../tools/fullscreen/CompactToolItem.tsx | 89 +- .../tools/fullscreen/DetailedToolItem.tsx | 53 +- .../components/tools/fullscreen/shared.ts | 85 +- .../tools/getPdfInfo/GetPdfInfoReportView.tsx | 133 +- .../tools/getPdfInfo/GetPdfInfoResults.tsx | 30 +- .../getPdfInfo/sections/ComplianceSection.tsx | 122 +- .../getPdfInfo/sections/KeyValueSection.tsx | 8 +- .../getPdfInfo/sections/OtherSection.tsx | 91 +- .../getPdfInfo/sections/PerPageSection.tsx | 125 +- .../getPdfInfo/sections/SummarySection.tsx | 162 +- .../sections/TableOfContentsSection.tsx | 22 +- .../tools/getPdfInfo/shared/KeyValueList.tsx | 22 +- .../getPdfInfo/shared/ScrollableCodeBlock.tsx | 23 +- .../tools/getPdfInfo/shared/SectionBlock.tsx | 10 +- .../getPdfInfo/shared/accordionStyles.ts | 9 +- .../tools/merge/MergeFileSorter.test.tsx | 110 +- .../tools/merge/MergeFileSorter.tsx | 46 +- .../tools/merge/MergeSettings.test.tsx | 76 +- .../components/tools/merge/MergeSettings.tsx | 22 +- .../tools/ocr/AdvancedOCRSettings.tsx | 38 +- .../tools/ocr/LanguagePicker.module.css | 4 +- .../components/tools/ocr/LanguagePicker.tsx | 104 +- .../core/components/tools/ocr/OCRSettings.tsx | 35 +- .../tools/overlayPdfs/OverlayPdfsSettings.tsx | 152 +- .../tools/pageLayout/LayoutPreview.tsx | 33 +- .../pageLayout/PageLayoutAdvancedSettings.tsx | 38 +- .../PageLayoutMarginsBordersSettings.tsx | 86 +- .../tools/pageLayout/PageLayoutPreview.tsx | 15 +- .../tools/pageLayout/PageLayoutSettings.tsx | 127 +- .../components/tools/pageLayout/constants.ts | 20 +- .../tools/pageLayout/utils/computeBoxes.ts | 14 +- .../tools/pdfTextEditor/FontStatusPanel.tsx | 163 +- .../pdfTextEditor/PdfTextEditorSidebar.tsx | 161 +- .../tools/pdfTextEditor/PdfTextEditorView.tsx | 1917 ++++++++-------- .../tools/redact/ManualRedactionControls.tsx | 35 +- .../redact/RedactAdvancedSettings.test.tsx | 189 +- .../tools/redact/RedactAdvancedSettings.tsx | 20 +- .../tools/redact/RedactModeSelector.tsx | 25 +- .../redact/RedactSingleStepSettings.test.tsx | 153 +- .../tools/redact/RedactSingleStepSettings.tsx | 16 +- .../tools/redact/WordsToRedactInput.test.tsx | 164 +- .../tools/redact/WordsToRedactInput.tsx | 58 +- .../RemoveAnnotationsSettings.tsx | 10 +- .../removeBlanks/RemoveBlanksSettings.tsx | 20 +- .../RemoveCertificateSignSettings.tsx | 13 +- .../tools/removePages/RemovePagesSettings.tsx | 21 +- .../RemovePasswordSettings.test.tsx | 111 +- .../removePassword/RemovePasswordSettings.tsx | 6 +- .../ReorganizePagesSettings.tsx | 55 +- .../tools/reorganizePages/constants.ts | 111 +- .../tools/repair/RepairSettings.tsx | 11 +- .../replaceColor/ReplaceColorSettings.tsx | 64 +- .../tools/rotate/RotateAutomationSettings.tsx | 2 +- .../tools/rotate/RotateSettings.tsx | 47 +- .../tools/sanitize/SanitizeSettings.test.tsx | 129 +- .../tools/sanitize/SanitizeSettings.tsx | 11 +- .../ScannerImageSplitSettings.tsx | 51 +- .../tools/shared/ErrorNotification.tsx | 19 +- .../components/tools/shared/FileMetadata.tsx | 6 +- .../tools/shared/FileStatusIndicator.tsx | 48 +- .../components/tools/shared/FilesToolStep.tsx | 29 +- .../tools/shared/NavigationControls.tsx | 35 +- .../components/tools/shared/NoToolsFound.tsx | 8 +- .../tools/shared/NumberInputWithUnit.tsx | 12 +- .../tools/shared/OperationButton.tsx | 69 +- .../tools/shared/ResultsPreview.tsx | 40 +- .../tools/shared/ReviewToolStep.tsx | 10 +- .../tools/shared/ScopedOperationButton.tsx | 37 +- .../tools/shared/SubcategoryHeader.tsx | 6 +- .../tools/shared/SuggestedToolsSection.tsx | 25 +- .../core/components/tools/shared/ToolStep.tsx | 162 +- .../tools/shared/ToolWorkflowTitle.tsx | 30 +- .../tools/shared/createToolFlow.tsx | 119 +- .../tools/shared/renderToolButtons.tsx | 16 +- .../components/tools/sign/PenSizeSelector.tsx | 10 +- .../tools/sign/SavedSignaturesSection.tsx | 196 +- .../components/tools/sign/SignSettings.tsx | 615 +++--- .../SingleLargePageSettings.tsx | 11 +- .../tools/split/SplitAutomationSettings.tsx | 8 +- .../components/tools/split/SplitSettings.tsx | 90 +- .../timestampPdf/TimestampPdfSettings.tsx | 27 +- .../tools/toolPicker/FavoriteStar.tsx | 16 +- .../tools/toolPicker/ToolButton.tsx | 114 +- .../tools/toolPicker/ToolPicker.css | 2 +- .../tools/toolPicker/ToolSearch.tsx | 26 +- .../unlockPdfForms/UnlockPdfFormsSettings.tsx | 11 +- .../ValidateSignatureReportView.tsx | 54 +- .../ValidateSignatureResults.tsx | 106 +- .../ValidateSignatureSettings.tsx | 38 +- .../reportView/FieldBlock.tsx | 11 +- .../reportView/FileSummaryHeader.tsx | 23 +- .../reportView/SignatureSection.tsx | 62 +- .../reportView/SignatureStatusBadge.tsx | 29 +- .../reportView/ThumbnailPreview.tsx | 18 +- .../PageLayout/usePageLayoutAdvancedTips.ts | 32 +- .../usePageLayoutMarginsBordersTips.ts | 30 +- .../tooltips/PageLayout/usePageLayoutTips.ts | 26 +- .../tooltips/useAddAttachmentsTips.ts | 15 +- .../tooltips/useAddPasswordPermissionsTips.ts | 15 +- .../components/tooltips/useAddPasswordTips.ts | 29 +- .../tooltips/useAdjustPageScaleTips.ts | 29 +- .../components/tooltips/useAdvancedOCRTips.ts | 33 +- .../components/tooltips/useAutoRenameTips.ts | 19 +- .../tooltips/useBookletImpositionTips.ts | 33 +- .../tooltips/useCertSignTooltips.ts | 39 +- .../tooltips/useCertificateChoiceTips.ts | 48 +- .../tooltips/useCertificateTypeTips.ts | 26 +- .../tooltips/useChangeMetadataTips.ts | 57 +- .../tooltips/useChangePermissionsTips.ts | 20 +- .../components/tooltips/useCompressTips.ts | 33 +- .../components/tooltips/useCropTooltips.ts | 15 +- .../tooltips/useExtractPagesTips.ts | 14 +- .../components/tooltips/useFlattenTips.ts | 30 +- .../tooltips/useGroupSigningTips.ts | 51 +- .../core/components/tooltips/useMergeTips.tsx | 24 +- .../core/components/tooltips/useOCRTips.ts | 26 +- .../components/tooltips/useOverlayPdfsTips.ts | 64 +- .../tooltips/usePageSelectionTips.ts | 56 +- .../tooltips/usePageSelectionTips.tsx | 39 +- .../tooltips/usePdfTextEditorTips.ts | 25 +- .../core/components/tooltips/useRedactTips.ts | 95 +- .../tooltips/useRemoveAnnotationsTips.ts | 15 +- .../tooltips/useRemoveBlanksTips.ts | 35 +- .../components/tooltips/useRemovePagesTips.ts | 21 +- .../tooltips/useRemovePasswordTips.ts | 14 +- .../tooltips/useReplaceColorTips.ts | 43 +- .../core/components/tooltips/useRotateTips.ts | 14 +- .../tooltips/useScannerImageSplitTips.ts | 68 +- .../tooltips/useSessionManagementTips.ts | 65 +- .../components/tooltips/useSignModeTips.ts | 43 +- .../tooltips/useSignatureAppearanceTips.ts | 30 +- .../tooltips/useSignatureSettingsTips.ts | 54 +- .../components/tooltips/useSplitMethodTips.ts | 21 +- .../tooltips/useSplitSettingsTips.ts | 128 +- .../components/tooltips/useWatermarkTips.ts | 113 +- .../tooltips/useWetSignatureTips.ts | 42 +- .../viewer/ActiveDocumentContext.tsx | 10 +- .../components/viewer/AnnotationAPIBridge.tsx | 78 +- .../viewer/AnnotationMenuButtons.tsx | 68 +- .../viewer/AnnotationSelectionMenu.tsx | 88 +- .../viewer/AnnotationTypeButtons.tsx | 90 +- .../components/viewer/AttachmentAPIBridge.tsx | 95 +- .../components/viewer/AttachmentSidebar.tsx | 131 +- .../components/viewer/BookmarkAPIBridge.tsx | 26 +- .../components/viewer/BookmarkSidebar.tsx | 174 +- .../components/viewer/CommentsSidebar.tsx | 433 ++-- .../components/viewer/CustomSearchLayer.tsx | 59 +- .../viewer/DocumentPermissionsAPIBridge.tsx | 61 +- .../viewer/DocumentReadyWrapper.tsx | 6 +- .../core/components/viewer/EmbedPdfViewer.tsx | 511 +++-- .../components/viewer/ExportAPIBridge.tsx | 14 +- .../components/viewer/HistoryAPIBridge.tsx | 114 +- .../core/components/viewer/LayerSidebar.css | 4 +- .../core/components/viewer/LayerSidebar.tsx | 110 +- .../src/core/components/viewer/LinkLayer.tsx | 81 +- .../core/components/viewer/LocalEmbedPDF.tsx | 1402 ++++++------ .../viewer/LocalEmbedPDFWithAnnotations.tsx | 1317 +++++------ .../core/components/viewer/NonPdfViewer.tsx | 72 +- .../core/components/viewer/PanAPIBridge.tsx | 20 +- .../components/viewer/PdfViewerToolbar.tsx | 375 ++-- .../core/components/viewer/PrintAPIBridge.tsx | 14 +- .../components/viewer/RedactionAPIBridge.tsx | 76 +- .../viewer/RedactionPendingTracker.tsx | 55 +- .../viewer/RedactionSelectionMenu.tsx | 171 +- .../components/viewer/RotateAPIBridge.tsx | 18 +- .../core/components/viewer/RulerOverlay.tsx | 496 +++-- .../components/viewer/ScrollAPIBridge.tsx | 16 +- .../components/viewer/SearchAPIBridge.tsx | 67 +- .../components/viewer/SearchInterface.tsx | 66 +- .../components/viewer/SelectionAPIBridge.tsx | 46 +- .../components/viewer/SignatureAPIBridge.tsx | 400 ++-- .../viewer/SignatureFieldOverlay.tsx | 107 +- .../viewer/SignaturePlacementOverlay.tsx | 46 +- .../components/viewer/SpreadAPIBridge.tsx | 14 +- .../viewer/StampPlacementOverlay.tsx | 52 +- .../viewer/TextSelectionHandler.tsx | 61 +- .../components/viewer/ThumbnailAPIBridge.tsx | 14 +- .../components/viewer/ThumbnailSidebar.tsx | 167 +- .../src/core/components/viewer/Viewer.tsx | 10 +- .../core/components/viewer/ZoomAPIBridge.tsx | 51 +- .../components/viewer/constants/search.ts | 8 +- .../viewer/hooks/useDocumentReady.ts | 8 +- .../src/core/components/viewer/layerUtils.ts | 52 +- .../components/viewer/nonpdf/CsvViewer.tsx | 100 +- .../components/viewer/nonpdf/HtmlViewer.tsx | 20 +- .../components/viewer/nonpdf/ImageViewer.tsx | 22 +- .../components/viewer/nonpdf/JsonViewer.tsx | 42 +- .../components/viewer/nonpdf/NonPdfBanner.tsx | 24 +- .../components/viewer/nonpdf/TextViewer.tsx | 173 +- .../core/components/viewer/nonpdf/types.ts | 56 +- .../viewer/readAloudHighlight.test.ts | 22 +- .../components/viewer/useActiveDocumentId.ts | 2 +- .../viewer/useAnnotationMenuHandlers.ts | 276 +-- .../viewer/useStopReadAloudOnNavigation.ts | 15 +- .../components/viewer/useViewerReadAloud.ts | 741 ++++--- .../viewer/useViewerRightRailButtons.tsx | 306 +-- .../src/core/components/viewer/viewerTypes.ts | 59 +- frontend/src/core/constants/app.ts | 17 +- frontend/src/core/constants/automation.ts | 42 +- .../src/core/constants/convertConstants.ts | 437 ++-- .../core/constants/convertSupportedFornats.ts | 86 +- frontend/src/core/constants/cropConstants.ts | 3 +- frontend/src/core/constants/downloads.ts | 11 +- frontend/src/core/constants/events.ts | 31 +- frontend/src/core/constants/logo.ts | 9 +- frontend/src/core/constants/routes.ts | 10 +- frontend/src/core/constants/signConstants.ts | 12 +- frontend/src/core/constants/splitConstants.ts | 57 +- frontend/src/core/constants/theme.ts | 6 +- frontend/src/core/constants/toolPanel.ts | 4 +- .../AdminTourOrchestrationContext.tsx | 43 +- .../src/core/contexts/AnnotationContext.tsx | 6 +- .../core/contexts/AppConfigContext.test.tsx | 88 +- .../src/core/contexts/AppConfigContext.tsx | 184 +- frontend/src/core/contexts/BannerContext.tsx | 10 +- .../core/contexts/CommentAuthorContext.tsx | 18 +- frontend/src/core/contexts/FileContext.tsx | 487 +++-- .../src/core/contexts/FileManagerContext.tsx | 1106 +++++----- .../src/core/contexts/FilesModalContext.tsx | 363 ++- frontend/src/core/contexts/HotkeyContext.tsx | 121 +- .../src/core/contexts/IndexedDBContext.tsx | 73 +- .../src/core/contexts/NavigationContext.tsx | 300 +-- .../src/core/contexts/PageEditorContext.tsx | 287 +-- .../src/core/contexts/PreferencesContext.tsx | 28 +- .../src/core/contexts/RedactionContext.tsx | 41 +- .../src/core/contexts/RightRailContext.tsx | 148 +- .../src/core/contexts/SaaSTeamContext.tsx | 2 +- frontend/src/core/contexts/SidebarContext.tsx | 61 +- .../src/core/contexts/SignatureContext.tsx | 22 +- .../src/core/contexts/ToolActionsContext.tsx | 2 +- .../src/core/contexts/ToolRegistryContext.tsx | 8 +- .../core/contexts/ToolRegistryProvider.tsx | 16 +- .../src/core/contexts/ToolWorkflowContext.tsx | 515 ++--- .../contexts/TourOrchestrationContext.tsx | 59 +- .../core/contexts/UnsavedChangesContext.tsx | 17 +- frontend/src/core/contexts/ViewerContext.tsx | 138 +- .../src/core/contexts/file/FileReducer.ts | 135 +- frontend/src/core/contexts/file/contexts.ts | 6 +- .../src/core/contexts/file/fileActions.ts | 484 ++-- frontend/src/core/contexts/file/fileHooks.ts | 205 +- .../src/core/contexts/file/fileSelectors.ts | 41 +- frontend/src/core/contexts/file/lifecycle.ts | 35 +- .../toolWorkflow/toolWorkflowState.ts | 47 +- .../src/core/contexts/viewer/viewerActions.ts | 54 +- .../src/core/contexts/viewer/viewerBridges.ts | 18 +- frontend/src/core/data/toolsTaxonomy.ts | 154 +- .../core/data/useProprietaryToolRegistry.tsx | 2 +- .../core/data/useTranslatedToolRegistry.tsx | 132 +- frontend/src/core/env.test.ts | 36 +- frontend/src/core/extensions/accountLogout.ts | 4 +- .../core/hooks/signing/useSigningSessions.ts | 28 +- .../core/hooks/signing/useSigningWorkbench.ts | 29 +- .../useAddAttachmentsOperation.ts | 16 +- .../useAddAttachmentsParameters.ts | 13 +- .../useAddPasswordOperation.test.ts | 96 +- .../addPassword/useAddPasswordOperation.ts | 18 +- .../useAddPasswordParameters.test.ts | 148 +- .../addPassword/useAddPasswordParameters.ts | 16 +- .../addWatermark/useAddWatermarkOperation.ts | 20 +- .../addWatermark/useAddWatermarkParameters.ts | 19 +- .../useAdjustContrastOperation.ts | 195 +- .../useAdjustContrastParameters.ts | 6 +- .../useAdjustPageScaleOperation.ts | 16 +- .../useAdjustPageScaleParameters.test.ts | 91 +- .../useAdjustPageScaleParameters.ts | 26 +- .../autoRename/useAutoRenameOperation.ts | 23 +- .../autoRename/useAutoRenameParameters.ts | 8 +- .../tools/automate/useAutomateOperation.ts | 75 +- .../hooks/tools/automate/useAutomationForm.ts | 77 +- .../tools/automate/useSavedAutomations.ts | 106 +- .../tools/automate/useSuggestedAutomations.ts | 177 +- .../useBookletImpositionOperation.ts | 21 +- .../useBookletImpositionParameters.ts | 16 +- .../tools/certSign/useCertSignOperation.ts | 52 +- .../tools/certSign/useCertSignParameters.ts | 40 +- .../useChangeMetadataOperation.test.ts | 146 +- .../useChangeMetadataOperation.ts | 30 +- .../useChangeMetadataParameters.test.ts | 120 +- .../useChangeMetadataParameters.ts | 67 +- .../changeMetadata/useMetadataExtraction.ts | 22 +- .../useChangePermissionsOperation.test.ts | 61 +- .../useChangePermissionsOperation.ts | 25 +- .../useChangePermissionsParameters.test.ts | 50 +- .../useChangePermissionsParameters.ts | 6 +- .../hooks/tools/compare/operationUtils.ts | 102 +- .../tools/compare/useCompareOperation.ts | 227 +- .../tools/compare/useCompareParameters.ts | 6 +- .../tools/compress/useCompressOperation.ts | 18 +- .../tools/compress/useCompressParameters.ts | 18 +- .../tools/convert/useConvertOperation.ts | 195 +- .../convert/useConvertParameters.test.ts | 157 +- .../tools/convert/useConvertParameters.ts | 443 ++-- .../useConvertParametersAutoDetection.test.ts | 245 +-- .../core/hooks/tools/crop/useCropOperation.ts | 16 +- .../hooks/tools/crop/useCropParameters.ts | 161 +- .../useEditTableOfContentsOperation.ts | 23 +- .../useEditTableOfContentsParameters.ts | 24 +- .../useExtractImagesOperation.ts | 33 +- .../useExtractImagesParameters.ts | 10 +- .../extractPages/useExtractPagesOperation.ts | 34 +- .../extractPages/useExtractPagesParameters.ts | 12 +- .../tools/flatten/useFlattenOperation.ts | 20 +- .../tools/flatten/useFlattenParameters.ts | 8 +- .../getPdfInfo/useGetPdfInfoOperation.ts | 63 +- .../getPdfInfo/useGetPdfInfoParameters.ts | 8 +- .../tools/merge/useMergeOperation.test.ts | 76 +- .../hooks/tools/merge/useMergeOperation.ts | 18 +- .../tools/merge/useMergeParameters.test.ts | 32 +- .../hooks/tools/merge/useMergeParameters.ts | 6 +- .../core/hooks/tools/ocr/useOCROperation.ts | 107 +- .../core/hooks/tools/ocr/useOCRParameters.ts | 10 +- .../overlayPdfs/useOverlayPdfsOperation.ts | 30 +- .../overlayPdfs/useOverlayPdfsParameters.ts | 20 +- .../pageLayout/usePageLayoutOperation.ts | 48 +- .../pageLayout/usePageLayoutParameters.ts | 36 +- .../tools/redact/useRedactOperation.test.ts | 100 +- .../hooks/tools/redact/useRedactOperation.ts | 24 +- .../tools/redact/useRedactParameters.test.ts | 104 +- .../hooks/tools/redact/useRedactParameters.ts | 22 +- .../useRemoveAnnotationsOperation.ts | 38 +- .../useRemoveAnnotationsParameters.ts | 11 +- .../removeBlanks/useRemoveBlanksOperation.ts | 39 +- .../removeBlanks/useRemoveBlanksParameters.ts | 8 +- .../useRemoveCertificateSignOperation.ts | 19 +- .../useRemoveCertificateSignParameters.ts | 8 +- .../removeImage/useRemoveImageOperation.ts | 20 +- .../removeImage/useRemoveImageParameters.ts | 8 +- .../removePages/useRemovePagesOperation.ts | 22 +- .../removePages/useRemovePagesParameters.ts | 10 +- .../buildRemovePasswordFormData.ts | 2 +- .../useRemovePasswordOperation.test.ts | 73 +- .../useRemovePasswordOperation.ts | 18 +- .../useRemovePasswordParameters.test.ts | 62 +- .../useRemovePasswordParameters.ts | 10 +- .../useReorganizePagesOperation.ts | 26 +- .../useReorganizePagesParameters.ts | 17 +- .../hooks/tools/repair/useRepairOperation.ts | 14 +- .../hooks/tools/repair/useRepairParameters.ts | 8 +- .../replaceColor/useReplaceColorOperation.ts | 30 +- .../replaceColor/useReplaceColorParameters.ts | 20 +- .../tools/rotate/useRotateOperation.test.ts | 85 +- .../hooks/tools/rotate/useRotateOperation.ts | 14 +- .../tools/rotate/useRotateParameters.test.ts | 36 +- .../hooks/tools/rotate/useRotateParameters.ts | 24 +- .../tools/sanitize/useSanitizeOperation.ts | 28 +- .../sanitize/useSanitizeParameters.test.ts | 36 +- .../tools/sanitize/useSanitizeParameters.ts | 8 +- .../useScannerImageSplitOperation.ts | 72 +- .../useScannerImageSplitParameters.ts | 8 +- .../tools/shared/toolOperationHelpers.ts | 19 +- .../hooks/tools/shared/toolOperationTypes.ts | 19 +- .../hooks/tools/shared/useAccordionSteps.ts | 72 +- .../hooks/tools/shared/useBaseParameters.ts | 4 +- .../core/hooks/tools/shared/useBaseTool.ts | 36 +- .../hooks/tools/shared/useOperationResults.ts | 16 +- .../hooks/tools/shared/useToolApiCalls.ts | 165 +- .../hooks/tools/shared/useToolOperation.ts | 754 ++++--- .../hooks/tools/shared/useToolResources.ts | 96 +- .../core/hooks/tools/shared/useToolState.ts | 84 +- .../hooks/tools/shared/useViewScopedFiles.ts | 14 +- .../hooks/tools/showJS/useShowJSOperation.ts | 222 +- .../hooks/tools/showJS/useShowJSParameters.ts | 14 +- .../hooks/tools/sign/useSavedSignatures.ts | 114 +- .../core/hooks/tools/sign/useSignOperation.ts | 38 +- .../hooks/tools/sign/useSignParameters.ts | 26 +- .../useSingleLargePageOperation.ts | 16 +- .../useSingleLargePageParameters.ts | 8 +- .../hooks/tools/split/useSplitOperation.ts | 39 +- .../hooks/tools/split/useSplitParameters.ts | 32 +- .../timestampPdf/useTimestampPdfOperation.ts | 18 +- .../timestampPdf/useTimestampPdfParameters.ts | 16 +- .../useUnlockPdfFormsOperation.ts | 16 +- .../useUnlockPdfFormsParameters.ts | 8 +- .../core/hooks/tools/useFavoriteToolItems.ts | 12 +- .../core/hooks/tools/useToolPanelGeometry.ts | 21 +- .../core/hooks/tools/useUserToolActivity.ts | 19 +- .../CenteredMessageSection.ts | 6 +- .../outputtedPDFSections/FieldBoxSection.ts | 19 +- .../outputtedPDFSections/SignatureSection.ts | 60 +- .../StatusBadgeSection.ts | 2 +- .../outputtedPDFSections/SummarySection.ts | 36 +- .../outputtedPDFSections/ThumbnailSection.ts | 10 +- .../validateSignature/signatureReportPdf.ts | 30 +- .../useValidateSignatureOperation.ts | 87 +- .../useValidateSignatureParameters.ts | 4 +- .../validateSignature/utils/pdfPageHelpers.ts | 22 +- .../validateSignature/utils/pdfPalette.ts | 37 +- .../tools/validateSignature/utils/pdfText.ts | 12 +- .../validateSignature/utils/reportStatus.ts | 18 +- .../validateSignature/utils/signatureCsv.ts | 131 +- .../utils/signatureReportBuilder.ts | 12 +- .../utils/signatureStatus.ts | 48 +- .../validateSignature/utils/signatureUtils.ts | 36 +- frontend/src/core/hooks/useAdminSettings.ts | 27 +- frontend/src/core/hooks/useAuditFilters.ts | 11 +- frontend/src/core/hooks/useBackendHealth.ts | 4 +- frontend/src/core/hooks/useBackendProbe.ts | 38 +- frontend/src/core/hooks/useBaseUrl.ts | 4 +- .../core/hooks/useConversionCloudStatus.ts | 6 +- frontend/src/core/hooks/useCookieConsent.ts | 200 +- frontend/src/core/hooks/useDocumentMeta.ts | 66 +- frontend/src/core/hooks/useEndpointConfig.ts | 58 +- .../core/hooks/useEnhancedProcessedFiles.ts | 46 +- frontend/src/core/hooks/useFileActionIcons.ts | 8 +- .../core/hooks/useFileActionTerminology.ts | 26 +- frontend/src/core/hooks/useFileHandler.ts | 23 +- frontend/src/core/hooks/useFileManager.ts | 494 +++-- frontend/src/core/hooks/useFileWithUrl.ts | 8 +- frontend/src/core/hooks/useFocusTrap.ts | 25 +- frontend/src/core/hooks/useFooterInfo.ts | 8 +- .../src/core/hooks/useGoogleDrivePicker.ts | 18 +- frontend/src/core/hooks/useGroupEnabled.ts | 18 +- .../src/core/hooks/useGroupSigningEnabled.ts | 2 +- .../src/core/hooks/useIndexedDBThumbnail.ts | 15 +- frontend/src/core/hooks/useIsMobile.ts | 6 +- frontend/src/core/hooks/useIsOverflowing.ts | 16 +- frontend/src/core/hooks/useJwtConfigSync.ts | 16 +- frontend/src/core/hooks/useLicenseAlert.ts | 17 +- frontend/src/core/hooks/useLoginRequired.ts | 65 +- frontend/src/core/hooks/useLogoAssets.test.ts | 52 +- frontend/src/core/hooks/useLogoAssets.ts | 13 +- frontend/src/core/hooks/useLogoPath.ts | 8 +- frontend/src/core/hooks/useLogoVariant.ts | 11 +- frontend/src/core/hooks/useOs.ts | 64 +- frontend/src/core/hooks/usePDFProcessor.ts | 137 +- frontend/src/core/hooks/usePdfLibLinks.ts | 22 +- .../core/hooks/usePdfSignatureDetection.ts | 17 +- frontend/src/core/hooks/useProcessedFiles.ts | 36 +- .../core/hooks/useProgressivePagePreviews.ts | 252 ++- frontend/src/core/hooks/useRainbowTheme.ts | 63 +- .../src/core/hooks/useRightRailButtons.ts | 21 +- .../src/core/hooks/useRightRailTooltipSide.ts | 24 +- frontend/src/core/hooks/useScarfTracking.ts | 20 +- .../hooks/useSelfHostedToolAvailability.ts | 4 +- .../src/core/hooks/useServerExperience.ts | 138 +- frontend/src/core/hooks/useSettingsDirty.ts | 8 +- frontend/src/core/hooks/useSharingEnabled.ts | 2 +- .../core/hooks/useShouldShowWelcomeModal.ts | 14 +- .../src/core/hooks/useSidebarNavigation.ts | 33 +- frontend/src/core/hooks/useSuggestedTools.ts | 66 +- .../src/core/hooks/useThumbnailGeneration.ts | 70 +- frontend/src/core/hooks/useToolManagement.tsx | 140 +- frontend/src/core/hooks/useToolNavigation.ts | 49 +- frontend/src/core/hooks/useToolParameters.ts | 20 +- frontend/src/core/hooks/useToolSections.ts | 34 +- frontend/src/core/hooks/useTooltipPosition.ts | 45 +- frontend/src/core/hooks/useTranslation.ts | 22 +- frontend/src/core/hooks/useUndoRedo.ts | 24 +- frontend/src/core/hooks/useUrlSync.ts | 82 +- .../src/core/hooks/useViewerKeyCommand.ts | 4 +- frontend/src/core/hooks/useWheelZoom.ts | 6 +- frontend/src/core/hooks/useZipConfirmation.ts | 8 +- frontend/src/core/i18n.ts | 153 +- frontend/src/core/i18n/config.ts | 70 +- frontend/src/core/i18n/tomlBackend.ts | 17 +- frontend/src/core/pages/HomePage.css | 4 +- frontend/src/core/pages/HomePage.tsx | 111 +- frontend/src/core/pages/MobileScannerPage.tsx | 542 ++--- frontend/src/core/services/accountService.ts | 36 +- frontend/src/core/services/apiClient.ts | 13 +- frontend/src/core/services/apiClientConfig.ts | 2 +- frontend/src/core/services/apiClientSetup.ts | 8 +- frontend/src/core/services/auditService.ts | 29 +- .../src/core/services/automationStorage.ts | 63 +- .../services/documentManipulationService.ts | 55 +- frontend/src/core/services/downloadService.ts | 6 +- .../services/enhancedPDFProcessingService.ts | 131 +- frontend/src/core/services/errorUtils.ts | 20 +- frontend/src/core/services/fileAnalyzer.ts | 66 +- .../src/core/services/fileDialogService.ts | 4 +- .../core/services/fileProcessingService.ts | 35 +- frontend/src/core/services/fileStorage.ts | 75 +- frontend/src/core/services/fileStubHelpers.ts | 14 +- .../core/services/googleDrivePickerService.ts | 61 +- .../src/core/services/httpErrorHandler.ts | 46 +- frontend/src/core/services/httpErrorUtils.ts | 50 +- .../src/core/services/indexedDBManager.ts | 77 +- .../src/core/services/localFileSaveService.ts | 12 +- .../src/core/services/openFilesFromDisk.ts | 8 +- .../services/operationResultsSaveService.ts | 8 +- .../src/core/services/pdfExportHelpers.ts | 23 +- .../src/core/services/pdfExportService.ts | 100 +- .../src/core/services/pdfMetadataService.ts | 69 +- .../src/core/services/pdfProcessingService.ts | 44 +- .../src/core/services/pdfWorkerManager.ts | 55 +- .../src/core/services/pdfiumDocBuilder.ts | 65 +- frontend/src/core/services/pdfiumService.ts | 429 ++-- .../src/core/services/preferencesService.ts | 37 +- frontend/src/core/services/processingCache.ts | 55 +- .../core/services/processingErrorHandler.ts | 188 +- .../src/core/services/serverStorageBundle.ts | 42 +- .../src/core/services/serverStorageUpload.ts | 62 +- .../src/core/services/shareBundleUtils.ts | 39 +- .../services/signatureDetectionService.ts | 54 +- .../core/services/signatureStorageService.ts | 46 +- .../src/core/services/specialErrorToasts.ts | 32 +- frontend/src/core/services/supabaseClient.ts | 10 +- .../services/thumbnailGenerationService.ts | 48 +- frontend/src/core/services/updateService.ts | 80 +- .../core/services/usageAnalyticsService.ts | 21 +- frontend/src/core/services/zipFileService.ts | 240 +- frontend/src/core/setupTests.js | 2 +- frontend/src/core/setupTests.ts | 30 +- frontend/src/core/styles/cookieconsent.css | 2 +- frontend/src/core/styles/index.css | 20 +- frontend/src/core/styles/rainbow.module.css | 217 +- frontend/src/core/styles/tailwind.css | 3 +- frontend/src/core/styles/theme.css | 444 ++-- frontend/src/core/styles/zIndex.ts | 2 - .../testing/serverExperienceSimulations.ts | 39 +- .../CertificateValidationE2E.spec.ts | 176 +- .../src/core/tests/convert/ConvertE2E.spec.ts | 132 +- .../tests/convert/ConvertIntegration.test.tsx | 665 +++--- .../ConvertSmartDetectionIntegration.test.tsx | 394 ++-- .../core/tests/missingTranslations.test.ts | 78 +- .../src/core/tests/test-fixtures/sample.htm | 232 +- .../src/core/tests/test-fixtures/sample.html | 182 +- frontend/src/core/tests/translation.test.ts | 33 +- .../core/tests/translationStructure.test.ts | 37 +- .../src/core/tests/utils/testFileHelpers.ts | 16 +- frontend/src/core/theme/mantineTheme.ts | 328 +-- frontend/src/core/tools/AddAttachments.tsx | 14 +- frontend/src/core/tools/AddImage.tsx | 12 +- frontend/src/core/tools/AddPageNumbers.tsx | 18 +- frontend/src/core/tools/AddPassword.tsx | 2 - frontend/src/core/tools/AddStamp.tsx | 51 +- frontend/src/core/tools/AddText.tsx | 12 +- frontend/src/core/tools/AddWatermark.tsx | 1 - frontend/src/core/tools/AdjustContrast.tsx | 66 +- frontend/src/core/tools/AdjustPageScale.tsx | 7 +- frontend/src/core/tools/Annotate.tsx | 332 +-- frontend/src/core/tools/AutoRename.tsx | 11 +- frontend/src/core/tools/Automate.tsx | 94 +- frontend/src/core/tools/BookletImposition.tsx | 9 +- frontend/src/core/tools/CertSign.tsx | 87 +- frontend/src/core/tools/ChangeMetadata.tsx | 27 +- frontend/src/core/tools/ChangePermissions.tsx | 7 +- frontend/src/core/tools/Compare.tsx | 380 ++-- frontend/src/core/tools/Compress.tsx | 8 +- frontend/src/core/tools/Convert.tsx | 12 +- frontend/src/core/tools/Crop.tsx | 14 +- .../src/core/tools/EditTableOfContents.tsx | 183 +- frontend/src/core/tools/ExtractImages.tsx | 9 +- frontend/src/core/tools/ExtractPages.tsx | 9 +- frontend/src/core/tools/Flatten.tsx | 7 +- frontend/src/core/tools/GetPdfInfo.tsx | 89 +- frontend/src/core/tools/Merge.tsx | 87 +- frontend/src/core/tools/OverlayPdfs.tsx | 33 +- frontend/src/core/tools/PageLayout.tsx | 61 +- frontend/src/core/tools/Redact.tsx | 75 +- frontend/src/core/tools/RemoveAnnotations.tsx | 9 +- frontend/src/core/tools/RemoveBlanks.tsx | 9 +- .../src/core/tools/RemoveCertificateSign.tsx | 4 +- frontend/src/core/tools/RemoveImage.tsx | 9 +- frontend/src/core/tools/RemovePages.tsx | 8 +- frontend/src/core/tools/RemovePassword.tsx | 7 +- frontend/src/core/tools/ReorganizePages.tsx | 18 +- frontend/src/core/tools/Repair.tsx | 7 +- frontend/src/core/tools/ReplaceColor.tsx | 9 +- frontend/src/core/tools/Rotate.tsx | 14 +- frontend/src/core/tools/Sanitize.tsx | 7 +- frontend/src/core/tools/ScannerImageSplit.tsx | 9 +- frontend/src/core/tools/ShowJS.tsx | 276 +-- frontend/src/core/tools/Sign.tsx | 12 +- frontend/src/core/tools/SingleLargePage.tsx | 7 +- frontend/src/core/tools/Split.tsx | 15 +- frontend/src/core/tools/TimestampPdf.tsx | 12 +- frontend/src/core/tools/UnlockPdfForms.tsx | 7 +- frontend/src/core/tools/ValidateSignature.tsx | 69 +- .../core/tools/annotate/AnnotationPanel.tsx | 505 +++-- .../tools/annotate/useAnnotationSelection.ts | 125 +- .../tools/annotate/useAnnotationStyleState.ts | 104 +- .../formFill/ButtonAppearanceOverlay.tsx | 69 +- .../src/core/tools/formFill/FieldInput.tsx | 72 +- .../core/tools/formFill/FormFieldOverlay.tsx | 353 +-- .../core/tools/formFill/FormFieldSidebar.tsx | 115 +- .../core/tools/formFill/FormFill.module.css | 2 +- frontend/src/core/tools/formFill/FormFill.tsx | 232 +- .../core/tools/formFill/FormFillContext.tsx | 259 +-- .../src/core/tools/formFill/FormSaveBar.tsx | 56 +- .../src/core/tools/formFill/fieldMeta.tsx | 44 +- frontend/src/core/tools/formFill/formApi.ts | 63 +- frontend/src/core/tools/formFill/index.ts | 22 +- .../formFill/providers/PdfBoxFormProvider.ts | 17 +- .../formFill/providers/PdfiumFormProvider.ts | 149 +- .../core/tools/formFill/providers/index.ts | 6 +- .../core/tools/formFill/providers/types.ts | 8 +- frontend/src/core/tools/formFill/types.ts | 23 +- .../tools/pdfTextEditor/PdfTextEditor.tsx | 912 ++++---- .../core/tools/pdfTextEditor/fontAnalysis.ts | 228 +- .../tools/pdfTextEditor/pdfTextEditorTypes.ts | 6 +- .../tools/pdfTextEditor/pdfTextEditorUtils.ts | 232 +- .../src/core/tools/stamp/createStampTool.tsx | 82 +- frontend/src/core/tsconfig.json | 12 +- frontend/src/core/types/appConfig.ts | 4 +- frontend/src/core/types/automation.ts | 11 +- frontend/src/core/types/backendHealth.ts | 2 +- frontend/src/core/types/compare.ts | 48 +- .../src/core/types/endpointAvailability.ts | 2 +- frontend/src/core/types/file.ts | 2 +- frontend/src/core/types/fileContext.ts | 128 +- frontend/src/core/types/fileIdSafety.d.ts | 4 +- frontend/src/core/types/getPdfInfo.ts | 66 +- frontend/src/core/types/metadata.ts | 6 +- frontend/src/core/types/navigation.ts | 5 +- frontend/src/core/types/navigationActions.ts | 6 +- frontend/src/core/types/pageEditor.ts | 12 +- frontend/src/core/types/parameters.ts | 2 +- frontend/src/core/types/processing.ts | 10 +- frontend/src/core/types/proprietaryToolId.ts | 6 +- frontend/src/core/types/rightRail.ts | 64 +- frontend/src/core/types/sidebar.ts | 8 +- frontend/src/core/types/signature.ts | 10 +- frontend/src/core/types/signingSession.ts | 8 +- frontend/src/core/types/tips.ts | 2 +- frontend/src/core/types/tool.ts | 11 +- frontend/src/core/types/toolId.ts | 161 +- frontend/src/core/types/types.ts | 4 +- frontend/src/core/types/workbench.ts | 8 +- .../src/core/utils/automationConverter.ts | 39 +- frontend/src/core/utils/automationExecutor.ts | 98 +- .../src/core/utils/automationFileProcessor.ts | 64 +- frontend/src/core/utils/browserIdentifier.ts | 10 +- .../bulkselection/parseSelection.test.ts | 242 +- .../utils/bulkselection/parseSelection.ts | 77 +- .../utils/bulkselection/selectionBuilders.ts | 66 +- frontend/src/core/utils/clickHandlers.ts | 8 +- frontend/src/core/utils/convertUtils.test.ts | 485 ++--- frontend/src/core/utils/convertUtils.ts | 54 +- frontend/src/core/utils/cropCoordinates.ts | 66 +- frontend/src/core/utils/downloadUtils.ts | 29 +- .../src/core/utils/editTableOfContents.ts | 14 +- frontend/src/core/utils/fileDialogUtils.ts | 10 +- frontend/src/core/utils/fileHash.ts | 39 +- frontend/src/core/utils/fileHistoryUtils.ts | 14 +- frontend/src/core/utils/fileIdSafety.ts | 4 +- .../src/core/utils/fileResponseUtils.test.ts | 151 +- frontend/src/core/utils/fileResponseUtils.ts | 14 +- frontend/src/core/utils/fileUtils.test.ts | 82 +- frontend/src/core/utils/fileUtils.ts | 78 +- frontend/src/core/utils/fuzzySearch.ts | 34 +- frontend/src/core/utils/genericUtils.ts | 2 +- .../src/core/utils/homePageNavigation.test.ts | 80 +- frontend/src/core/utils/homePageNavigation.ts | 16 +- frontend/src/core/utils/hotkeys.ts | 108 +- frontend/src/core/utils/imageToPdfUtils.ts | 98 +- frontend/src/core/utils/imageTransparency.ts | 48 +- frontend/src/core/utils/languageMapping.ts | 1054 +++++---- frontend/src/core/utils/pageMetadata.ts | 32 +- frontend/src/core/utils/pageSelection.ts | 13 +- frontend/src/core/utils/pdfLinkUtils.ts | 73 +- frontend/src/core/utils/pdfiumBitmapUtils.ts | 49 +- frontend/src/core/utils/resourceManager.ts | 18 +- frontend/src/core/utils/scarfTracking.ts | 11 +- frontend/src/core/utils/scriptLoader.ts | 2 +- frontend/src/core/utils/settingsNavigation.ts | 10 +- .../src/core/utils/settingsPendingHelper.ts | 33 +- frontend/src/core/utils/sidebarUtils.ts | 14 +- .../src/core/utils/signatureFlattening.ts | 112 +- frontend/src/core/utils/signaturePreview.ts | 26 +- frontend/src/core/utils/storageUtils.ts | 36 +- frontend/src/core/utils/textDiff.ts | 18 +- frontend/src/core/utils/textUtils.ts | 9 +- frontend/src/core/utils/thumbnailUtils.ts | 383 +++- frontend/src/core/utils/toolErrorHandler.ts | 47 +- .../src/core/utils/toolResponseProcessor.ts | 22 +- frontend/src/core/utils/toolSearch.ts | 16 +- frontend/src/core/utils/urlMapping.ts | 214 +- frontend/src/core/utils/urlRouting.ts | 42 +- frontend/src/core/utils/viewerZoom.ts | 24 +- frontend/src/core/workers/compareWorker.ts | 94 +- frontend/src/desktop/auth/supabase.ts | 26 +- .../src/desktop/components/AppProviders.tsx | 124 +- .../components/BackendHealthIndicator.tsx | 59 +- .../desktop/components/ConnectionSettings.tsx | 61 +- .../components/DesktopBannerInitializer.tsx | 12 +- .../desktop/components/DesktopConfigSync.tsx | 8 +- .../components/DesktopOnboardingModal.tsx | 88 +- .../components/SaveShortcutListener.tsx | 4 +- .../SetupWizard/DesktopAuthLayout.tsx | 39 +- .../SetupWizard/DesktopOAuthButtons.tsx | 73 +- .../SetupWizard/SaaSLoginScreen.tsx | 54 +- .../SetupWizard/SaaSSignupScreen.tsx | 34 +- .../components/SetupWizard/SelfHostedLink.tsx | 17 +- .../SetupWizard/SelfHostedLoginScreen.tsx | 53 +- .../SetupWizard/ServerSelection.tsx | 141 +- .../SetupWizard/ServerSelectionScreen.tsx | 24 +- .../desktop/components/SetupWizard/index.tsx | 241 +- .../src/desktop/components/SignInModal.tsx | 14 +- .../fileEditor/FileEditorFileName.tsx | 58 +- .../orchestrator/onboardingConfig.ts | 17 +- .../orchestrator/useOnboardingOrchestrator.ts | 6 +- .../QuickAccessBarFooterExtensions.tsx | 40 +- .../rightRail/RightRailFooterExtensions.tsx | 68 +- .../desktop/components/shared/CloudBadge.tsx | 15 +- .../components/shared/DefaultAppBanner.tsx | 12 +- .../shared/DisabledButtonWithTooltip.tsx | 12 +- .../shared/SelfHostedOfflineBanner.tsx | 127 +- .../shared/TeamInvitationBanner.tsx | 53 +- .../shared/billing/SaaSStripeCheckout.tsx | 107 +- .../shared/config/configNavSections.tsx | 91 +- .../configSections/DefaultAppSettings.tsx | 22 +- .../config/configSections/GeneralSection.tsx | 8 +- .../configSections/SaaSTeamsSection.tsx | 238 +- .../config/configSections/SaasPlanSection.tsx | 76 +- .../plan/ActiveSubscriptionCard.tsx | 84 +- .../configSections/plan/PlanUpgradeCard.tsx | 38 +- .../plan/SaaSAvailablePlansSection.tsx | 26 +- .../configSections/plan/SaasPlanCard.tsx | 120 +- .../configSections/plan/UsageDisplay.tsx | 49 +- .../desktop/components/shared/config/types.ts | 10 +- .../shared/modals/CreditExhaustedModal.tsx | 226 +- .../shared/modals/CreditModalBootstrap.tsx | 29 +- .../shared/modals/CreditUsageBanner.tsx | 12 +- .../shared/modals/FeatureListItem.tsx | 22 +- .../modals/InsufficientCreditsModal.tsx | 80 +- .../tools/toolPicker/ToolButton.tsx | 22 +- .../toolPicker/ToolPickerFooterExtensions.tsx | 20 +- .../components/viewer/PrintAPIBridge.tsx | 20 +- frontend/src/desktop/config/billing.ts | 32 +- .../src/desktop/config/defaultAppConfig.ts | 2 +- frontend/src/desktop/config/planFeatures.ts | 74 +- .../src/desktop/constants/backendErrors.ts | 16 +- frontend/src/desktop/constants/connection.ts | 2 +- .../src/desktop/constants/creditEvents.ts | 8 +- .../src/desktop/constants/signInEvents.ts | 2 +- .../desktop/contexts/SaaSCheckoutContext.tsx | 23 +- .../src/desktop/contexts/SaaSTeamContext.tsx | 148 +- .../desktop/contexts/SaasBillingContext.tsx | 134 +- .../src/desktop/extensions/accountLogout.ts | 10 +- .../src/desktop/extensions/authCallback.ts | 6 +- .../desktop/extensions/authSessionCleanup.ts | 14 +- .../desktop/extensions/cookieConsentConfig.ts | 2 +- .../src/desktop/extensions/oauthNavigation.ts | 6 +- .../extensions/platformSessionBridge.ts | 16 +- .../src/desktop/hooks/useAppInitialization.ts | 26 +- .../src/desktop/hooks/useBackendHealth.ts | 29 +- .../desktop/hooks/useBackendInitializer.ts | 12 +- .../desktop/hooks/useConversionCloudStatus.ts | 39 +- frontend/src/desktop/hooks/useCreditCheck.ts | 82 +- frontend/src/desktop/hooks/useCreditEvents.ts | 10 +- frontend/src/desktop/hooks/useDefaultApp.ts | 34 +- .../desktop/hooks/useEnableMeteredBilling.ts | 20 +- .../src/desktop/hooks/useEndpointConfig.ts | 153 +- frontend/src/desktop/hooks/useExitWarning.ts | 59 +- .../src/desktop/hooks/useFileActionIcons.ts | 10 +- .../desktop/hooks/useFileActionTerminology.ts | 26 +- .../src/desktop/hooks/useFirstLaunchCheck.ts | 8 +- frontend/src/desktop/hooks/useGroupEnabled.ts | 20 +- .../desktop/hooks/useGroupSigningEnabled.ts | 4 +- frontend/src/desktop/hooks/useOpenedFile.ts | 18 +- frontend/src/desktop/hooks/useSaaSMode.ts | 8 +- frontend/src/desktop/hooks/useSaaSPlans.ts | 86 +- frontend/src/desktop/hooks/useSaveShortcut.ts | 25 +- .../src/desktop/hooks/useSelfHostedAuth.ts | 14 +- .../hooks/useSelfHostedToolAvailability.ts | 26 +- .../src/desktop/hooks/useSharingEnabled.ts | 6 +- .../src/desktop/hooks/useToolCloudStatus.ts | 14 +- .../src/desktop/hooks/useViewerKeyCommand.ts | 29 +- frontend/src/desktop/hooks/useWillUseCloud.ts | 10 +- frontend/src/desktop/routes/Landing.tsx | 2 +- frontend/src/desktop/routes/Login.tsx | 2 +- .../src/desktop/routes/login/LoginHeader.tsx | 20 +- frontend/src/desktop/services/apiClient.ts | 14 +- .../src/desktop/services/apiClientConfig.ts | 6 +- .../src/desktop/services/apiClientSetup.ts | 72 +- frontend/src/desktop/services/authService.ts | 513 +++-- .../src/desktop/services/authTokenStore.ts | 10 +- .../desktop/services/backendHealthMonitor.ts | 37 +- .../desktop/services/backendReadinessGuard.ts | 31 +- .../desktop/services/connectionModeService.ts | 325 +-- .../src/desktop/services/defaultAppService.ts | 24 +- .../services/desktopNotificationService.ts | 47 +- .../src/desktop/services/downloadService.ts | 6 +- .../services/endpointAvailabilityService.ts | 63 +- .../src/desktop/services/fileDialogService.ts | 28 +- .../src/desktop/services/fileOpenService.ts | 46 +- .../src/desktop/services/httpErrorHandler.ts | 2 +- .../desktop/services/localFileSaveService.ts | 30 +- .../desktop/services/nativePrintService.ts | 5 +- .../services/operationResultsSaveService.ts | 12 +- .../src/desktop/services/operationRouter.ts | 128 +- .../desktop/services/saasBillingService.ts | 141 +- .../desktop/services/saasErrorInterceptor.ts | 10 +- .../services/selfHostedServerMonitor.ts | 26 +- .../desktop/services/tauriBackendService.ts | 96 +- .../src/desktop/services/tauriHttpClient.ts | 180 +- frontend/src/desktop/tsconfig.json | 23 +- frontend/src/desktop/types/billing.ts | 20 +- .../src/desktop/utils/oauthCallbackHtml.ts | 24 +- frontend/src/global.d.ts | 8 +- frontend/src/index.tsx | 44 +- frontend/src/proprietary/App.tsx | 4 +- frontend/src/proprietary/auth/UseSession.tsx | 125 +- frontend/src/proprietary/auth/oauthStorage.ts | 12 +- frontend/src/proprietary/auth/oauthTypes.ts | 18 +- .../proprietary/auth/springAuthClient.test.ts | 222 +- .../src/proprietary/auth/springAuthClient.ts | 248 ++- .../proprietary/components/AppProviders.tsx | 5 +- .../shared/ChangeUserPasswordModal.tsx | 141 +- .../components/shared/DividerWithText.tsx | 41 +- .../components/shared/InviteMembersModal.tsx | 338 +-- .../components/shared/LoginRightCarousel.tsx | 124 +- .../components/shared/ManageBillingButton.tsx | 33 +- .../components/shared/UpdateSeatsButton.tsx | 25 +- .../components/shared/UpdateSeatsModal.tsx | 89 +- .../components/shared/UpgradeBanner.tsx | 157 +- .../shared/UpgradeBannerInitializer.tsx | 7 +- .../config/EnterpriseRequiredBanner.tsx | 18 +- .../shared/config/OverviewHeader.tsx | 20 +- .../shared/config/configNavSections.tsx | 356 +-- .../config/configSections/AccountSection.tsx | 275 +-- .../configSections/AdminAdvancedSection.tsx | 1937 +++++++++-------- .../configSections/AdminAuditSection.tsx | 102 +- .../AdminConnectionsSection.tsx | 636 +++--- .../configSections/AdminDatabaseSection.tsx | 843 ++++--- .../configSections/AdminEndpointsSection.tsx | 481 ++-- .../configSections/AdminFeaturesSection.tsx | 346 +-- .../configSections/AdminGeneralSection.tsx | 1381 ++++++------ .../configSections/AdminLegalSection.tsx | 265 ++- .../configSections/AdminMailSection.tsx | 321 +-- .../configSections/AdminPlanSection.tsx | 101 +- .../configSections/AdminPremiumSection.tsx | 209 +- .../configSections/AdminPrivacySection.tsx | 302 +-- .../configSections/AdminSecuritySection.tsx | 1722 ++++++++------- .../AdminStorageSharingSection.tsx | 480 ++-- .../configSections/AdminUsageSection.tsx | 174 +- .../shared/config/configSections/ApiKeys.tsx | 48 +- .../config/configSections/PeopleSection.tsx | 478 ++-- .../configSections/TeamDetailsSection.tsx | 392 ++-- .../config/configSections/TeamsSection.tsx | 355 +-- .../configSections/apiKeys/ApiKeySection.tsx | 56 +- .../configSections/apiKeys/RefreshModal.tsx | 26 +- .../configSections/apiKeys/hooks/useApiKey.ts | 54 +- .../audit/AuditChartsSection.tsx | 119 +- .../audit/AuditClearDataSection.tsx | 95 +- .../configSections/audit/AuditEventsTable.tsx | 407 ++-- .../audit/AuditExportSection.tsx | 165 +- .../configSections/audit/AuditFiltersForm.tsx | 76 +- .../configSections/audit/AuditStatsCards.tsx | 77 +- .../audit/AuditSystemStatus.tsx | 52 +- .../plan/AvailablePlansSection.tsx | 44 +- .../plan/FeatureComparisonTable.tsx | 53 +- .../configSections/plan/LicenseKeySection.tsx | 150 +- .../config/configSections/plan/PlanCard.tsx | 165 +- .../plan/StaticCheckoutModal.tsx | 208 +- .../configSections/plan/StaticPlanSection.tsx | 207 +- .../usage/UsageAnalyticsChart.tsx | 32 +- .../usage/UsageAnalyticsTable.tsx | 46 +- .../dividerWithText/DividerWithText.css | 1 - .../components/shared/loginSlides.ts | 38 +- .../shared/stripeCheckout/StripeCheckout.tsx | 126 +- .../components/PriceDisplay.tsx | 34 +- .../components/PricingBadge.tsx | 14 +- .../hooks/useCheckoutNavigation.ts | 27 +- .../hooks/useCheckoutSession.ts | 64 +- .../stripeCheckout/hooks/useCheckoutState.ts | 36 +- .../stripeCheckout/hooks/useLicensePolling.ts | 53 +- .../components/shared/stripeCheckout/index.ts | 6 +- .../stripeCheckout/stages/EmailStage.tsx | 31 +- .../stripeCheckout/stages/ErrorStage.tsx | 10 +- .../stripeCheckout/stages/PaymentStage.tsx | 27 +- .../stages/PlanSelectionStage.tsx | 90 +- .../stripeCheckout/stages/SuccessStage.tsx | 68 +- .../shared/stripeCheckout/types/checkout.ts | 8 +- .../shared/stripeCheckout/utils/cardStyles.ts | 22 +- .../stripeCheckout/utils/checkoutUtils.ts | 36 +- .../stripeCheckout/utils/pricingUtils.ts | 10 +- .../stripeCheckout/utils/savingsCalculator.ts | 17 +- .../components/workflow/ParticipantView.tsx | 158 +- .../proprietary/constants/planConstants.ts | 152 +- .../constants/staticStripeLinks.ts | 10 +- .../proprietary/contexts/CheckoutContext.tsx | 200 +- .../proprietary/contexts/LicenseContext.tsx | 44 +- .../contexts/ServerExperienceContext.tsx | 141 +- .../contexts/UpdateSeatsContext.tsx | 169 +- .../proprietary/extensions/accountLogout.ts | 4 +- frontend/src/proprietary/hooks/usePlans.ts | 13 +- .../proprietary/hooks/useRequestHeaders.ts | 4 +- .../proprietary/hooks/useServerExperience.ts | 3 +- .../hooks/useShouldShowWelcomeModal.ts | 12 +- .../hooks/workflow/useParticipantSession.ts | 74 +- .../routes/AuthCallback.module.css | 2 +- .../proprietary/routes/AuthCallback.test.tsx | 100 +- .../src/proprietary/routes/AuthCallback.tsx | 48 +- .../src/proprietary/routes/InviteAccept.tsx | 106 +- frontend/src/proprietary/routes/Landing.tsx | 82 +- .../src/proprietary/routes/Login.test.tsx | 389 ++-- frontend/src/proprietary/routes/Login.tsx | 281 +-- .../proprietary/routes/ShareLinkLoader.tsx | 102 +- .../src/proprietary/routes/ShareLinkPage.tsx | 167 +- frontend/src/proprietary/routes/Signup.tsx | 70 +- .../routes/authShared/AuthLayout.tsx | 45 +- .../proprietary/routes/authShared/auth.css | 16 +- .../routes/login/EmailPasswordForm.tsx | 76 +- .../proprietary/routes/login/ErrorMessage.tsx | 2 +- .../routes/login/LoggedInState.tsx | 72 +- .../proprietary/routes/login/LoginHeader.tsx | 15 +- .../routes/login/NavigationLink.tsx | 15 +- .../routes/login/OAuthButtons.test.tsx | 248 +-- .../proprietary/routes/login/OAuthButtons.tsx | 122 +- .../proprietary/routes/signup/AuthService.ts | 34 +- .../proprietary/routes/signup/SignupForm.tsx | 82 +- .../routes/signup/SignupFormValidation.ts | 38 +- .../proprietary/services/apiClientSetup.ts | 68 +- .../proprietary/services/licenseService.ts | 240 +- .../proprietary/services/shareLinkImport.ts | 47 +- .../src/proprietary/services/teamService.ts | 34 +- .../services/userManagementService.ts | 91 +- .../proprietary/services/workflowService.ts | 46 +- .../src/proprietary/styles/auth-theme.css | 4 +- .../testing/serverExperienceSimulations.ts | 44 +- frontend/src/proprietary/tsconfig.json | 18 +- frontend/src/proprietary/types/license.ts | 2 +- .../proprietary/types/proprietaryToolId.ts | 15 +- frontend/src/proprietary/utils/creditCosts.ts | 2 +- .../proprietary/utils/currencyDetection.ts | 102 +- .../proprietary/utils/licenseCheckoutUtils.ts | 84 +- .../src/proprietary/utils/planTierUtils.ts | 15 +- .../proprietary/utils/protocolDetection.ts | 12 +- frontend/src/proprietary/utils/urlMapping.ts | 4 +- frontend/src/prototypes/App.tsx | 4 +- .../prototypes/components/AppProviders.tsx | 9 +- .../components/chat/ChatContext.tsx | 107 +- .../prototypes/components/chat/ChatPanel.tsx | 18 +- frontend/src/prototypes/tsconfig.json | 23 +- frontend/src/reportWebVitals.js | 4 +- frontend/src/saas/App.tsx | 40 +- frontend/src/saas/auth/UseSession.tsx | 833 +++---- frontend/src/saas/auth/supabase.ts | 153 +- .../saas/components/OnboardingBootstrap.tsx | 36 +- .../saas/components/TrialExpiredBootstrap.tsx | 40 +- .../saas/components/auth/GuestUserBanner.css | 6 +- .../saas/components/auth/GuestUserBanner.tsx | 82 +- .../src/saas/components/auth/RequireAuth.tsx | 32 +- .../components/feedback/UserbackWidget.tsx | 15 +- .../components/home/HomePageExtensions.tsx | 2 +- .../onboarding/SaasOnboardingModal.tsx | 74 +- .../components/onboarding/renderButtons.tsx | 46 +- .../components/onboarding/saasFlowResolver.ts | 19 +- .../onboarding/saasOnboardingFlowConfig.ts | 120 +- .../onboarding/slides/FreeTrialSlide.tsx | 74 +- .../onboarding/useSaasOnboardingState.ts | 68 +- .../saas/components/shared/AppConfigModal.tsx | 248 ++- .../src/saas/components/shared/InfoBanner.tsx | 76 +- .../components/shared/ManageBillingButton.tsx | 29 +- .../saas/components/shared/PrivateContent.tsx | 13 +- .../components/shared/StripeCheckoutSaas.tsx | 97 +- .../components/shared/TrialExpiredModal.tsx | 107 +- .../components/shared/TrialStatusBanner.tsx | 46 +- .../shared/charts/StackedBarChart.tsx | 284 +-- .../stackedBarChart/StackedBarTooltip.tsx | 34 +- .../components/shared/charts/utils/d3Utils.ts | 65 +- .../shared/charts/utils/themeUtils.ts | 39 +- .../shared/charts/utils/tooltipUtils.ts | 35 +- .../shared/config/ProfilePictureCropper.tsx | 73 +- .../shared/config/configSections/ApiKeys.tsx | 92 +- .../shared/config/configSections/Overview.tsx | 292 +-- .../configSections/PasswordSecurity.tsx | 80 +- .../shared/config/configSections/Plan.tsx | 153 +- .../configSections/apiKeys/UsageSection.tsx | 74 +- .../configSections/apiKeys/hooks/useApiKey.ts | 2 - .../apiKeys/hooks/useCredits.ts | 43 +- .../configSections/plan/ActivePlanSection.tsx | 51 +- .../plan/ApiPackagesSection.tsx | 37 +- .../plan/AvailablePlansSection.tsx | 72 +- .../config/configSections/plan/PlanCard.tsx | 37 +- .../shared/config/saasConfigNavSections.tsx | 87 +- .../saas/components/shared/config/types.ts | 118 +- .../src/saas/components/shared/utils/date.ts | 2 - .../components/tools/sign/SignSettings.tsx | 629 +++--- frontend/src/saas/constants/app.ts | 2 +- frontend/src/saas/constants/authProviders.ts | 18 +- .../src/saas/hooks/useAutoAnonymousAuth.ts | 137 +- .../src/saas/hooks/useConfigButtonIcon.tsx | 4 +- frontend/src/saas/hooks/useCreditCheck.ts | 65 +- frontend/src/saas/hooks/useCredits.ts | 27 +- frontend/src/saas/hooks/useEndpointConfig.ts | 58 +- frontend/src/saas/hooks/usePlans.ts | 248 +-- frontend/src/saas/routes/AuthCallback.tsx | 191 +- frontend/src/saas/routes/Landing.tsx | 55 +- frontend/src/saas/routes/Login.tsx | 256 +-- frontend/src/saas/routes/ResetPassword.tsx | 221 +- frontend/src/saas/routes/Signup.tsx | 214 +- .../src/saas/routes/authShared/AuthLayout.tsx | 51 +- .../routes/authShared/GuestSignInButton.tsx | 16 +- .../src/saas/routes/authShared/saas-auth.css | 4 +- .../saas/routes/login/EmailPasswordForm.tsx | 60 +- .../src/saas/routes/login/LoadingState.tsx | 28 +- .../src/saas/routes/login/MagicLinkForm.tsx | 44 +- .../src/saas/routes/login/OAuthButtons.tsx | 95 +- .../src/saas/routes/login/SuccessMessage.tsx | 6 +- .../src/saas/routes/signup/AuthService.ts | 51 +- frontend/src/saas/services/accountDeletion.ts | 6 +- frontend/src/saas/services/apiClient.test.ts | 82 +- frontend/src/saas/services/apiClient.ts | 144 +- .../src/saas/services/avatarSyncService.ts | 272 ++- .../saas/services/signatureStorageService.ts | 24 +- .../saas/services/userManagementService.ts | 80 +- frontend/src/saas/services/userService.ts | 18 +- frontend/src/saas/setupTests.ts | 119 +- frontend/src/saas/styles/saas-theme.css | 84 +- frontend/src/saas/styles/zIndex.ts | 5 +- frontend/src/saas/tsconfig.json | 22 +- frontend/src/saas/types/charts.ts | 4 +- frontend/src/saas/types/credits.ts | 6 +- frontend/src/saas/types/stripe.ts | 16 +- frontend/src/saas/utils/appSettings.ts | 10 +- frontend/src/saas/utils/cropImage.ts | 21 +- frontend/src/saas/utils/pathUtils.ts | 32 +- frontend/tailwind.config.js | 107 +- frontend/tsconfig.core.vite.json | 10 +- frontend/tsconfig.desktop.vite.json | 6 +- frontend/tsconfig.json | 34 +- frontend/tsconfig.proprietary.vite.json | 12 +- frontend/tsconfig.prototypes.vite.json | 6 +- frontend/tsconfig.saas.vite.json | 6 +- frontend/vite.config.ts | 138 +- frontend/vitest.config.ts | 108 +- 1359 files changed, 57785 insertions(+), 57461 deletions(-) create mode 100644 frontend/.prettierignore diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e431fd5f7f..643ef13fd0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -50,6 +50,7 @@ jobs: permissions: actions: read security-events: write + pull-requests: write strategy: fail-fast: false matrix: @@ -84,6 +85,60 @@ jobs: gradle-version: 9.3.1 cache-disabled: true + - name: Check Java formatting (Spotless) + if: matrix.jdk-version == 25 && matrix.spring-security == false + id: spotless-check + run: ./gradlew spotlessCheck + continue-on-error: true + env: + MAVEN_USER: ${{ secrets.MAVEN_USER }} + MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} + MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} + + - name: Comment on Java formatting failure + if: steps.spotless-check.outcome == 'failure' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const marker = ''; + const body = [ + marker, + '### Java Formatting Check Failed', + '', + 'Your code has formatting issues. Run the following command to fix them:', + '', + '```bash', + './gradlew spotlessApply', + '```', + '', + 'Then commit and push the changes.', + ].join('\n'); + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find(c => c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + - name: Fail if Java formatting issues found + if: steps.spotless-check.outcome == 'failure' + run: exit 1 + - name: Build with Gradle and spring security ${{ matrix.spring-security }} run: ./gradlew build -PnoSpotless env: @@ -187,6 +242,9 @@ jobs: if: needs.files-changed.outputs.frontend == 'true' needs: files-changed runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: - name: Harden Runner uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 @@ -202,6 +260,52 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Install frontend dependencies run: cd frontend && npm ci + - name: Check TypeScript formatting (Prettier) + id: prettier-check + run: cd frontend && npm run format:check + continue-on-error: true + - name: Comment on TypeScript formatting failure + if: steps.prettier-check.outcome == 'failure' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const marker = ''; + const body = [ + marker, + '### TypeScript Formatting Check Failed', + '', + 'Your code has formatting issues. Run the following command to fix them:', + '', + '```bash', + 'cd frontend && npm run fix', + '```', + '', + 'Then commit and push the changes.', + ].join('\n'); + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find(c => c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + - name: Fail if TypeScript formatting issues found + if: steps.prettier-check.outcome == 'failure' + run: exit 1 - name: Type-check frontend run: cd frontend && npm run prep && npm run typecheck:all - name: Lint frontend diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml index 764f748d44..9e4d5d574f 100644 --- a/.github/workflows/pre_commit.yml +++ b/.github/workflows/pre_commit.yml @@ -2,7 +2,7 @@ name: Pre-commit on: workflow_dispatch: - push: + pull_request: branches: - main @@ -16,9 +16,6 @@ jobs: # Prevents sdist builds → no tar extraction PIP_ONLY_BINARY: ":all:" PIP_DISABLE_PIP_VERSION_CHECK: "1" - permissions: - contents: write - pull-requests: write steps: - name: Harden Runner uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1 @@ -31,13 +28,6 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Setup GitHub App Bot - id: setup-bot - uses: ./.github/actions/setup-bot - with: - app-id: ${{ secrets.GH_APP_ID }} - private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -57,47 +47,4 @@ jobs: pre-commit run gitleaks --all-files -c .pre-commit-config.yaml pre-commit run end-of-file-fixer --all-files -c .pre-commit-config.yaml pre-commit run trailing-whitespace --all-files -c .pre-commit-config.yaml - continue-on-error: true - - - name: Set up JDK 25 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 - with: - java-version: "25" - distribution: "temurin" - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1 - with: - gradle-version: 9.3.1 - - - name: Build with Gradle - run: ./gradlew build - env: - MAVEN_USER: ${{ secrets.MAVEN_USER }} - MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} - MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }} - - - name: git add - run: | - git add . - git diff --staged --quiet || echo "CHANGES_DETECTED=true" >> $GITHUB_ENV - - - name: Create Pull Request - if: env.CHANGES_DETECTED == 'true' - uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 - with: - token: ${{ steps.setup-bot.outputs.token }} - commit-message: ":file_folder: pre-commit" - committer: ${{ steps.setup-bot.outputs.committer }} - author: ${{ steps.setup-bot.outputs.committer }} - signoff: true - branch: pre-commit - title: "🤖 format everything with pre-commit by ${{ steps.setup-bot.outputs.app-slug }}" - body: | - Auto-generated by [create-pull-request][1] with **${{ steps.setup-bot.outputs.app-slug }}** - - [1]: https://github.com/peter-evans/create-pull-request - draft: false - delete-branch: true - labels: github-actions - sign-commits: true + git diff --exit-code diff --git a/build.gradle b/build.gradle index 62e92d9484..0721c37baa 100644 --- a/build.gradle +++ b/build.gradle @@ -110,11 +110,11 @@ tasks.register('syncAppVersion') { [new File(sim1Path), new File(sim2Path)].each { f -> if (f.exists()) { def content = f.getText('UTF-8') - def matcher = (content =~ /(appVersion:\s*')([^']*)(')/) + def matcher = (content =~ /(appVersion:\s*(['"]))(.*?)(\2)/) if (!matcher.find()) { throw new GradleException("Could not locate appVersion in ${f} for synchronization") } - def updatedContent = matcher.replaceFirst("${matcher.group(1)}${appVersionStr}${matcher.group(3)}") + def updatedContent = matcher.replaceFirst("${matcher.group(1)}${appVersionStr}${matcher.group(4)}") if (content != updatedContent) { f.write(updatedContent, 'UTF-8') } diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000000..ad5dab21a1 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,10 @@ +dist/ +node_modules/ +public/vendor/ +public/pdfjs*/ +public/js/thirdParty/ +public/css/cookieconsent.css +*.min.* +*.md +*.wxs +src/output.css diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 9bbe913d60..de728c2775 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -1,62 +1,52 @@ // @ts-check -import eslint from '@eslint/js'; -import globals from 'globals'; -import { defineConfig } from 'eslint/config'; -import tseslint from 'typescript-eslint'; +import eslint from "@eslint/js"; +import globals from "globals"; +import { defineConfig } from "eslint/config"; +import tseslint from "typescript-eslint"; -const srcGlobs = [ - 'src/**/*.{js,mjs,jsx,ts,tsx}', -]; -const nodeGlobs = [ - 'scripts/**/*.{js,ts,mjs}', - '*.config.{js,ts,mjs}', -]; +const srcGlobs = ["src/**/*.{js,mjs,jsx,ts,tsx}"]; +const nodeGlobs = ["scripts/**/*.{js,ts,mjs}", "*.config.{js,ts,mjs}"]; const baseRestrictedImportPatterns = [ - { regex: '^\\.', message: "Use @app/* imports instead of relative imports." }, - { regex: '^src/', message: "Use @app/* imports instead of absolute src/ imports." }, + { regex: "^\\.", message: "Use @app/* imports instead of relative imports." }, + { regex: "^src/", message: "Use @app/* imports instead of absolute src/ imports." }, ]; export default defineConfig( { // Everything that contains 3rd party code that we don't want to lint - ignores: [ - 'dist', - 'node_modules', - 'public', - 'src-tauri', - ], + ignores: ["dist", "node_modules", "public", "src-tauri"], }, eslint.configs.recommended, tseslint.configs.recommended, { rules: { - 'no-restricted-imports': [ - 'error', + "no-restricted-imports": [ + "error", { patterns: baseRestrictedImportPatterns, }, ], - '@typescript-eslint/no-empty-object-type': [ - 'error', + "@typescript-eslint/no-empty-object-type": [ + "error", { // Allow empty extending interfaces because there's no real reason not to, and it makes it obvious where to put extra attributes in the future - allowInterfaces: 'with-single-extends', + allowInterfaces: "with-single-extends", }, ], - '@typescript-eslint/no-explicit-any': 'off', // Temporarily disabled until codebase conformant - '@typescript-eslint/no-require-imports': 'off', // Temporarily disabled until codebase conformant - '@typescript-eslint/no-unused-vars': [ - 'error', + "@typescript-eslint/no-explicit-any": "off", // Temporarily disabled until codebase conformant + "@typescript-eslint/no-require-imports": "off", // Temporarily disabled until codebase conformant + "@typescript-eslint/no-unused-vars": [ + "error", { - 'args': 'all', // All function args must be used (or explicitly ignored) - 'argsIgnorePattern': '^_', // Allow unused variables beginning with an underscore - 'caughtErrors': 'all', // Caught errors must be used (or explicitly ignored) - 'caughtErrorsIgnorePattern': '^_', // Allow unused variables beginning with an underscore - 'destructuredArrayIgnorePattern': '^_', // Allow unused variables beginning with an underscore - 'varsIgnorePattern': '^_', // Allow unused variables beginning with an underscore - 'ignoreRestSiblings': true, // Allow unused variables when removing attributes from objects (otherwise this requires explicit renaming like `({ x: _x, ...y }) => y`, which is clunky) + args: "all", // All function args must be used (or explicitly ignored) + argsIgnorePattern: "^_", // Allow unused variables beginning with an underscore + caughtErrors: "all", // Caught errors must be used (or explicitly ignored) + caughtErrorsIgnorePattern: "^_", // Allow unused variables beginning with an underscore + destructuredArrayIgnorePattern: "^_", // Allow unused variables beginning with an underscore + varsIgnorePattern: "^_", // Allow unused variables beginning with an underscore + ignoreRestSiblings: true, // Allow unused variables when removing attributes from objects (otherwise this requires explicit renaming like `({ x: _x, ...y }) => y`, which is clunky) }, ], }, @@ -65,15 +55,15 @@ export default defineConfig( // Use the stub/shadow pattern instead: define a stub in src/core/ and override in src/desktop/. { files: srcGlobs, - ignores: ['src/desktop/**'], + ignores: ["src/desktop/**"], rules: { - 'no-restricted-imports': [ - 'error', + "no-restricted-imports": [ + "error", { patterns: [ ...baseRestrictedImportPatterns, { - regex: '^@tauri-apps/', + regex: "^@tauri-apps/", message: "Tauri APIs are desktop-only. Review frontend/DeveloperGuide.md for structure advice.", }, ], @@ -84,9 +74,9 @@ export default defineConfig( // Folders that have been cleaned up and are now conformant - stricter rules enforced here { files: [ - 'src/proprietary/**/*.{js,mjs,jsx,ts,tsx}', - 'src/saas/**/*.{js,mjs,jsx,ts,tsx}', - 'src/prototypes/**/*.{js,mjs,jsx,ts,tsx}', + "src/proprietary/**/*.{js,mjs,jsx,ts,tsx}", + "src/saas/**/*.{js,mjs,jsx,ts,tsx}", + "src/prototypes/**/*.{js,mjs,jsx,ts,tsx}", ], languageOptions: { parserOptions: { @@ -95,8 +85,8 @@ export default defineConfig( }, }, rules: { - '@typescript-eslint/no-explicit-any': 'error', - '@typescript-eslint/no-unnecessary-type-assertion': 'error', + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-unnecessary-type-assertion": "error", }, }, // Config for browser scripts @@ -105,8 +95,8 @@ export default defineConfig( languageOptions: { globals: { ...globals.browser, - } - } + }, + }, }, // Config for node scripts { @@ -114,7 +104,7 @@ export default defineConfig( languageOptions: { globals: { ...globals.node, - } - } + }, + }, }, ); diff --git a/frontend/index.html b/frontend/index.html index c790b8d1ed..465841285d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,4 +1,4 @@ - + @@ -6,10 +6,7 @@ - + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dbf6bbd484..d6a59cbed7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -114,6 +114,7 @@ "postcss-cli": "^11.0.1", "postcss-preset-mantine": "^1.18.0", "postcss-simple-vars": "^7.0.1", + "prettier": "^3.8.1", "puppeteer": "^24.25.0", "tsx": "^4.21.0", "typescript": "^5.9.2", @@ -11308,6 +11309,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 93051e2dc7..167fd7181e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -89,8 +89,12 @@ "dev:saas": "npm run prep:saas && vite --mode saas", "dev:desktop": "npm run prep:desktop && vite --mode desktop", "dev:prototypes": "npm run prep && vite --mode prototypes", + "fix": "npm run format && npm run lint:fix", + "format": "prettier --write .", + "format:check": "prettier --check .", "lint": "npm run lint:eslint && npm run lint:cycles", "lint:eslint": "eslint --max-warnings=0", + "lint:fix": "eslint --fix", "lint:cycles": "dpdm src --circular --no-warning --no-tree --exit-code circular:1", "build": "npm run prep && vite build", "build:core": "npm run prep && vite build --mode core", @@ -176,6 +180,7 @@ "postcss-cli": "^11.0.1", "postcss-preset-mantine": "^1.18.0", "postcss-simple-vars": "^7.0.1", + "prettier": "^3.8.1", "puppeteer": "^24.25.0", "tsx": "^4.21.0", "typescript": "^5.9.2", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 14eb25f85c..deb6a63977 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -1,11 +1,11 @@ -import { defineConfig, devices } from '@playwright/test'; +import { defineConfig, devices } from "@playwright/test"; /** * @see https://playwright.dev/docs/test-configuration */ export default defineConfig({ - testDir: './src/core/tests', - testMatch: '**/*.spec.ts', + testDir: "./src/core/tests", + testMatch: "**/*.spec.ts", /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ @@ -15,34 +15,34 @@ export default defineConfig({ /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', + reporter: "html", /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: 'http://localhost:5173', + baseURL: "http://localhost:5173", /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'on-first-retry', + trace: "on-first-retry", }, /* Configure projects for major browsers */ projects: [ { - name: 'chromium', - use: { - ...devices['Desktop Chrome'], - viewport: { width: 1920, height: 1080 } + name: "chromium", + use: { + ...devices["Desktop Chrome"], + viewport: { width: 1920, height: 1080 }, }, }, { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, + name: "firefox", + use: { ...devices["Desktop Firefox"] }, }, { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, + name: "webkit", + use: { ...devices["Desktop Safari"] }, }, /* Test against mobile viewports. */ @@ -68,8 +68,8 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { - command: 'npm run dev', - url: 'http://localhost:5173', + command: "npm run dev", + url: "http://localhost:5173", reuseExistingServer: !process.env.CI, }, -}); \ No newline at end of file +}); diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js index 57e730c99a..7b8895cce8 100644 --- a/frontend/postcss.config.js +++ b/frontend/postcss.config.js @@ -1,6 +1,3 @@ module.exports = { - plugins: [ - require('@tailwindcss/postcss'), - require('autoprefixer'), - ], + plugins: [require("@tailwindcss/postcss"), require("autoprefixer")], }; diff --git a/frontend/public/css/cookieconsentCustomisation.css b/frontend/public/css/cookieconsentCustomisation.css index ec360c20be..fd1a8ff355 100644 --- a/frontend/public/css/cookieconsentCustomisation.css +++ b/frontend/public/css/cookieconsentCustomisation.css @@ -1,206 +1,205 @@ /* Light theme variables */ :root { - --cc-bg: #ffffff; - --cc-primary-color: #1c1c1c; - --cc-secondary-color: #666666; + --cc-bg: #ffffff; + --cc-primary-color: #1c1c1c; + --cc-secondary-color: #666666; - --cc-btn-primary-bg: #007BFF; - --cc-btn-primary-color: #ffffff; - --cc-btn-primary-border-color: #007BFF; - --cc-btn-primary-hover-bg: #0056b3; - --cc-btn-primary-hover-color: #ffffff; - --cc-btn-primary-hover-border-color: #0056b3; + --cc-btn-primary-bg: #007bff; + --cc-btn-primary-color: #ffffff; + --cc-btn-primary-border-color: #007bff; + --cc-btn-primary-hover-bg: #0056b3; + --cc-btn-primary-hover-color: #ffffff; + --cc-btn-primary-hover-border-color: #0056b3; - --cc-btn-secondary-bg: #f1f3f4; - --cc-btn-secondary-color: #1c1c1c; - --cc-btn-secondary-border-color: #f1f3f4; - --cc-btn-secondary-hover-bg: #007BFF; - --cc-btn-secondary-hover-color: #ffffff; - --cc-btn-secondary-hover-border-color: #007BFF; + --cc-btn-secondary-bg: #f1f3f4; + --cc-btn-secondary-color: #1c1c1c; + --cc-btn-secondary-border-color: #f1f3f4; + --cc-btn-secondary-hover-bg: #007bff; + --cc-btn-secondary-hover-color: #ffffff; + --cc-btn-secondary-hover-border-color: #007bff; - --cc-separator-border-color: #e0e0e0; + --cc-separator-border-color: #e0e0e0; - --cc-toggle-on-bg: #007BFF; - --cc-toggle-off-bg: #667481; - --cc-toggle-on-knob-bg: #ffffff; - --cc-toggle-off-knob-bg: #ffffff; + --cc-toggle-on-bg: #007bff; + --cc-toggle-off-bg: #667481; + --cc-toggle-on-knob-bg: #ffffff; + --cc-toggle-off-knob-bg: #ffffff; - --cc-toggle-enabled-icon-color: #ffffff; - --cc-toggle-disabled-icon-color: #ffffff; + --cc-toggle-enabled-icon-color: #ffffff; + --cc-toggle-disabled-icon-color: #ffffff; - --cc-toggle-readonly-bg: #f1f3f4; - --cc-toggle-readonly-knob-bg: #79747E; - --cc-toggle-readonly-knob-icon-color: #f1f3f4; + --cc-toggle-readonly-bg: #f1f3f4; + --cc-toggle-readonly-knob-bg: #79747e; + --cc-toggle-readonly-knob-icon-color: #f1f3f4; - --cc-section-category-border: #e0e0e0; + --cc-section-category-border: #e0e0e0; - --cc-cookie-category-block-bg: #f1f3f4; - --cc-cookie-category-block-border: #f1f3f4; - --cc-cookie-category-block-hover-bg: #e9eff4; - --cc-cookie-category-block-hover-border: #e9eff4; - - --cc-cookie-category-expanded-block-bg: #f1f3f4; - --cc-cookie-category-expanded-block-hover-bg: #e9eff4; + --cc-cookie-category-block-bg: #f1f3f4; + --cc-cookie-category-block-border: #f1f3f4; + --cc-cookie-category-block-hover-bg: #e9eff4; + --cc-cookie-category-block-hover-border: #e9eff4; - --cc-footer-bg: #ffffff; - --cc-footer-color: #1c1c1c; - --cc-footer-border-color: #ffffff; + --cc-cookie-category-expanded-block-bg: #f1f3f4; + --cc-cookie-category-expanded-block-hover-bg: #e9eff4; + + --cc-footer-bg: #ffffff; + --cc-footer-color: #1c1c1c; + --cc-footer-border-color: #ffffff; } /* Dark theme variables */ -.cc--darkmode{ - --cc-bg: #2d2d2d; - --cc-primary-color: #e5e5e5; - --cc-secondary-color: #b0b0b0; +.cc--darkmode { + --cc-bg: #2d2d2d; + --cc-primary-color: #e5e5e5; + --cc-secondary-color: #b0b0b0; - --cc-btn-primary-bg: #4dabf7; - --cc-btn-primary-color: #ffffff; - --cc-btn-primary-border-color: #4dabf7; - --cc-btn-primary-hover-bg: #3d3d3d; - --cc-btn-primary-hover-color: #ffffff; - --cc-btn-primary-hover-border-color: #3d3d3d; + --cc-btn-primary-bg: #4dabf7; + --cc-btn-primary-color: #ffffff; + --cc-btn-primary-border-color: #4dabf7; + --cc-btn-primary-hover-bg: #3d3d3d; + --cc-btn-primary-hover-color: #ffffff; + --cc-btn-primary-hover-border-color: #3d3d3d; - --cc-btn-secondary-bg: #3d3d3d; - --cc-btn-secondary-color: #ffffff; - --cc-btn-secondary-border-color: #3d3d3d; - --cc-btn-secondary-hover-bg: #4dabf7; - --cc-btn-secondary-hover-color: #ffffff; - --cc-btn-secondary-hover-border-color: #4dabf7; + --cc-btn-secondary-bg: #3d3d3d; + --cc-btn-secondary-color: #ffffff; + --cc-btn-secondary-border-color: #3d3d3d; + --cc-btn-secondary-hover-bg: #4dabf7; + --cc-btn-secondary-hover-color: #ffffff; + --cc-btn-secondary-hover-border-color: #4dabf7; - --cc-separator-border-color: #555555; + --cc-separator-border-color: #555555; - --cc-toggle-on-bg: #4dabf7; - --cc-toggle-off-bg: #667481; - --cc-toggle-on-knob-bg: #2d2d2d; - --cc-toggle-off-knob-bg: #2d2d2d; + --cc-toggle-on-bg: #4dabf7; + --cc-toggle-off-bg: #667481; + --cc-toggle-on-knob-bg: #2d2d2d; + --cc-toggle-off-knob-bg: #2d2d2d; - --cc-toggle-enabled-icon-color: #2d2d2d; - --cc-toggle-disabled-icon-color: #2d2d2d; + --cc-toggle-enabled-icon-color: #2d2d2d; + --cc-toggle-disabled-icon-color: #2d2d2d; - --cc-toggle-readonly-bg: #555555; - --cc-toggle-readonly-knob-bg: #8e8e8e; - --cc-toggle-readonly-knob-icon-color: #555555; + --cc-toggle-readonly-bg: #555555; + --cc-toggle-readonly-knob-bg: #8e8e8e; + --cc-toggle-readonly-knob-icon-color: #555555; - --cc-section-category-border: #555555; + --cc-section-category-border: #555555; - --cc-cookie-category-block-bg: #3d3d3d; - --cc-cookie-category-block-border: #3d3d3d; - --cc-cookie-category-block-hover-bg: #4d4d4d; - --cc-cookie-category-block-hover-border: #4d4d4d; - - --cc-cookie-category-expanded-block-bg: #3d3d3d; - --cc-cookie-category-expanded-block-hover-bg: #4d4d4d; + --cc-cookie-category-block-bg: #3d3d3d; + --cc-cookie-category-block-border: #3d3d3d; + --cc-cookie-category-block-hover-bg: #4d4d4d; + --cc-cookie-category-block-hover-border: #4d4d4d; - --cc-footer-bg: #2d2d2d; - --cc-footer-color: #e5e5e5; - --cc-footer-border-color: #2d2d2d; + --cc-cookie-category-expanded-block-bg: #3d3d3d; + --cc-cookie-category-expanded-block-hover-bg: #4d4d4d; + + --cc-footer-bg: #2d2d2d; + --cc-footer-color: #e5e5e5; + --cc-footer-border-color: #2d2d2d; } -.cm__body{ - max-width: 90% !important; - flex-direction: row !important; - align-items: center !important; - +.cm__body { + max-width: 90% !important; + flex-direction: row !important; + align-items: center !important; } -.cm__desc{ - max-width: 70rem !important; +.cm__desc { + max-width: 70rem !important; } -.cm__btns{ - flex-direction: row-reverse !important; - gap:10px !important; - padding-top: 3.4rem !important; +.cm__btns { + flex-direction: row-reverse !important; + gap: 10px !important; + padding-top: 3.4rem !important; } @media only screen and (max-width: 1400px) { - .cm__body{ - max-width: 90% !important; - flex-direction: column !important; - align-items: normal !important; - } + .cm__body { + max-width: 90% !important; + flex-direction: column !important; + align-items: normal !important; + } - .cm__btns{ - padding-top: 1rem !important; - } + .cm__btns { + padding-top: 1rem !important; + } } /* Toggle visibility fixes */ #cc-main .section__toggle { - opacity: 0 !important; /* Keep invisible but functional */ + opacity: 0 !important; /* Keep invisible but functional */ } #cc-main .toggle__icon { - display: flex !important; - align-items: center !important; - justify-content: flex-start !important; + display: flex !important; + align-items: center !important; + justify-content: flex-start !important; } #cc-main .toggle__icon-circle { - display: block !important; - position: absolute !important; - transition: transform 0.25s ease !important; + display: block !important; + position: absolute !important; + transition: transform 0.25s ease !important; } #cc-main .toggle__icon-on, #cc-main .toggle__icon-off { - display: flex !important; - align-items: center !important; - justify-content: center !important; - position: absolute !important; - width: 100% !important; - height: 100% !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + position: absolute !important; + width: 100% !important; + height: 100% !important; } /* Ensure toggles are visible in both themes */ #cc-main .toggle__icon { - background: var(--cc-toggle-off-bg) !important; - border: 1px solid var(--cc-toggle-off-bg) !important; + background: var(--cc-toggle-off-bg) !important; + border: 1px solid var(--cc-toggle-off-bg) !important; } #cc-main .section__toggle:checked ~ .toggle__icon { - background: var(--cc-toggle-on-bg) !important; - border: 1px solid var(--cc-toggle-on-bg) !important; + background: var(--cc-toggle-on-bg) !important; + border: 1px solid var(--cc-toggle-on-bg) !important; } /* Ensure toggle text is visible */ #cc-main .pm__section-title { - color: var(--cc-primary-color) !important; + color: var(--cc-primary-color) !important; } #cc-main .pm__section-desc { - color: var(--cc-secondary-color) !important; + color: var(--cc-secondary-color) !important; } /* Make sure the modal has proper contrast */ #cc-main .pm { - background: var(--cc-bg) !important; - color: var(--cc-primary-color) !important; + background: var(--cc-bg) !important; + color: var(--cc-primary-color) !important; } /* Lower z-index so cookie banner appears behind onboarding modals */ #cc-main { - z-index: 100 !important; + z-index: 100 !important; } /* Ensure consent modal text is visible in both themes */ #cc-main .cm { - background: var(--cc-bg) !important; - color: var(--cc-primary-color) !important; + background: var(--cc-bg) !important; + color: var(--cc-primary-color) !important; } #cc-main .cm__title { - color: var(--cc-primary-color) !important; + color: var(--cc-primary-color) !important; } #cc-main .cm__desc { - color: var(--cc-primary-color) !important; + color: var(--cc-primary-color) !important; } #cc-main .cm__footer { - color: var(--cc-primary-color) !important; + color: var(--cc-primary-color) !important; } #cc-main .cm__footer-links a, #cc-main .cm__link { - color: var(--cc-primary-color) !important; -} \ No newline at end of file + color: var(--cc-primary-color) !important; +} diff --git a/frontend/public/manifest-classic.json b/frontend/public/manifest-classic.json index 9b47da7d05..d6e81e7ddf 100644 --- a/frontend/public/manifest-classic.json +++ b/frontend/public/manifest-classic.json @@ -23,4 +23,3 @@ "theme_color": "#000000", "background_color": "#ffffff" } - diff --git a/frontend/scripts/build-provisioner.mjs b/frontend/scripts/build-provisioner.mjs index 2f974a195f..52240b766e 100644 --- a/frontend/scripts/build-provisioner.mjs +++ b/frontend/scripts/build-provisioner.mjs @@ -1,28 +1,24 @@ -import { execFileSync } from 'node:child_process'; -import { existsSync, mkdirSync, copyFileSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, copyFileSync } from "node:fs"; +import { join, resolve } from "node:path"; -if (process.platform !== 'win32') { +if (process.platform !== "win32") { process.exit(0); } const frontendDir = process.cwd(); -const tauriDir = resolve(frontendDir, 'src-tauri'); -const provisionerManifest = join(tauriDir, 'provisioner', 'Cargo.toml'); +const tauriDir = resolve(frontendDir, "src-tauri"); +const provisionerManifest = join(tauriDir, "provisioner", "Cargo.toml"); -execFileSync( - 'cargo', - ['build', '--release', '--manifest-path', provisionerManifest], - { stdio: 'inherit' } -); +execFileSync("cargo", ["build", "--release", "--manifest-path", provisionerManifest], { stdio: "inherit" }); -const provisionerExe = join(tauriDir, 'provisioner', 'target', 'release', 'stirling-provisioner.exe'); +const provisionerExe = join(tauriDir, "provisioner", "target", "release", "stirling-provisioner.exe"); if (!existsSync(provisionerExe)) { throw new Error(`Provisioner binary not found at ${provisionerExe}`); } -const wixDir = join(tauriDir, 'windows', 'wix'); +const wixDir = join(tauriDir, "windows", "wix"); mkdirSync(wixDir, { recursive: true }); -const destExe = join(wixDir, 'stirling-provision.exe'); +const destExe = join(wixDir, "stirling-provision.exe"); copyFileSync(provisionerExe, destExe); diff --git a/frontend/scripts/generate-icons.js b/frontend/scripts/generate-icons.js index 96566341c6..d15c53393d 100644 --- a/frontend/scripts/generate-icons.js +++ b/frontend/scripts/generate-icons.js @@ -1,11 +1,11 @@ #!/usr/bin/env node -const { icons } = require('@iconify-json/material-symbols'); -const fs = require('fs'); -const path = require('path'); +const { icons } = require("@iconify-json/material-symbols"); +const fs = require("fs"); +const path = require("path"); // Check for verbose flag -const isVerbose = process.argv.includes('--verbose') || process.argv.includes('-v'); +const isVerbose = process.argv.includes("--verbose") || process.argv.includes("-v"); // Logging functions const info = (message) => console.log(message); @@ -18,12 +18,12 @@ const debug = (message) => { // Function to scan codebase for LocalIcon usage function scanForUsedIcons() { const usedIcons = new Set(); - const srcDir = path.join(__dirname, '..', 'src'); + const srcDir = path.join(__dirname, "..", "src"); - info('🔍 Scanning codebase for LocalIcon usage...'); + info("🔍 Scanning codebase for LocalIcon usage..."); if (!fs.existsSync(srcDir)) { - console.error('❌ Source directory not found:', srcDir); + console.error("❌ Source directory not found:", srcDir); process.exit(1); } @@ -31,19 +31,19 @@ function scanForUsedIcons() { function scanDirectory(dir) { const files = fs.readdirSync(dir); - files.forEach(file => { + files.forEach((file) => { const filePath = path.join(dir, file); const stat = fs.statSync(filePath); if (stat.isDirectory()) { scanDirectory(filePath); - } else if (file.endsWith('.tsx') || file.endsWith('.ts')) { - const content = fs.readFileSync(filePath, 'utf8'); + } else if (file.endsWith(".tsx") || file.endsWith(".ts")) { + const content = fs.readFileSync(filePath, "utf8"); // Match LocalIcon usage: const localIconMatches = content.match(/]*icon="([^"]+)"/g); if (localIconMatches) { - localIconMatches.forEach(match => { + localIconMatches.forEach((match) => { const iconMatch = match.match(/icon="([^"]+)"/); if (iconMatch) { usedIcons.add(iconMatch[1]); @@ -55,7 +55,7 @@ function scanForUsedIcons() { // Match LocalIcon usage: const localIconSingleQuoteMatches = content.match(/]*icon='([^']+)'/g); if (localIconSingleQuoteMatches) { - localIconSingleQuoteMatches.forEach(match => { + localIconSingleQuoteMatches.forEach((match) => { const iconMatch = match.match(/icon='([^']+)'/); if (iconMatch) { usedIcons.add(iconMatch[1]); @@ -67,7 +67,7 @@ function scanForUsedIcons() { // Match old material-symbols-rounded spans: icon-name const spanMatches = content.match(/]*className="[^"]*material-symbols-rounded[^"]*"[^>]*>([^<]+)<\/span>/g); if (spanMatches) { - spanMatches.forEach(match => { + spanMatches.forEach((match) => { const iconMatch = match.match(/>([^<]+)<\/span>/); if (iconMatch && iconMatch[1].trim()) { const iconName = iconMatch[1].trim(); @@ -80,7 +80,7 @@ function scanForUsedIcons() { // Match Icon component usage: const iconMatches = content.match(/]*icon="material-symbols:([^"]+)"/g); if (iconMatches) { - iconMatches.forEach(match => { + iconMatches.forEach((match) => { const iconMatch = match.match(/icon="material-symbols:([^"]+)"/); if (iconMatch) { usedIcons.add(iconMatch[1]); @@ -92,7 +92,7 @@ function scanForUsedIcons() { // Match icon config usage: icon: 'icon-name' or icon: "icon-name" const iconPropertyMatches = content.match(/icon:\s*(['"])([a-z0-9-]+)\1/g); if (iconPropertyMatches) { - iconPropertyMatches.forEach(match => { + iconPropertyMatches.forEach((match) => { const iconMatch = match.match(/icon:\s*(['"])([a-z0-9-]+)\1/); if (iconMatch) { usedIcons.add(iconMatch[2]); @@ -118,18 +118,20 @@ async function main() { const usedIcons = scanForUsedIcons(); // Check if we need to regenerate (compare with existing) - const outputPath = path.join(__dirname, '..', 'src', 'assets', 'material-symbols-icons.json'); + const outputPath = path.join(__dirname, "..", "src", "assets", "material-symbols-icons.json"); let needsRegeneration = true; if (fs.existsSync(outputPath)) { try { - const existingSet = JSON.parse(fs.readFileSync(outputPath, 'utf8')); + const existingSet = JSON.parse(fs.readFileSync(outputPath, "utf8")); const existingIcons = Object.keys(existingSet.icons || {}).sort(); const currentIcons = [...usedIcons].sort(); if (JSON.stringify(existingIcons) === JSON.stringify(currentIcons)) { needsRegeneration = false; - info(`✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`); + info( + `✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`, + ); } } catch { // If we can't parse existing file, regenerate @@ -138,34 +140,34 @@ async function main() { } if (!needsRegeneration) { - info('🎉 No regeneration needed!'); + info("🎉 No regeneration needed!"); process.exit(0); } info(`🔍 Extracting ${usedIcons.length} icons from Material Symbols...`); // Dynamic import of ES module - const { getIcons } = await import('@iconify/utils'); + const { getIcons } = await import("@iconify/utils"); // Extract only our used icons from the full set const extractedIcons = getIcons(icons, usedIcons); if (!extractedIcons) { - console.error('❌ Failed to extract icons'); + console.error("❌ Failed to extract icons"); process.exit(1); } // Check for missing icons const extractedIconNames = Object.keys(extractedIcons.icons || {}); - const missingIcons = usedIcons.filter(icon => !extractedIconNames.includes(icon)); + const missingIcons = usedIcons.filter((icon) => !extractedIconNames.includes(icon)); if (missingIcons.length > 0) { - info(`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(', ')}`); - info('💡 These icons don\'t exist in Material Symbols. Please use available alternatives.'); + info(`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(", ")}`); + info("💡 These icons don't exist in Material Symbols. Please use available alternatives."); } // Create output directory - const outputDir = path.join(__dirname, '..', 'src', 'assets'); + const outputDir = path.join(__dirname, "..", "src", "assets"); if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } @@ -182,7 +184,7 @@ async function main() { // This file is automatically generated by scripts/generate-icons.js // Do not edit manually - changes will be overwritten -export type MaterialSymbolIcon = ${usedIcons.map(icon => `'${icon}'`).join(' | ')}; +export type MaterialSymbolIcon = ${usedIcons.map((icon) => `'${icon}'`).join(" | ")}; export interface IconSet { prefix: string; @@ -196,7 +198,7 @@ declare const iconSet: IconSet; export default iconSet; `; - const typesPath = path.join(outputDir, 'material-symbols-icons.d.ts'); + const typesPath = path.join(outputDir, "material-symbols-icons.d.ts"); fs.writeFileSync(typesPath, typesContent); info(`📝 Generated types: ${typesPath}`); @@ -204,7 +206,7 @@ export default iconSet; } // Run the main function -main().catch(error => { - console.error('❌ Script failed:', error); +main().catch((error) => { + console.error("❌ Script failed:", error); process.exit(1); }); diff --git a/frontend/scripts/generate-licenses.js b/frontend/scripts/generate-licenses.js index e4b40c0e42..339e208326 100644 --- a/frontend/scripts/generate-licenses.js +++ b/frontend/scripts/generate-licenses.js @@ -1,11 +1,11 @@ #!/usr/bin/env node -const { execSync } = require('node:child_process'); -const { existsSync, mkdirSync, writeFileSync, readFileSync } = require('node:fs'); -const path = require('node:path'); +const { execSync } = require("node:child_process"); +const { existsSync, mkdirSync, writeFileSync, readFileSync } = require("node:fs"); +const path = require("node:path"); -const { argv } = require('node:process'); -const inputIdx = argv.indexOf('--input'); +const { argv } = require("node:process"); +const inputIdx = argv.indexOf("--input"); const INPUT_FILE = inputIdx > -1 ? argv[inputIdx + 1] : null; const POSTPROCESS_ONLY = !!INPUT_FILE; @@ -16,408 +16,434 @@ const POSTPROCESS_ONLY = !!INPUT_FILE; * This script creates a JSON file similar to the Java backend's 3rdPartyLicenses.json */ -const OUTPUT_FILE = path.join(__dirname, '..', 'src', 'assets', '3rdPartyLicenses.json'); -const PACKAGE_JSON = path.join(__dirname, '..', 'package.json'); +const OUTPUT_FILE = path.join(__dirname, "..", "src", "assets", "3rdPartyLicenses.json"); +const PACKAGE_JSON = path.join(__dirname, "..", "package.json"); // Ensure the output directory exists const outputDir = path.dirname(OUTPUT_FILE); if (!existsSync(outputDir)) { - mkdirSync(outputDir, { recursive: true }); + mkdirSync(outputDir, { recursive: true }); } -console.log('🔍 Generating frontend license report...'); +console.log("🔍 Generating frontend license report..."); try { - // Safety guard: don't run this script on fork PRs (workflow setzt PR_IS_FORK) - if (process.env.PR_IS_FORK === 'true' && !POSTPROCESS_ONLY) { - console.error('Fork PR detected: only --input (postprocess-only) mode is allowed.'); - process.exit(2); + // Safety guard: don't run this script on fork PRs (workflow setzt PR_IS_FORK) + if (process.env.PR_IS_FORK === "true" && !POSTPROCESS_ONLY) { + console.error("Fork PR detected: only --input (postprocess-only) mode is allowed."); + process.exit(2); + } + + let licenseData; + // Generate license report using pinned license-checker; disable lifecycle scripts + if (POSTPROCESS_ONLY) { + if (!INPUT_FILE || !existsSync(INPUT_FILE)) { + console.error("❌ --input file missing or not found"); + process.exit(1); } - - let licenseData; - // Generate license report using pinned license-checker; disable lifecycle scripts - if (POSTPROCESS_ONLY) { - if (!INPUT_FILE || !existsSync(INPUT_FILE)) { - console.error('❌ --input file missing or not found'); - process.exit(1); - } - licenseData = JSON.parse(readFileSync(INPUT_FILE, 'utf8')); - } else { - const licenseReport = execSync( - // 'npx --yes license-checker@25.0.1 --production --json', - 'npx --yes license-report --only=prod --output=json', - { - encoding: 'utf8', - cwd: path.dirname(PACKAGE_JSON), - env: { ...process.env, NPM_CONFIG_IGNORE_SCRIPTS: 'true' } - } - ); - try { - licenseData = JSON.parse(licenseReport); - } catch (parseError) { - console.error('❌ Failed to parse license data:', parseError.message); - console.error('Raw output:', licenseReport.substring(0, 500) + '...'); - process.exit(1); - } - } - - if (!Array.isArray(licenseData)) { - console.error('❌ Invalid license data structure'); - process.exit(1); - } - - // Convert license-checker format to array - const licenseArray = licenseData.map(dep => { - let licenseType = dep.licenseType; - - // Handle missing or null licenses - if (!licenseType || licenseType === null || licenseType === undefined) { - licenseType = 'Unknown'; - } - - // Handle empty string licenses - if (licenseType === '') { - licenseType = 'Unknown'; - } - - // Handle array licenses (rare but possible) - if (Array.isArray(licenseType)) { - licenseType = licenseType.join(' AND '); - } - - // Handle object licenses (fallback) - if (typeof licenseType === 'object' && licenseType !== null) { - licenseType = 'Unknown'; - } - - if ( "posthog-js" === dep.name && licenseType.startsWith("SEE LICENSE IN LICENSE")) { - licenseType = "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE"; - } - - return { - name: dep.name, - version: dep.installedVersion || dep.definedVersion || dep.remoteVersion || 'unknown', - licenseType: licenseType, - repository: dep.link, - url: dep.link, - link: dep.link - }; - }); - - // Transform to match Java backend format - const transformedData = { - dependencies: licenseArray.map(dep => { - const licenseType = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : (dep.licenseType || 'Unknown'); - const licenseUrl = dep.link || getLicenseUrl(licenseType); - - return { - moduleName: dep.name, - moduleUrl: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`, - moduleVersion: dep.version, - moduleLicense: licenseType, - moduleLicenseUrl: licenseUrl - }; - }) - }; - - // Log summary of license types found - const licenseSummary = licenseArray.reduce((acc, dep) => { - const license = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : (dep.licenseType || 'Unknown'); - acc[license] = (acc[license] || 0) + 1; - return acc; - }, {}); - - console.log('📊 License types found:'); - Object.entries(licenseSummary).forEach(([license, count]) => { - console.log(` ${license}: ${count} packages`); - }); - - // Log any complex or unusual license formats for debugging - const complexLicenses = licenseArray.filter(dep => - dep.licenseType && ( - dep.licenseType.includes('AND') || - dep.licenseType.includes('OR') || - dep.licenseType === 'Unknown' || - dep.licenseType.includes('SEE LICENSE') - ) + licenseData = JSON.parse(readFileSync(INPUT_FILE, "utf8")); + } else { + const licenseReport = execSync( + // 'npx --yes license-checker@25.0.1 --production --json', + "npx --yes license-report --only=prod --output=json", + { + encoding: "utf8", + cwd: path.dirname(PACKAGE_JSON), + env: { ...process.env, NPM_CONFIG_IGNORE_SCRIPTS: "true" }, + }, ); - - if (complexLicenses.length > 0) { - console.log('\n🔍 Complex/Edge case licenses detected:'); - complexLicenses.forEach(dep => { - console.log(` ${dep.name}@${dep.version}: "${dep.licenseType}"`); - }); + try { + licenseData = JSON.parse(licenseReport); + } catch (parseError) { + console.error("❌ Failed to parse license data:", parseError.message); + console.error("Raw output:", licenseReport.substring(0, 500) + "..."); + process.exit(1); } + } - // Check for potentially problematic licenses - const problematicLicenses = checkLicenseCompatibility(licenseSummary, licenseArray); - if (problematicLicenses.length > 0) { - console.log('\n⚠️ License compatibility warnings:'); - problematicLicenses.forEach(warning => { - console.log(` ${warning.message}`); - }); - - // Write license warnings to a separate file for CI/CD - const warningsFile = path.join(__dirname, '..', 'src', 'assets', 'license-warnings.json'); - writeFileSync(warningsFile, JSON.stringify({ - warnings: problematicLicenses, - generated: new Date().toISOString() - }, null, 2)); - console.log(`⚠️ License warnings saved to: ${warningsFile}`); - } else { - console.log('\n✅ All licenses appear to be corporate-friendly'); - } - - // Write to file - writeFileSync(OUTPUT_FILE, JSON.stringify(transformedData, null, 4)); - - console.log(`✅ License report generated successfully!`); - console.log(`📄 Found ${transformedData.dependencies.length} dependencies`); - console.log(`💾 Saved to: ${OUTPUT_FILE}`); - -} catch (error) { - console.error('❌ Error generating license report:', error.message); + if (!Array.isArray(licenseData)) { + console.error("❌ Invalid license data structure"); process.exit(1); + } + + // Convert license-checker format to array + const licenseArray = licenseData.map((dep) => { + let licenseType = dep.licenseType; + + // Handle missing or null licenses + if (!licenseType || licenseType === null || licenseType === undefined) { + licenseType = "Unknown"; + } + + // Handle empty string licenses + if (licenseType === "") { + licenseType = "Unknown"; + } + + // Handle array licenses (rare but possible) + if (Array.isArray(licenseType)) { + licenseType = licenseType.join(" AND "); + } + + // Handle object licenses (fallback) + if (typeof licenseType === "object" && licenseType !== null) { + licenseType = "Unknown"; + } + + if ("posthog-js" === dep.name && licenseType.startsWith("SEE LICENSE IN LICENSE")) { + licenseType = "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE"; + } + + return { + name: dep.name, + version: dep.installedVersion || dep.definedVersion || dep.remoteVersion || "unknown", + licenseType: licenseType, + repository: dep.link, + url: dep.link, + link: dep.link, + }; + }); + + // Transform to match Java backend format + const transformedData = { + dependencies: licenseArray.map((dep) => { + const licenseType = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType || "Unknown"; + const licenseUrl = dep.link || getLicenseUrl(licenseType); + + return { + moduleName: dep.name, + moduleUrl: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`, + moduleVersion: dep.version, + moduleLicense: licenseType, + moduleLicenseUrl: licenseUrl, + }; + }), + }; + + // Log summary of license types found + const licenseSummary = licenseArray.reduce((acc, dep) => { + const license = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType || "Unknown"; + acc[license] = (acc[license] || 0) + 1; + return acc; + }, {}); + + console.log("📊 License types found:"); + Object.entries(licenseSummary).forEach(([license, count]) => { + console.log(` ${license}: ${count} packages`); + }); + + // Log any complex or unusual license formats for debugging + const complexLicenses = licenseArray.filter( + (dep) => + dep.licenseType && + (dep.licenseType.includes("AND") || + dep.licenseType.includes("OR") || + dep.licenseType === "Unknown" || + dep.licenseType.includes("SEE LICENSE")), + ); + + if (complexLicenses.length > 0) { + console.log("\n🔍 Complex/Edge case licenses detected:"); + complexLicenses.forEach((dep) => { + console.log(` ${dep.name}@${dep.version}: "${dep.licenseType}"`); + }); + } + + // Check for potentially problematic licenses + const problematicLicenses = checkLicenseCompatibility(licenseSummary, licenseArray); + if (problematicLicenses.length > 0) { + console.log("\n⚠️ License compatibility warnings:"); + problematicLicenses.forEach((warning) => { + console.log(` ${warning.message}`); + }); + + // Write license warnings to a separate file for CI/CD + const warningsFile = path.join(__dirname, "..", "src", "assets", "license-warnings.json"); + writeFileSync( + warningsFile, + JSON.stringify( + { + warnings: problematicLicenses, + generated: new Date().toISOString(), + }, + null, + 2, + ), + ); + console.log(`⚠️ License warnings saved to: ${warningsFile}`); + } else { + console.log("\n✅ All licenses appear to be corporate-friendly"); + } + + // Write to file + writeFileSync(OUTPUT_FILE, JSON.stringify(transformedData, null, 2) + "\n"); + + console.log(`✅ License report generated successfully!`); + console.log(`📄 Found ${transformedData.dependencies.length} dependencies`); + console.log(`💾 Saved to: ${OUTPUT_FILE}`); +} catch (error) { + console.error("❌ Error generating license report:", error.message); + process.exit(1); } /** * Get standard license URLs for common licenses */ function getLicenseUrl(licenseType) { - if (!licenseType || licenseType === 'Unknown') return ''; + if (!licenseType || licenseType === "Unknown") return ""; - const licenseUrls = { - 'MIT': 'https://opensource.org/licenses/MIT', - 'MIT*': 'https://opensource.org/licenses/MIT', - 'Apache-2.0': 'https://www.apache.org/licenses/LICENSE-2.0', - 'Apache License 2.0': 'https://www.apache.org/licenses/LICENSE-2.0', - 'BSD-3-Clause': 'https://opensource.org/licenses/BSD-3-Clause', - 'BSD-2-Clause': 'https://opensource.org/licenses/BSD-2-Clause', - 'BSD': 'https://opensource.org/licenses/BSD-3-Clause', - 'GPL-3.0': 'https://www.gnu.org/licenses/gpl-3.0.html', - 'GPL-2.0': 'https://www.gnu.org/licenses/gpl-2.0.html', - 'LGPL-2.1': 'https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html', - 'LGPL-3.0': 'https://www.gnu.org/licenses/lgpl-3.0.html', - 'ISC': 'https://opensource.org/licenses/ISC', - 'CC0-1.0': 'https://creativecommons.org/publicdomain/zero/1.0/', - 'Unlicense': 'https://unlicense.org/', - 'MPL-2.0': 'https://www.mozilla.org/en-US/MPL/2.0/', - 'WTFPL': 'http://www.wtfpl.net/', - 'Zlib': 'https://opensource.org/licenses/Zlib', - 'Artistic-2.0': 'https://opensource.org/licenses/Artistic-2.0', - 'EPL-1.0': 'https://www.eclipse.org/legal/epl-v10.html', - 'EPL-2.0': 'https://www.eclipse.org/legal/epl-2.0/', - 'CDDL-1.0': 'https://opensource.org/licenses/CDDL-1.0', - 'Ruby': 'https://www.ruby-lang.org/en/about/license.txt', - 'Python-2.0': 'https://www.python.org/download/releases/2.0/license/', - 'Public Domain': 'https://creativecommons.org/publicdomain/zero/1.0/', - 'UNLICENSED': '' - }; + const licenseUrls = { + MIT: "https://opensource.org/licenses/MIT", + "MIT*": "https://opensource.org/licenses/MIT", + "Apache-2.0": "https://www.apache.org/licenses/LICENSE-2.0", + "Apache License 2.0": "https://www.apache.org/licenses/LICENSE-2.0", + "BSD-3-Clause": "https://opensource.org/licenses/BSD-3-Clause", + "BSD-2-Clause": "https://opensource.org/licenses/BSD-2-Clause", + BSD: "https://opensource.org/licenses/BSD-3-Clause", + "GPL-3.0": "https://www.gnu.org/licenses/gpl-3.0.html", + "GPL-2.0": "https://www.gnu.org/licenses/gpl-2.0.html", + "LGPL-2.1": "https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html", + "LGPL-3.0": "https://www.gnu.org/licenses/lgpl-3.0.html", + ISC: "https://opensource.org/licenses/ISC", + "CC0-1.0": "https://creativecommons.org/publicdomain/zero/1.0/", + Unlicense: "https://unlicense.org/", + "MPL-2.0": "https://www.mozilla.org/en-US/MPL/2.0/", + WTFPL: "http://www.wtfpl.net/", + Zlib: "https://opensource.org/licenses/Zlib", + "Artistic-2.0": "https://opensource.org/licenses/Artistic-2.0", + "EPL-1.0": "https://www.eclipse.org/legal/epl-v10.html", + "EPL-2.0": "https://www.eclipse.org/legal/epl-2.0/", + "CDDL-1.0": "https://opensource.org/licenses/CDDL-1.0", + Ruby: "https://www.ruby-lang.org/en/about/license.txt", + "Python-2.0": "https://www.python.org/download/releases/2.0/license/", + "Public Domain": "https://creativecommons.org/publicdomain/zero/1.0/", + UNLICENSED: "", + }; - // Try exact match first - if (licenseUrls[licenseType]) { - return licenseUrls[licenseType]; + // Try exact match first + if (licenseUrls[licenseType]) { + return licenseUrls[licenseType]; + } + + // Try case-insensitive match + const lowerType = licenseType.toLowerCase(); + for (const [key, url] of Object.entries(licenseUrls)) { + if (key.toLowerCase() === lowerType) { + return url; } + } - // Try case-insensitive match - const lowerType = licenseType.toLowerCase(); - for (const [key, url] of Object.entries(licenseUrls)) { - if (key.toLowerCase() === lowerType) { - return url; - } + // Handle complex SPDX expressions like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)" + if (licenseType.includes("AND") || licenseType.includes("OR")) { + // Extract the first license from compound expressions for URL + const match = licenseType.match(/\(?\s*([A-Za-z0-9\-.]+)/); + if (match && licenseUrls[match[1]]) { + return licenseUrls[match[1]]; } + } - // Handle complex SPDX expressions like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)" - if (licenseType.includes('AND') || licenseType.includes('OR')) { - // Extract the first license from compound expressions for URL - const match = licenseType.match(/\(?\s*([A-Za-z0-9\-.]+)/); - if (match && licenseUrls[match[1]]) { - return licenseUrls[match[1]]; - } - } - - // For non-standard licenses, return empty string (will use package link if available) - return ''; + // For non-standard licenses, return empty string (will use package link if available) + return ""; } /** * Check for potentially problematic licenses that may not be MIT/corporate compatible */ function checkLicenseCompatibility(licenseSummary, licenseArray) { - const warnings = []; + const warnings = []; - // Define problematic license patterns - const problematicLicenses = { - // Copyleft licenses - 'GPL-2.0': 'Strong copyleft license - requires derivative works to be GPL', - 'GPL-3.0': 'Strong copyleft license - requires derivative works to be GPL', - 'LGPL-2.1': 'Weak copyleft license - may require source disclosure for modifications', - 'LGPL-3.0': 'Weak copyleft license - may require source disclosure for modifications', - 'AGPL-3.0': 'Network copyleft license - requires source disclosure for network use', - 'AGPL-1.0': 'Network copyleft license - requires source disclosure for network use', + // Define problematic license patterns + const problematicLicenses = { + // Copyleft licenses + "GPL-2.0": "Strong copyleft license - requires derivative works to be GPL", + "GPL-3.0": "Strong copyleft license - requires derivative works to be GPL", + "LGPL-2.1": "Weak copyleft license - may require source disclosure for modifications", + "LGPL-3.0": "Weak copyleft license - may require source disclosure for modifications", + "AGPL-3.0": "Network copyleft license - requires source disclosure for network use", + "AGPL-1.0": "Network copyleft license - requires source disclosure for network use", - // Other potentially problematic licenses - 'WTFPL': 'Potentially problematic license - legal uncertainty', - 'CC-BY-SA-4.0': 'ShareAlike license - requires derivative works to use same license', - 'CC-BY-SA-3.0': 'ShareAlike license - requires derivative works to use same license', - 'CC-BY-NC-4.0': 'Non-commercial license - prohibits commercial use', - 'CC-BY-NC-3.0': 'Non-commercial license - prohibits commercial use', - 'OSL-3.0': 'Copyleft license - requires derivative works to be OSL', - 'EPL-1.0': 'Weak copyleft license - may require source disclosure', - 'EPL-2.0': 'Weak copyleft license - may require source disclosure', - 'CDDL-1.0': 'Weak copyleft license - may require source disclosure', - 'CDDL-1.1': 'Weak copyleft license - may require source disclosure', - 'CPL-1.0': 'Weak copyleft license - may require source disclosure', - 'MPL-1.1': 'Weak copyleft license - may require source disclosure', - 'EUPL-1.1': 'Copyleft license - requires derivative works to be EUPL', - 'EUPL-1.2': 'Copyleft license - requires derivative works to be EUPL', - 'UNLICENSED': 'No license specified - usage rights unclear', - 'Unknown': 'License not detected - manual review required' - }; + // Other potentially problematic licenses + WTFPL: "Potentially problematic license - legal uncertainty", + "CC-BY-SA-4.0": "ShareAlike license - requires derivative works to use same license", + "CC-BY-SA-3.0": "ShareAlike license - requires derivative works to use same license", + "CC-BY-NC-4.0": "Non-commercial license - prohibits commercial use", + "CC-BY-NC-3.0": "Non-commercial license - prohibits commercial use", + "OSL-3.0": "Copyleft license - requires derivative works to be OSL", + "EPL-1.0": "Weak copyleft license - may require source disclosure", + "EPL-2.0": "Weak copyleft license - may require source disclosure", + "CDDL-1.0": "Weak copyleft license - may require source disclosure", + "CDDL-1.1": "Weak copyleft license - may require source disclosure", + "CPL-1.0": "Weak copyleft license - may require source disclosure", + "MPL-1.1": "Weak copyleft license - may require source disclosure", + "EUPL-1.1": "Copyleft license - requires derivative works to be EUPL", + "EUPL-1.2": "Copyleft license - requires derivative works to be EUPL", + UNLICENSED: "No license specified - usage rights unclear", + Unknown: "License not detected - manual review required", + }; - // Known good licenses (no warnings needed) - const goodLicenses = new Set([ - 'MIT', 'MIT*', 'Apache-2.0', 'Apache License 2.0', 'BSD-2-Clause', 'BSD-3-Clause', 'BSD', - 'ISC', 'CC0-1.0', 'Public Domain', 'Unlicense', '0BSD', 'BlueOak-1.0.0', - 'Zlib', 'Artistic-2.0', 'Python-2.0', 'Ruby', 'MPL-2.0', 'CC-BY-4.0', - 'SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE', - 'SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE' - ]); + // Known good licenses (no warnings needed) + const goodLicenses = new Set([ + "MIT", + "MIT*", + "Apache-2.0", + "Apache License 2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "BSD", + "ISC", + "CC0-1.0", + "Public Domain", + "Unlicense", + "0BSD", + "BlueOak-1.0.0", + "Zlib", + "Artistic-2.0", + "Python-2.0", + "Ruby", + "MPL-2.0", + "CC-BY-4.0", + "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE", + "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE", + ]); - // Helper function to normalize license names for comparison - function normalizeLicense(license) { - return license - .replace(/-or-later$/, '') // Remove -or-later suffix - .replace(/\+$/, '') // Remove + suffix - .trim(); + // Helper function to normalize license names for comparison + function normalizeLicense(license) { + return license + .replace(/-or-later$/, "") // Remove -or-later suffix + .replace(/\+$/, "") // Remove + suffix + .trim(); + } + + // Check each license type + Object.entries(licenseSummary).forEach(([license, count]) => { + // Skip known good licenses + if (goodLicenses.has(license)) { + return; } - // Check each license type - Object.entries(licenseSummary).forEach(([license, count]) => { - // Skip known good licenses - if (goodLicenses.has(license)) { - return; - } - - // Check if this license only affects our own packages - const affectedPackages = licenseArray.filter(dep => { - const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : dep.licenseType; - return depLicense === license; - }); - - const isOnlyOurPackages = affectedPackages.every(dep => - dep.name === 'frontend' || - dep.name.toLowerCase().includes('stirling-pdf') || - dep.name.toLowerCase().includes('stirling_pdf') || - dep.name.toLowerCase().includes('stirlingpdf') - ); - - if (isOnlyOurPackages && (license === 'UNLICENSED' || license.startsWith('SEE LICENSE IN'))) { - return; // Skip warnings for our own Stirling-PDF packages - } - - // Check for compound licenses like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)" - if (license.includes('AND') || license.includes('OR')) { - // For OR licenses, check if there's at least one acceptable license option - if (license.includes('OR')) { - // Extract license components from OR expression - const orComponents = license - .replace(/[()]/g, '') // Remove parentheses - .split(' OR ') - .map(component => component.trim()); - - // Check if any component is in the goodLicenses set (with normalization) - const hasGoodLicense = orComponents.some(component => { - const normalized = normalizeLicense(component); - return goodLicenses.has(component) || goodLicenses.has(normalized); - }); - - if (hasGoodLicense) { - return; // Skip warning - can use the good license option - } - } - - // For AND licenses or OR licenses with no good options, check for problematic components - const hasProblematicComponent = Object.keys(problematicLicenses).some(problematic => - license.includes(problematic) - ); - - if (hasProblematicComponent) { - const affectedPackages = licenseArray - .filter(dep => { - const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : dep.licenseType; - return depLicense === license; - }) - .map(dep => ({ - name: dep.name, - version: dep.version, - url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}` - })); - - const licenseType = license.includes('AND') ? 'AND' : 'OR'; - const reason = licenseType === 'AND' - ? 'Compound license with AND requirement - all components must be compatible' - : 'Compound license with potentially problematic components and no good fallback options'; - - warnings.push({ - message: `📋 This PR contains ${count} package${count > 1 ? 's' : ''} with compound license "${license}" - manual review recommended`, - licenseType: license, - licenseUrl: '', - reason: reason, - packageCount: count, - affectedDependencies: affectedPackages - }); - } - return; - } - - // Check for exact matches with problematic licenses - if (problematicLicenses[license]) { - const affectedPackages = licenseArray - .filter(dep => { - const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : dep.licenseType; - return depLicense === license; - }) - .map(dep => ({ - name: dep.name, - version: dep.version, - url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}` - })); - - const packageList = affectedPackages.map(pkg => pkg.name).slice(0, 5).join(', ') + (affectedPackages.length > 5 ? `, and ${affectedPackages.length - 5} more` : ''); - const licenseUrl = getLicenseUrl(license) || 'https://opensource.org/licenses'; - - warnings.push({ - message: `⚠️ This PR contains ${count} package${count > 1 ? 's' : ''} with license type [${license}](${licenseUrl}) - ${problematicLicenses[license]}. Affected packages: ${packageList}`, - licenseType: license, - licenseUrl: licenseUrl, - reason: problematicLicenses[license], - packageCount: count, - affectedDependencies: affectedPackages - }); - } else { - // Unknown license type - flag for manual review - const affectedPackages = licenseArray - .filter(dep => { - const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(', ') : dep.licenseType; - return depLicense === license; - }) - .map(dep => ({ - name: dep.name, - version: dep.version, - url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}` - })); - - warnings.push({ - message: `❓ This PR contains ${count} package${count > 1 ? 's' : ''} with unknown license type "${license}" - manual review required`, - licenseType: license, - licenseUrl: '', - reason: 'Unknown license type', - packageCount: count, - affectedDependencies: affectedPackages - }); - } + // Check if this license only affects our own packages + const affectedPackages = licenseArray.filter((dep) => { + const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType; + return depLicense === license; }); - return warnings; + const isOnlyOurPackages = affectedPackages.every( + (dep) => + dep.name === "frontend" || + dep.name.toLowerCase().includes("stirling-pdf") || + dep.name.toLowerCase().includes("stirling_pdf") || + dep.name.toLowerCase().includes("stirlingpdf"), + ); + + if (isOnlyOurPackages && (license === "UNLICENSED" || license.startsWith("SEE LICENSE IN"))) { + return; // Skip warnings for our own Stirling-PDF packages + } + + // Check for compound licenses like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)" + if (license.includes("AND") || license.includes("OR")) { + // For OR licenses, check if there's at least one acceptable license option + if (license.includes("OR")) { + // Extract license components from OR expression + const orComponents = license + .replace(/[()]/g, "") // Remove parentheses + .split(" OR ") + .map((component) => component.trim()); + + // Check if any component is in the goodLicenses set (with normalization) + const hasGoodLicense = orComponents.some((component) => { + const normalized = normalizeLicense(component); + return goodLicenses.has(component) || goodLicenses.has(normalized); + }); + + if (hasGoodLicense) { + return; // Skip warning - can use the good license option + } + } + + // For AND licenses or OR licenses with no good options, check for problematic components + const hasProblematicComponent = Object.keys(problematicLicenses).some((problematic) => license.includes(problematic)); + + if (hasProblematicComponent) { + const affectedPackages = licenseArray + .filter((dep) => { + const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType; + return depLicense === license; + }) + .map((dep) => ({ + name: dep.name, + version: dep.version, + url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`, + })); + + const licenseType = license.includes("AND") ? "AND" : "OR"; + const reason = + licenseType === "AND" + ? "Compound license with AND requirement - all components must be compatible" + : "Compound license with potentially problematic components and no good fallback options"; + + warnings.push({ + message: `📋 This PR contains ${count} package${count > 1 ? "s" : ""} with compound license "${license}" - manual review recommended`, + licenseType: license, + licenseUrl: "", + reason: reason, + packageCount: count, + affectedDependencies: affectedPackages, + }); + } + return; + } + + // Check for exact matches with problematic licenses + if (problematicLicenses[license]) { + const affectedPackages = licenseArray + .filter((dep) => { + const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType; + return depLicense === license; + }) + .map((dep) => ({ + name: dep.name, + version: dep.version, + url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`, + })); + + const packageList = + affectedPackages + .map((pkg) => pkg.name) + .slice(0, 5) + .join(", ") + (affectedPackages.length > 5 ? `, and ${affectedPackages.length - 5} more` : ""); + const licenseUrl = getLicenseUrl(license) || "https://opensource.org/licenses"; + + warnings.push({ + message: `⚠️ This PR contains ${count} package${count > 1 ? "s" : ""} with license type [${license}](${licenseUrl}) - ${problematicLicenses[license]}. Affected packages: ${packageList}`, + licenseType: license, + licenseUrl: licenseUrl, + reason: problematicLicenses[license], + packageCount: count, + affectedDependencies: affectedPackages, + }); + } else { + // Unknown license type - flag for manual review + const affectedPackages = licenseArray + .filter((dep) => { + const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType; + return depLicense === license; + }) + .map((dep) => ({ + name: dep.name, + version: dep.version, + url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`, + })); + + warnings.push({ + message: `❓ This PR contains ${count} package${count > 1 ? "s" : ""} with unknown license type "${license}" - manual review required`, + licenseType: license, + licenseUrl: "", + reason: "Unknown license type", + packageCount: count, + affectedDependencies: affectedPackages, + }); + } + }); + + return warnings; } diff --git a/frontend/scripts/sample-pdf/generate.mjs b/frontend/scripts/sample-pdf/generate.mjs index 93e5cf7ee3..2ad477cc98 100755 --- a/frontend/scripts/sample-pdf/generate.mjs +++ b/frontend/scripts/sample-pdf/generate.mjs @@ -8,20 +8,20 @@ * for users to experiment with Stirling PDF's features. */ -import puppeteer from 'puppeteer'; -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; -import { existsSync, mkdirSync, statSync } from 'fs'; +import puppeteer from "puppeteer"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; +import { existsSync, mkdirSync, statSync } from "fs"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -const TEMPLATE_PATH = join(__dirname, 'template.html'); -const OUTPUT_DIR = join(__dirname, '../../public/samples'); -const OUTPUT_PATH = join(OUTPUT_DIR, 'Sample.pdf'); +const TEMPLATE_PATH = join(__dirname, "template.html"); +const OUTPUT_DIR = join(__dirname, "../../public/samples"); +const OUTPUT_PATH = join(OUTPUT_DIR, "Sample.pdf"); async function generatePDF() { - console.log('🚀 Starting Stirling PDF sample document generation...\n'); + console.log("🚀 Starting Stirling PDF sample document generation...\n"); // Ensure output directory exists if (!existsSync(OUTPUT_DIR)) { @@ -40,66 +40,65 @@ async function generatePDF() { let browser; try { // Launch Puppeteer - console.log('🌐 Launching browser...'); + console.log("🌐 Launching browser..."); browser = await puppeteer.launch({ - headless: 'new', - args: ['--no-sandbox', '--disable-setuid-sandbox'] + headless: "new", + args: ["--no-sandbox", "--disable-setuid-sandbox"], }); const page = await browser.newPage(); // Set viewport to match A4 proportions await page.setViewport({ - width: 794, // A4 width in pixels at 96 DPI + width: 794, // A4 width in pixels at 96 DPI height: 1123, // A4 height in pixels at 96 DPI - deviceScaleFactor: 2 // Higher quality rendering + deviceScaleFactor: 2, // Higher quality rendering }); // Navigate to the template file const fileUrl = `file://${TEMPLATE_PATH}`; - console.log('📖 Loading HTML template...'); + console.log("📖 Loading HTML template..."); await page.goto(fileUrl, { - waitUntil: 'networkidle0' // Wait for all resources to load + waitUntil: "networkidle0", // Wait for all resources to load }); // Generate PDF with A4 dimensions - console.log('📝 Generating PDF...'); + console.log("📝 Generating PDF..."); await page.pdf({ path: OUTPUT_PATH, - format: 'A4', + format: "A4", printBackground: true, margin: { top: 0, right: 0, bottom: 0, - left: 0 + left: 0, }, - preferCSSPageSize: true + preferCSSPageSize: true, }); - console.log('\n✅ PDF generated successfully!'); + console.log("\n✅ PDF generated successfully!"); console.log(`📦 Output: ${OUTPUT_PATH}`); // Get file size const stats = statSync(OUTPUT_PATH); const fileSizeInKB = (stats.size / 1024).toFixed(2); console.log(`📊 File size: ${fileSizeInKB} KB`); - } catch (error) { - console.error('\n❌ Error generating PDF:', error.message); + console.error("\n❌ Error generating PDF:", error.message); process.exit(1); } finally { if (browser) { await browser.close(); - console.log('🔒 Browser closed.'); + console.log("🔒 Browser closed."); } } - console.log('\n🎉 Done! Sample PDF is ready for use in Stirling PDF.\n'); + console.log("\n🎉 Done! Sample PDF is ready for use in Stirling PDF.\n"); } // Run the generator -generatePDF().catch(error => { - console.error('Fatal error:', error); +generatePDF().catch((error) => { + console.error("Fatal error:", error); process.exit(1); }); diff --git a/frontend/scripts/sample-pdf/styles.css b/frontend/scripts/sample-pdf/styles.css index 067452833c..7f34b95e8f 100644 --- a/frontend/scripts/sample-pdf/styles.css +++ b/frontend/scripts/sample-pdf/styles.css @@ -20,8 +20,9 @@ --color-white: #ffffff; /* Font Stack */ - --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; + --font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", + "Helvetica Neue", sans-serif; } * { diff --git a/frontend/scripts/sample-pdf/template.html b/frontend/scripts/sample-pdf/template.html index edd7f2c9f4..aea5e4cb13 100644 --- a/frontend/scripts/sample-pdf/template.html +++ b/frontend/scripts/sample-pdf/template.html @@ -1,234 +1,244 @@ - + - - - - Stirling PDF - Sample Document - - - - -
-
- - - - - -
-
-
- + + + + Stirling PDF - Sample Document + + + + +
+
+ + + + +
-

The Free Adobe Acrobat Alternative

-
-
- 10M+ - Downloads +
+
+
-
-
-
Open Source
-
Privacy First
-
Self-Hosted
-
-
-
- - -
-
-

What is Stirling PDF?

-

- Stirling PDF is a robust, web-based PDF manipulation tool. - It enables you to carry out various operations on PDF files, including splitting, - merging, converting, rearranging, adding images, rotating, compressing, and more. -

- -
-
-
- - - +

The Free Adobe Acrobat Alternative

+
+
+ 10M+ + Downloads
-

50+ PDF Operations

-

Comprehensive toolkit covering all your PDF needs. From basic operations to advanced processing.

- -
-
- - - -
-

Workflow Automation

-

Chain multiple operations together and save them as reusable workflows. Perfect for recurring tasks.

-
- -
-
- - - - - -
-

Multi-Language Support

-

Available in over 30 languages with community-contributed translations. Accessible to users worldwide.

-
- -
-
- - - - - -
-

Privacy First

-

Self-hosted solution means your data stays on your infrastructure. You have full control over your documents.

-
- -
-
- - - - -
-

Open Source

-

Transparent, community-driven development. Inspect the code, contribute features, and adapt as needed.

-
- -
-
- - - - -
-

API Access

-

RESTful API for integration with external tools and scripts. Automate PDF operations programmatically.

+
+
Open Source
+
Privacy First
+
Self-Hosted
-
- -
-
-

Key Features

+ +
+
+

What is Stirling PDF?

+

+ Stirling PDF is a robust, web-based PDF manipulation tool. It enables you to carry out various operations on PDF + files, including splitting, merging, converting, rearranging, adding images, rotating, compressing, and more. +

-
-
-
-
+
+
+
- - - - - +
-

Page Operations

+

50+ PDF Operations

+

Comprehensive toolkit covering all your PDF needs. From basic operations to advanced processing.

-
    -
  • Merge & split PDFs
  • -
  • Rearrange pages
  • -
  • Rotate & crop
  • -
  • Extract pages
  • -
  • Multi-page layout
  • -
-
-
-
-
+
+
+ + + +
+

Workflow Automation

+

Chain multiple operations together and save them as reusable workflows. Perfect for recurring tasks.

+
+ +
+
- - + + +
-

Security & Signing

+

Multi-Language Support

+

Available in over 30 languages with community-contributed translations. Accessible to users worldwide.

-
    -
  • Password protection
  • -
  • Digital signatures
  • -
  • Watermarks
  • -
  • Permission controls
  • -
  • Redaction tools
  • -
-
-
-
-
- - +
+
+ + + +
-

File Conversions

+

Privacy First

+

+ Self-hosted solution means your data stays on your infrastructure. You have full control over your documents. +

-
    -
  • PDF to/from images
  • -
  • Office documents
  • -
  • HTML to PDF
  • -
  • Markdown to PDF
  • -
  • PDF to Word/Excel
  • -
-
-
-
-
- - +
+
+ + +
-

Automation

+

Open Source

+

Transparent, community-driven development. Inspect the code, contribute features, and adapt as needed.

-
    -
  • Multi-step workflows
  • -
  • Chain PDF operations
  • -
  • Save recurring tasks
  • -
  • Batch file processing
  • -
  • API integration
  • -
-
-
-
-
-
- - - +
+
+ + + + +
+

API Access

+

RESTful API for integration with external tools and scripts. Automate PDF operations programmatically.

-

Plus Many More

-
-
-
    -
  • OCR text recognition
  • -
  • Compress PDFs
  • -
  • Add images & stamps
  • -
  • Detect blank pages
  • -
  • Extract images
  • -
  • Edit metadata
  • -
-
    -
  • Flatten forms
  • -
  • PDF/A conversion
  • -
  • Add page numbers
  • -
  • Remove pages
  • -
  • Repair PDFs
  • -
  • And 40+ more tools
  • -
-
- + +
+
+

Key Features

+ +
+
+
+
+ + + + + + + +
+

Page Operations

+
+
    +
  • Merge & split PDFs
  • +
  • Rearrange pages
  • +
  • Rotate & crop
  • +
  • Extract pages
  • +
  • Multi-page layout
  • +
+
+ +
+
+
+ + + + +
+

Security & Signing

+
+
    +
  • Password protection
  • +
  • Digital signatures
  • +
  • Watermarks
  • +
  • Permission controls
  • +
  • Redaction tools
  • +
+
+ +
+
+
+ + + +
+

File Conversions

+
+
    +
  • PDF to/from images
  • +
  • Office documents
  • +
  • HTML to PDF
  • +
  • Markdown to PDF
  • +
  • PDF to Word/Excel
  • +
+
+ +
+
+
+ + + +
+

Automation

+
+
    +
  • Multi-step workflows
  • +
  • Chain PDF operations
  • +
  • Save recurring tasks
  • +
  • Batch file processing
  • +
  • API integration
  • +
+
+
+ +
+
+
+ + + +
+

Plus Many More

+
+
+
    +
  • OCR text recognition
  • +
  • Compress PDFs
  • +
  • Add images & stamps
  • +
  • Detect blank pages
  • +
  • Extract images
  • +
  • Edit metadata
  • +
+
    +
  • Flatten forms
  • +
  • PDF/A conversion
  • +
  • Add page numbers
  • +
  • Remove pages
  • +
  • Repair PDFs
  • +
  • And 40+ more tools
  • +
+
+
+
+
+ diff --git a/frontend/scripts/setup-env.ts b/frontend/scripts/setup-env.ts index 00ec03df00..508a3ee19a 100644 --- a/frontend/scripts/setup-env.ts +++ b/frontend/scripts/setup-env.ts @@ -10,22 +10,22 @@ * tsx scripts/setup-env.ts --saas # also checks .env.saas */ -import { existsSync, copyFileSync, readFileSync } from 'fs'; -import { join } from 'path'; -import { config, parse } from 'dotenv'; +import { existsSync, copyFileSync, readFileSync } from "fs"; +import { join } from "path"; +import { config, parse } from "dotenv"; // npm scripts run from the directory containing package.json (frontend/) const root = process.cwd(); const args = process.argv.slice(2); -const isDesktop = args.includes('--desktop'); -const isSaas = args.includes('--saas'); +const isDesktop = args.includes("--desktop"); +const isSaas = args.includes("--saas"); -console.log('setup-env: see frontend/README.md#environment-variables for documentation'); +console.log("setup-env: see frontend/README.md#environment-variables for documentation"); function getExampleKeys(exampleFile: string): string[] { const examplePath = join(root, exampleFile); if (!existsSync(examplePath)) return []; - return Object.keys(parse(readFileSync(examplePath, 'utf-8'))); + return Object.keys(parse(readFileSync(examplePath, "utf-8"))); } function ensureEnvFile(envFile: string, exampleFile: string): boolean { @@ -44,13 +44,13 @@ function ensureEnvFile(envFile: string, exampleFile: string): boolean { config({ path: envPath }); - const missing = getExampleKeys(exampleFile).filter(k => !(k in process.env)); + const missing = getExampleKeys(exampleFile).filter((k) => !(k in process.env)); if (missing.length > 0) { console.error( `setup-env: ${envFile} is missing keys from ${exampleFile}:\n` + - missing.map(k => ` ${k}`).join('\n') + - '\n Add them manually or delete your local file to re-copy from the example.' + missing.map((k) => ` ${k}`).join("\n") + + "\n Add them manually or delete your local file to re-copy from the example.", ); return true; } @@ -59,29 +59,28 @@ function ensureEnvFile(envFile: string, exampleFile: string): boolean { } let failed = false; -failed = ensureEnvFile('.env', 'config/.env.example') || failed; +failed = ensureEnvFile(".env", "config/.env.example") || failed; if (isDesktop) { - failed = ensureEnvFile('.env.desktop', 'config/.env.desktop.example') || failed; + failed = ensureEnvFile(".env.desktop", "config/.env.desktop.example") || failed; } if (isSaas) { - failed = ensureEnvFile('.env.saas', 'config/.env.saas.example') || failed; + failed = ensureEnvFile(".env.saas", "config/.env.saas.example") || failed; } // Warn about any VITE_ vars set in the environment that aren't listed in any example file. const allExampleKeys = new Set([ - ...getExampleKeys('config/.env.example'), - ...getExampleKeys('config/.env.desktop.example'), - ...getExampleKeys('config/.env.saas.example'), + ...getExampleKeys("config/.env.example"), + ...getExampleKeys("config/.env.desktop.example"), + ...getExampleKeys("config/.env.saas.example"), ]); -const unknownViteVars = Object.keys(process.env) - .filter(k => k.startsWith('VITE_') && !allExampleKeys.has(k)); +const unknownViteVars = Object.keys(process.env).filter((k) => k.startsWith("VITE_") && !allExampleKeys.has(k)); if (unknownViteVars.length > 0) { console.warn( - 'setup-env: the following VITE_ vars are set but not listed in any example file:\n' + - unknownViteVars.map(k => ` ${k}`).join('\n') + - '\n Add them to the appropriate config/.env.*.example file if they are required.' + "setup-env: the following VITE_ vars are set but not listed in any example file:\n" + + unknownViteVars.map((k) => ` ${k}`).join("\n") + + "\n Add them to the appropriate config/.env.*.example file if they are required.", ); } diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json index 6acaac5145..9259e4543f 100644 --- a/frontend/src-tauri/capabilities/default.json +++ b/frontend/src-tauri/capabilities/default.json @@ -2,9 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "enables the default permissions", - "windows": [ - "main" - ], + "windows": ["main"], "permissions": [ "core:default", "core:window:allow-destroy", diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index 10203960c0..536d0d382d 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -1,98 +1,82 @@ { - "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", - "productName": "Stirling-PDF", - "version": "2.9.2", - "identifier": "stirling.pdf.dev", - "build": { - "frontendDist": "../dist", - "devUrl": "http://localhost:5173", - "beforeDevCommand": "npm run dev -- --mode desktop", - "beforeBuildCommand": "node scripts/build-provisioner.mjs && npm run build -- --mode desktop" + "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", + "productName": "Stirling-PDF", + "version": "2.9.2", + "identifier": "stirling.pdf.dev", + "build": { + "frontendDist": "../dist", + "devUrl": "http://localhost:5173", + "beforeDevCommand": "npm run dev -- --mode desktop", + "beforeBuildCommand": "node scripts/build-provisioner.mjs && npm run build -- --mode desktop" + }, + "app": { + "windows": [ + { + "title": "Stirling-PDF", + "width": 1280, + "height": 800, + "resizable": true, + "fullscreen": false, + "additionalBrowserArgs": "--enable-features=CertVerifierBuiltinFeature" + } + ] + }, + "bundle": { + "active": true, + "publisher": "Stirling PDF Inc.", + "targets": ["deb", "rpm", "dmg", "msi"], + "icon": [ + "icons/icon.png", + "icons/icon.icns", + "icons/icon.ico", + "icons/16x16.png", + "icons/32x32.png", + "icons/64x64.png", + "icons/128x128.png", + "icons/192x192.png" + ], + "resources": ["libs/*.jar", "runtime/jre/**/*"], + "fileAssociations": [ + { + "ext": ["pdf"], + "name": "PDF Document", + "role": "Editor", + "mimeType": "application/pdf" + } + ], + "linux": { + "deb": { + "desktopTemplate": "stirling-pdf.desktop" + } }, - "app": { - "windows": [ - { - "title": "Stirling-PDF", - "width": 1280, - "height": 800, - "resizable": true, - "fullscreen": false, - "additionalBrowserArgs": "--enable-features=CertVerifierBuiltinFeature" - } - ] + "windows": { + "certificateThumbprint": null, + "digestAlgorithm": "sha256", + "timestampUrl": "http://timestamp.digicert.com", + "wix": { + "fragmentPaths": ["windows/wix/provisioning.wxs"], + "componentGroupRefs": ["ProvisioningComponentGroup"] + } }, - "bundle": { - "active": true, - "publisher": "Stirling PDF Inc.", - "targets": [ - "deb", - "rpm", - "dmg", - "msi" - ], - "icon": [ - "icons/icon.png", - "icons/icon.icns", - "icons/icon.ico", - "icons/16x16.png", - "icons/32x32.png", - "icons/64x64.png", - "icons/128x128.png", - "icons/192x192.png" - ], - "resources": [ - "libs/*.jar", - "runtime/jre/**/*" - ], - "fileAssociations": [ - { - "ext": [ - "pdf" - ], - "name": "PDF Document", - "role": "Editor", - "mimeType": "application/pdf" - } - ], - "linux": { - "deb": { - "desktopTemplate": "stirling-pdf.desktop" - } - }, - "windows": { - "certificateThumbprint": null, - "digestAlgorithm": "sha256", - "timestampUrl": "http://timestamp.digicert.com", - "wix": { - "fragmentPaths": [ - "windows/wix/provisioning.wxs" - ], - "componentGroupRefs": [ - "ProvisioningComponentGroup" - ] - } - }, - "macOS": { - "minimumSystemVersion": "10.15", - "signingIdentity": null, - "entitlements": null, - "providerShortName": null, - "infoPlist": "Info.plist" - } - }, - "plugins": { - "shell": { - "open": true - }, - "fs": { - "requireLiteralLeadingDot": false - }, - "deep-link": { - "desktop": { - "schemes": [ - "stirlingpdf" - ] - } - } + "macOS": { + "minimumSystemVersion": "10.15", + "signingIdentity": null, + "entitlements": null, + "providerShortName": null, + "infoPlist": "Info.plist" } + }, + "plugins": { + "shell": { + "open": true + }, + "fs": { + "requireLiteralLeadingDot": false + }, + "deep-link": { + "desktop": { + "schemes": ["stirlingpdf"] + } + } + } } diff --git a/frontend/src/assets/3rdPartyLicenses.json b/frontend/src/assets/3rdPartyLicenses.json index 392249d420..baf60879b6 100644 --- a/frontend/src/assets/3rdPartyLicenses.json +++ b/frontend/src/assets/3rdPartyLicenses.json @@ -1,326 +1,326 @@ { - "dependencies": [ - { - "moduleName": "@atlaskit/pragmatic-drag-and-drop", - "moduleUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git", - "moduleVersion": "1.7.7", - "moduleLicense": "Apache-2.0", - "moduleLicenseUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git" - }, - { - "moduleName": "@embedpdf/core", - "moduleUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz", - "moduleVersion": "1.3.0", - "moduleLicense": "MIT", - "moduleLicenseUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz" - }, - { - "moduleName": "@embedpdf/engines", - "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" - }, - { - "moduleName": "@embedpdf/plugin-annotation", - "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" - }, - { - "moduleName": "@embedpdf/plugin-export", - "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" - }, - { - "moduleName": "@embedpdf/plugin-history", - "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" - }, - { - "moduleName": "@embedpdf/plugin-interaction-manager", - "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" - }, - { - "moduleName": "@embedpdf/plugin-loader", - "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" - }, - { - "moduleName": "@embedpdf/plugin-pan", - "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" - }, - { - "moduleName": "@embedpdf/plugin-render", - "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" - }, - { - "moduleName": "@embedpdf/plugin-rotate", - "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" - }, - { - "moduleName": "@embedpdf/plugin-scroll", - "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" - }, - { - "moduleName": "@embedpdf/plugin-search", - "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" - }, - { - "moduleName": "@embedpdf/plugin-selection", - "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" - }, - { - "moduleName": "@embedpdf/plugin-spread", - "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" - }, - { - "moduleName": "@embedpdf/plugin-thumbnail", - "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" - }, - { - "moduleName": "@embedpdf/plugin-tiling", - "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" - }, - { - "moduleName": "@embedpdf/plugin-viewport", - "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" - }, - { - "moduleName": "@embedpdf/plugin-zoom", - "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", - "moduleVersion": "1.3.0", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" - }, - { - "moduleName": "@emotion/react", - "moduleUrl": "git+https://github.com/emotion-js/emotion.git#main", - "moduleVersion": "11.14.0", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/emotion-js/emotion.git#main" - }, - { - "moduleName": "@emotion/styled", - "moduleUrl": "git+https://github.com/emotion-js/emotion.git#main", - "moduleVersion": "11.14.1", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/emotion-js/emotion.git#main" - }, - { - "moduleName": "@iconify/react", - "moduleUrl": "git+https://github.com/iconify/iconify.git", - "moduleVersion": "6.0.2", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/iconify/iconify.git" - }, - { - "moduleName": "@mantine/core", - "moduleUrl": "git+https://github.com/mantinedev/mantine.git", - "moduleVersion": "8.3.1", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git" - }, - { - "moduleName": "@mantine/dates", - "moduleUrl": "git+https://github.com/mantinedev/mantine.git", - "moduleVersion": "8.3.1", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git" - }, - { - "moduleName": "@mantine/dropzone", - "moduleUrl": "git+https://github.com/mantinedev/mantine.git", - "moduleVersion": "8.3.1", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git" - }, - { - "moduleName": "@mantine/hooks", - "moduleUrl": "git+https://github.com/mantinedev/mantine.git", - "moduleVersion": "8.3.1", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git" - }, - { - "moduleName": "@mui/icons-material", - "moduleUrl": "git+https://github.com/mui/material-ui.git", - "moduleVersion": "7.3.2", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/mui/material-ui.git" - }, - { - "moduleName": "@mui/material", - "moduleUrl": "git+https://github.com/mui/material-ui.git", - "moduleVersion": "7.3.2", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/mui/material-ui.git" - }, - { - "moduleName": "@tailwindcss/postcss", - "moduleUrl": "git+https://github.com/tailwindlabs/tailwindcss.git", - "moduleVersion": "4.1.13", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/tailwindlabs/tailwindcss.git" - }, - { - "moduleName": "@tanstack/react-virtual", - "moduleUrl": "git+https://github.com/TanStack/virtual.git", - "moduleVersion": "3.13.12", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/TanStack/virtual.git" - }, - { - "moduleName": "autoprefixer", - "moduleUrl": "git+https://github.com/postcss/autoprefixer.git", - "moduleVersion": "10.4.21", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/postcss/autoprefixer.git" - }, - { - "moduleName": "axios", - "moduleUrl": "git+https://github.com/axios/axios.git", - "moduleVersion": "1.12.2", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/axios/axios.git" - }, - { - "moduleName": "i18next", - "moduleUrl": "git+https://github.com/i18next/i18next.git", - "moduleVersion": "25.5.2", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/i18next/i18next.git" - }, - { - "moduleName": "i18next-browser-languagedetector", - "moduleUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git", - "moduleVersion": "8.2.0", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git" - }, - { - "moduleName": "i18next-http-backend", - "moduleUrl": "git+ssh://git@github.com/i18next/i18next-http-backend.git", - "moduleVersion": "3.0.2", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+ssh://git@github.com/i18next/i18next-http-backend.git" - }, - { - "moduleName": "jszip", - "moduleUrl": "git+https://github.com/Stuk/jszip.git", - "moduleVersion": "3.10.1", - "moduleLicense": "(MIT OR GPL-3.0-or-later)", - "moduleLicenseUrl": "git+https://github.com/Stuk/jszip.git" - }, - { - "moduleName": "license-report", - "moduleUrl": "git+https://github.com/kessler/license-report.git", - "moduleVersion": "6.8.0", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/kessler/license-report.git" - }, - { - "moduleName": "pdf-lib", - "moduleUrl": "git+https://github.com/Hopding/pdf-lib.git", - "moduleVersion": "1.17.1", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/Hopding/pdf-lib.git" - }, - { - "moduleName": "pdfjs-dist", - "moduleUrl": "git+https://github.com/mozilla/pdf.js.git", - "moduleVersion": "5.4.149", - "moduleLicense": "Apache-2.0", - "moduleLicenseUrl": "git+https://github.com/mozilla/pdf.js.git" - }, - { - "moduleName": "posthog-js", - "moduleUrl": "git+https://github.com/PostHog/posthog-js.git", - "moduleVersion": "1.268.0", - "moduleLicense": "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE", - "moduleLicenseUrl": "git+https://github.com/PostHog/posthog-js.git" - }, - { - "moduleName": "react", - "moduleUrl": "git+https://github.com/facebook/react.git", - "moduleVersion": "19.1.1", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/facebook/react.git" - }, - { - "moduleName": "react-dom", - "moduleUrl": "git+https://github.com/facebook/react.git", - "moduleVersion": "19.1.1", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/facebook/react.git" - }, - { - "moduleName": "react-i18next", - "moduleUrl": "git+https://github.com/i18next/react-i18next.git", - "moduleVersion": "15.7.3", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/i18next/react-i18next.git" - }, - { - "moduleName": "react-router-dom", - "moduleUrl": "git+https://github.com/remix-run/react-router.git", - "moduleVersion": "7.9.1", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/remix-run/react-router.git" - }, - { - "moduleName": "tailwindcss", - "moduleUrl": "git+https://github.com/tailwindlabs/tailwindcss.git", - "moduleVersion": "4.1.13", - "moduleLicense": "MIT", - "moduleLicenseUrl": "git+https://github.com/tailwindlabs/tailwindcss.git" - }, - { - "moduleName": "web-vitals", - "moduleUrl": "git+https://github.com/GoogleChrome/web-vitals.git", - "moduleVersion": "5.1.0", - "moduleLicense": "Apache-2.0", - "moduleLicenseUrl": "git+https://github.com/GoogleChrome/web-vitals.git" - } - ] -} \ No newline at end of file + "dependencies": [ + { + "moduleName": "@atlaskit/pragmatic-drag-and-drop", + "moduleUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git", + "moduleVersion": "1.7.7", + "moduleLicense": "Apache-2.0", + "moduleLicenseUrl": "git+https://github.com/atlassian/pragmatic-drag-and-drop.git" + }, + { + "moduleName": "@embedpdf/core", + "moduleUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz", + "moduleVersion": "1.3.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz" + }, + { + "moduleName": "@embedpdf/engines", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "1.3.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/plugin-annotation", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "1.3.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/plugin-export", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "1.3.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/plugin-history", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "1.3.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/plugin-interaction-manager", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "1.3.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/plugin-loader", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "1.3.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/plugin-pan", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "1.3.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/plugin-render", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "1.3.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/plugin-rotate", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "1.3.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/plugin-scroll", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "1.3.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/plugin-search", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "1.3.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/plugin-selection", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "1.3.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/plugin-spread", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "1.3.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/plugin-thumbnail", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "1.3.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/plugin-tiling", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "1.3.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/plugin-viewport", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "1.3.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@embedpdf/plugin-zoom", + "moduleUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git", + "moduleVersion": "1.3.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/embedpdf/embed-pdf-viewer.git" + }, + { + "moduleName": "@emotion/react", + "moduleUrl": "git+https://github.com/emotion-js/emotion.git#main", + "moduleVersion": "11.14.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/emotion-js/emotion.git#main" + }, + { + "moduleName": "@emotion/styled", + "moduleUrl": "git+https://github.com/emotion-js/emotion.git#main", + "moduleVersion": "11.14.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/emotion-js/emotion.git#main" + }, + { + "moduleName": "@iconify/react", + "moduleUrl": "git+https://github.com/iconify/iconify.git", + "moduleVersion": "6.0.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/iconify/iconify.git" + }, + { + "moduleName": "@mantine/core", + "moduleUrl": "git+https://github.com/mantinedev/mantine.git", + "moduleVersion": "8.3.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git" + }, + { + "moduleName": "@mantine/dates", + "moduleUrl": "git+https://github.com/mantinedev/mantine.git", + "moduleVersion": "8.3.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git" + }, + { + "moduleName": "@mantine/dropzone", + "moduleUrl": "git+https://github.com/mantinedev/mantine.git", + "moduleVersion": "8.3.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git" + }, + { + "moduleName": "@mantine/hooks", + "moduleUrl": "git+https://github.com/mantinedev/mantine.git", + "moduleVersion": "8.3.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/mantinedev/mantine.git" + }, + { + "moduleName": "@mui/icons-material", + "moduleUrl": "git+https://github.com/mui/material-ui.git", + "moduleVersion": "7.3.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/mui/material-ui.git" + }, + { + "moduleName": "@mui/material", + "moduleUrl": "git+https://github.com/mui/material-ui.git", + "moduleVersion": "7.3.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/mui/material-ui.git" + }, + { + "moduleName": "@tailwindcss/postcss", + "moduleUrl": "git+https://github.com/tailwindlabs/tailwindcss.git", + "moduleVersion": "4.1.13", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/tailwindlabs/tailwindcss.git" + }, + { + "moduleName": "@tanstack/react-virtual", + "moduleUrl": "git+https://github.com/TanStack/virtual.git", + "moduleVersion": "3.13.12", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/TanStack/virtual.git" + }, + { + "moduleName": "autoprefixer", + "moduleUrl": "git+https://github.com/postcss/autoprefixer.git", + "moduleVersion": "10.4.21", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/postcss/autoprefixer.git" + }, + { + "moduleName": "axios", + "moduleUrl": "git+https://github.com/axios/axios.git", + "moduleVersion": "1.12.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/axios/axios.git" + }, + { + "moduleName": "i18next", + "moduleUrl": "git+https://github.com/i18next/i18next.git", + "moduleVersion": "25.5.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/i18next/i18next.git" + }, + { + "moduleName": "i18next-browser-languagedetector", + "moduleUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git", + "moduleVersion": "8.2.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/i18next/i18next-browser-languageDetector.git" + }, + { + "moduleName": "i18next-http-backend", + "moduleUrl": "git+ssh://git@github.com/i18next/i18next-http-backend.git", + "moduleVersion": "3.0.2", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+ssh://git@github.com/i18next/i18next-http-backend.git" + }, + { + "moduleName": "jszip", + "moduleUrl": "git+https://github.com/Stuk/jszip.git", + "moduleVersion": "3.10.1", + "moduleLicense": "(MIT OR GPL-3.0-or-later)", + "moduleLicenseUrl": "git+https://github.com/Stuk/jszip.git" + }, + { + "moduleName": "license-report", + "moduleUrl": "git+https://github.com/kessler/license-report.git", + "moduleVersion": "6.8.0", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/kessler/license-report.git" + }, + { + "moduleName": "pdf-lib", + "moduleUrl": "git+https://github.com/Hopding/pdf-lib.git", + "moduleVersion": "1.17.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/Hopding/pdf-lib.git" + }, + { + "moduleName": "pdfjs-dist", + "moduleUrl": "git+https://github.com/mozilla/pdf.js.git", + "moduleVersion": "5.4.149", + "moduleLicense": "Apache-2.0", + "moduleLicenseUrl": "git+https://github.com/mozilla/pdf.js.git" + }, + { + "moduleName": "posthog-js", + "moduleUrl": "git+https://github.com/PostHog/posthog-js.git", + "moduleVersion": "1.268.0", + "moduleLicense": "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE", + "moduleLicenseUrl": "git+https://github.com/PostHog/posthog-js.git" + }, + { + "moduleName": "react", + "moduleUrl": "git+https://github.com/facebook/react.git", + "moduleVersion": "19.1.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/facebook/react.git" + }, + { + "moduleName": "react-dom", + "moduleUrl": "git+https://github.com/facebook/react.git", + "moduleVersion": "19.1.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/facebook/react.git" + }, + { + "moduleName": "react-i18next", + "moduleUrl": "git+https://github.com/i18next/react-i18next.git", + "moduleVersion": "15.7.3", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/i18next/react-i18next.git" + }, + { + "moduleName": "react-router-dom", + "moduleUrl": "git+https://github.com/remix-run/react-router.git", + "moduleVersion": "7.9.1", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/remix-run/react-router.git" + }, + { + "moduleName": "tailwindcss", + "moduleUrl": "git+https://github.com/tailwindlabs/tailwindcss.git", + "moduleVersion": "4.1.13", + "moduleLicense": "MIT", + "moduleLicenseUrl": "git+https://github.com/tailwindlabs/tailwindcss.git" + }, + { + "moduleName": "web-vitals", + "moduleUrl": "git+https://github.com/GoogleChrome/web-vitals.git", + "moduleVersion": "5.1.0", + "moduleLicense": "Apache-2.0", + "moduleLicenseUrl": "git+https://github.com/GoogleChrome/web-vitals.git" + } + ] +} diff --git a/frontend/src/core/App.tsx b/frontend/src/core/App.tsx index f53031c4ba..c417ef1f65 100644 --- a/frontend/src/core/App.tsx +++ b/frontend/src/core/App.tsx @@ -21,9 +21,7 @@ import "@app/utils/fileIdSafety"; function MobileScannerProviders({ children }: { children: React.ReactNode }) { return ( - - {children} - + {children} ); } diff --git a/frontend/src/core/components/AppLayout.tsx b/frontend/src/core/components/AppLayout.tsx index 9bcd31e6db..328758cf3a 100644 --- a/frontend/src/core/components/AppLayout.tsx +++ b/frontend/src/core/components/AppLayout.tsx @@ -1,6 +1,6 @@ -import { ReactNode } from 'react'; -import { useBanner } from '@app/contexts/BannerContext'; -import NavigationWarningModal from '@app/components/shared/NavigationWarningModal'; +import { ReactNode } from "react"; +import { useBanner } from "@app/contexts/BannerContext"; +import NavigationWarningModal from "@app/components/shared/NavigationWarningModal"; interface AppLayoutProps { children: ReactNode; @@ -21,11 +21,9 @@ export function AppLayout({ children }: AppLayoutProps) { height: 100% !important; } `} -
+
{banner} -
- {children} -
+
{children}
diff --git a/frontend/src/core/components/AppProviders.tsx b/frontend/src/core/components/AppProviders.tsx index 75c7d281c3..3080f946c0 100644 --- a/frontend/src/core/components/AppProviders.tsx +++ b/frontend/src/core/components/AppProviders.tsx @@ -8,7 +8,12 @@ import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext"; import { HotkeyProvider } from "@app/contexts/HotkeyContext"; import { SidebarProvider } from "@app/contexts/SidebarContext"; import { PreferencesProvider, usePreferences } from "@app/contexts/PreferencesContext"; -import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions, useAppConfig } from "@app/contexts/AppConfigContext"; +import { + AppConfigProvider, + AppConfigProviderProps, + AppConfigRetryOptions, + useAppConfig, +} from "@app/contexts/AppConfigContext"; import { RightRailProvider } from "@app/contexts/RightRailContext"; import { ViewerProvider } from "@app/contexts/ViewerContext"; import { SignatureProvider } from "@app/contexts/SignatureContext"; @@ -20,8 +25,8 @@ import { BannerProvider } from "@app/contexts/BannerContext"; import ErrorBoundary from "@app/components/shared/ErrorBoundary"; import { useScarfTracking } from "@app/hooks/useScarfTracking"; import { useAppInitialization } from "@app/hooks/useAppInitialization"; -import { useLogoAssets } from '@app/hooks/useLogoAssets'; -import AppConfigLoader from '@app/components/shared/AppConfigLoader'; +import { useLogoAssets } from "@app/hooks/useLogoAssets"; +import AppConfigLoader from "@app/components/shared/AppConfigLoader"; import { RedactionProvider } from "@app/contexts/RedactionContext"; import { FormFillProvider } from "@app/tools/formFill/FormFillContext"; @@ -41,14 +46,14 @@ function BrandingAssetManager() { const { favicon, logo192, manifestHref } = useLogoAssets(); useEffect(() => { - if (typeof document === 'undefined') { + if (typeof document === "undefined") { return; } const setLinkHref = (selector: string, href: string) => { const link = document.querySelector(selector); - if (link && link.getAttribute('href') !== href) { - link.setAttribute('href', href); + if (link && link.getAttribute("href") !== href) { + link.setAttribute("href", href); } }; @@ -62,7 +67,7 @@ function BrandingAssetManager() { } // Avoid requirement to have props which are required in app providers anyway -type AppConfigProviderOverrides = Omit; +type AppConfigProviderOverrides = Omit; export interface AppProvidersProps { children: ReactNode; @@ -98,49 +103,44 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + - - {children} - + {children} - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/frontend/src/core/components/FileManager.tsx b/frontend/src/core/components/FileManager.tsx index e3c9a84661..fe140ab173 100644 --- a/frontend/src/core/components/FileManager.tsx +++ b/frontend/src/core/components/FileManager.tsx @@ -1,19 +1,19 @@ -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 { useFileManager } from '@app/hooks/useFileManager'; -import { useFilesModalContext } from '@app/contexts/FilesModalContext'; -import { useAppConfig } from '@app/contexts/AppConfigContext'; -import { Tool } from '@app/types/tool'; -import MobileLayout from '@app/components/fileManager/MobileLayout'; -import DesktopLayout from '@app/components/fileManager/DesktopLayout'; -import DragOverlay from '@app/components/fileManager/DragOverlay'; -import { FileManagerProvider } from '@app/contexts/FileManagerContext'; -import { Z_INDEX_FILE_MANAGER_MODAL } from '@app/styles/zIndex'; -import { isGoogleDriveConfigured, extractGoogleDriveBackendConfig } from '@app/services/googleDrivePickerService'; -import { loadScript } from '@app/utils/scriptLoader'; -import { useAllFiles } from '@app/contexts/FileContext'; +import React, { useState, useCallback, useEffect, useMemo } from "react"; +import { Modal } from "@mantine/core"; +import { Dropzone } from "@mantine/dropzone"; +import { StirlingFileStub } from "@app/types/fileContext"; +import { useFileManager } from "@app/hooks/useFileManager"; +import { useFilesModalContext } from "@app/contexts/FilesModalContext"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { Tool } from "@app/types/tool"; +import MobileLayout from "@app/components/fileManager/MobileLayout"; +import DesktopLayout from "@app/components/fileManager/DesktopLayout"; +import DragOverlay from "@app/components/fileManager/DragOverlay"; +import { FileManagerProvider } from "@app/contexts/FileManagerContext"; +import { Z_INDEX_FILE_MANAGER_MODAL } from "@app/styles/zIndex"; +import { isGoogleDriveConfigured, extractGoogleDriveBackendConfig } from "@app/services/googleDrivePickerService"; +import { loadScript } from "@app/utils/scriptLoader"; +import { useAllFiles } from "@app/contexts/FileContext"; interface FileManagerProps { selectedTool?: Tool | null; @@ -32,47 +32,59 @@ const FileManager: React.FC = ({ selectedTool }) => { const { fileIds: activeFileIds } = useAllFiles(); // File management handlers - const isFileSupported = useCallback((fileName: string) => { - if (!selectedTool?.supportedFormats) return true; - const extension = fileName.split('.').pop()?.toLowerCase(); - return selectedTool.supportedFormats.includes(extension || ''); - }, [selectedTool?.supportedFormats]); + const isFileSupported = useCallback( + (fileName: string) => { + if (!selectedTool?.supportedFormats) return true; + const extension = fileName.split(".").pop()?.toLowerCase(); + return selectedTool.supportedFormats.includes(extension || ""); + }, + [selectedTool?.supportedFormats], + ); const refreshRecentFiles = useCallback(async () => { const files = await loadRecentFiles(); setRecentFiles(files); }, [loadRecentFiles]); - const handleRecentFilesSelected = useCallback(async (files: StirlingFileStub[]) => { - try { - // Use StirlingFileStubs directly - preserves all metadata! - onRecentFileSelect(files); - } catch (error) { - console.error('Failed to process selected files:', error); - } - }, [onRecentFileSelect]); - - const handleNewFileUpload = useCallback(async (files: File[]) => { - if (files.length > 0) { + const handleRecentFilesSelected = useCallback( + async (files: StirlingFileStub[]) => { try { - // Files will get IDs assigned through onFilesSelect -> FileContext addFiles - onFileUpload(files); - await refreshRecentFiles(); + // Use StirlingFileStubs directly - preserves all metadata! + onRecentFileSelect(files); } catch (error) { - console.error('Failed to process dropped files:', error); + console.error("Failed to process selected files:", error); } - } - }, [onFileUpload, refreshRecentFiles]); + }, + [onRecentFileSelect], + ); - const handleRemoveFileByIndex = useCallback(async (index: number) => { - await handleRemoveFile(index, recentFiles, setRecentFiles); - }, [handleRemoveFile, recentFiles]); + const handleNewFileUpload = useCallback( + async (files: File[]) => { + if (files.length > 0) { + try { + // Files will get IDs assigned through onFilesSelect -> FileContext addFiles + onFileUpload(files); + await refreshRecentFiles(); + } catch (error) { + console.error("Failed to process dropped files:", error); + } + } + }, + [onFileUpload, refreshRecentFiles], + ); + + const handleRemoveFileByIndex = useCallback( + async (index: number) => { + await handleRemoveFile(index, recentFiles, setRecentFiles); + }, + [handleRemoveFile, recentFiles], + ); useEffect(() => { const checkMobile = () => setIsMobile(window.innerWidth < 1030); checkMobile(); - window.addEventListener('resize', checkMobile); - return () => window.removeEventListener('resize', checkMobile); + window.addEventListener("resize", checkMobile); + return () => window.removeEventListener("resize", checkMobile); }, []); useEffect(() => { @@ -89,7 +101,7 @@ const FileManager: React.FC = ({ selectedTool }) => { return () => { // StoredFileMetadata doesn't have blob URLs, so no cleanup needed // Blob URLs are managed by FileContext and tool operations - console.log('FileManager unmounting - FileContext handles blob URL cleanup'); + console.log("FileManager unmounting - FileContext handles blob URL cleanup"); }; }, []); @@ -97,7 +109,7 @@ const FileManager: React.FC = ({ selectedTool }) => { // Use useMemo to only track Google Drive config changes, not all config updates const googleDriveBackendConfig = useMemo( () => extractGoogleDriveBackendConfig(config), - [config?.googleDriveEnabled, config?.googleDriveClientId, config?.googleDriveApiKey, config?.googleDriveAppId] + [config?.googleDriveEnabled, config?.googleDriveClientId, config?.googleDriveApiKey, config?.googleDriveAppId], ); useEffect(() => { @@ -105,29 +117,29 @@ const FileManager: React.FC = ({ selectedTool }) => { // Load scripts in parallel without blocking Promise.all([ loadScript({ - src: 'https://apis.google.com/js/api.js', - id: 'gapi-script', + src: "https://apis.google.com/js/api.js", + id: "gapi-script", async: true, defer: true, }), loadScript({ - src: 'https://accounts.google.com/gsi/client', - id: 'gis-script', + src: "https://accounts.google.com/gsi/client", + id: "gis-script", async: true, defer: true, }), ]).catch((error) => { - console.warn('Failed to preload Google Drive scripts:', error); + console.warn("Failed to preload Google Drive scripts:", error); }); } }, [googleDriveBackendConfig]); // Modal size constants for consistent scaling - const modalHeight = '80vh'; - const modalWidth = isMobile ? '100%' : '80vw'; - const modalMaxWidth = isMobile ? '100%' : '1200px'; - const modalMaxHeight = '1200px'; - const modalMinWidth = isMobile ? '320px' : '800px'; + const modalHeight = "80vh"; + const modalWidth = isMobile ? "100%" : "80vw"; + const modalMaxWidth = isMobile ? "100%" : "1200px"; + const modalMaxHeight = "1200px"; + const modalMinWidth = isMobile ? "320px" : "800px"; return ( = ({ selectedTool }) => { zIndex={Z_INDEX_FILE_MANAGER_MODAL} styles={{ content: { - position: 'relative', - margin: isMobile ? '1rem' : '2rem' + position: "relative", + margin: isMobile ? "1rem" : "2rem", }, body: { padding: 0 }, - header: { display: 'none' } + header: { display: "none" }, }} > -
+
setIsDragging(true)} @@ -165,14 +179,14 @@ const FileManager: React.FC = ({ selectedTool }) => { multiple={true} activateOnClick={false} style={{ - height: '100%', - width: '100%', - border: 'none', - borderRadius: 'var(--radius-md)', - backgroundColor: 'var(--bg-file-manager)' + height: "100%", + width: "100%", + border: "none", + borderRadius: "var(--radius-md)", + backgroundColor: "var(--bg-file-manager)", }} styles={{ - inner: { pointerEvents: 'all' } + inner: { pointerEvents: "all" }, }} > void; } -const StorageStatsCard: React.FC = ({ - storageStats, - filesCount, - onClearAll, - onReloadFiles, -}) => { +const StorageStatsCard: React.FC = ({ storageStats, filesCount, onClearAll, onReloadFiles }) => { const { t } = useTranslation(); if (!storageStats) return null; @@ -59,12 +54,7 @@ const StorageStatsCard: React.FC = ({ {t("fileManager.clearAll", "Clear All")} )} - @@ -73,4 +63,4 @@ const StorageStatsCard: React.FC = ({ ); }; -export default StorageStatsCard; \ No newline at end of file +export default StorageStatsCard; diff --git a/frontend/src/core/components/annotation/providers/PDFAnnotationProvider.tsx b/frontend/src/core/components/annotation/providers/PDFAnnotationProvider.tsx index 0979d59e35..d1c8f5c087 100644 --- a/frontend/src/core/components/annotation/providers/PDFAnnotationProvider.tsx +++ b/frontend/src/core/components/annotation/providers/PDFAnnotationProvider.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, ReactNode } from 'react'; +import React, { createContext, useContext, ReactNode } from "react"; interface PDFAnnotationContextValue { // Drawing mode management @@ -58,7 +58,7 @@ export const PDFAnnotationProvider: React.FC = ({ getImageData, isPlacementMode, signatureConfig, - setSignatureConfig + setSignatureConfig, }) => { const contextValue: PDFAnnotationContextValue = { activateDrawMode, @@ -72,20 +72,16 @@ export const PDFAnnotationProvider: React.FC = ({ getImageData, isPlacementMode, signatureConfig, - setSignatureConfig + setSignatureConfig, }; - return ( - - {children} - - ); + return {children}; }; export const usePDFAnnotation = (): PDFAnnotationContextValue => { const context = useContext(PDFAnnotationContext); if (context === undefined) { - throw new Error('usePDFAnnotation must be used within a PDFAnnotationProvider'); + throw new Error("usePDFAnnotation must be used within a PDFAnnotationProvider"); } return context; -}; \ No newline at end of file +}; diff --git a/frontend/src/core/components/annotation/shared/BaseAnnotationTool.tsx b/frontend/src/core/components/annotation/shared/BaseAnnotationTool.tsx index ea093b5be8..07a441abd5 100644 --- a/frontend/src/core/components/annotation/shared/BaseAnnotationTool.tsx +++ b/frontend/src/core/components/annotation/shared/BaseAnnotationTool.tsx @@ -1,10 +1,10 @@ -import React, { useEffect, useState } from 'react'; -import { Stack, Alert, Text } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import { DrawingControls } from '@app/components/annotation/shared/DrawingControls'; -import { ColorPicker } from '@app/components/annotation/shared/ColorPicker'; -import { usePDFAnnotation } from '@app/components/annotation/providers/PDFAnnotationProvider'; -import { useSignature } from '@app/contexts/SignatureContext'; +import React, { useEffect, useState } from "react"; +import { Stack, Alert, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { DrawingControls } from "@app/components/annotation/shared/DrawingControls"; +import { ColorPicker } from "@app/components/annotation/shared/ColorPicker"; +import { usePDFAnnotation } from "@app/components/annotation/providers/PDFAnnotationProvider"; +import { useSignature } from "@app/contexts/SignatureContext"; export interface AnnotationToolConfig { enableDrawing?: boolean; @@ -25,17 +25,13 @@ export const BaseAnnotationTool: React.FC = ({ config, children, onSignatureDataChange, - disabled = false + disabled = false, }) => { const { t } = useTranslation(); - const { - activateSignaturePlacementMode, - undo, - redo - } = usePDFAnnotation(); + const { activateSignaturePlacementMode, undo, redo } = usePDFAnnotation(); const { historyApiRef } = useSignature(); - const [selectedColor, setSelectedColor] = useState('#000000'); + const [selectedColor, setSelectedColor] = useState("#000000"); const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); const [signatureData, setSignatureData] = useState(null); const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false }); @@ -94,14 +90,12 @@ export const BaseAnnotationTool: React.FC = ({ signatureData, onSignatureDataChange: handleSignatureDataChange, onColorSwatchClick: () => setIsColorPickerOpen(true), - disabled + disabled, })} {/* Instructions for placing signature */} - - - Click anywhere on the PDF to place your annotation. - + + Click anywhere on the PDF to place your annotation. {/* Color Picker Modal */} diff --git a/frontend/src/core/components/annotation/shared/ColorControl.tsx b/frontend/src/core/components/annotation/shared/ColorControl.tsx index 16b3f845bb..66ff868fd0 100644 --- a/frontend/src/core/components/annotation/shared/ColorControl.tsx +++ b/frontend/src/core/components/annotation/shared/ColorControl.tsx @@ -1,15 +1,15 @@ -import { ActionIcon, Tooltip, Popover, Stack, ColorSwatch, ColorPicker as MantineColorPicker, Group } from '@mantine/core'; -import { useState, useCallback, useEffect } from 'react'; -import ColorizeIcon from '@mui/icons-material/Colorize'; +import { ActionIcon, Tooltip, Popover, Stack, ColorSwatch, ColorPicker as MantineColorPicker, Group } from "@mantine/core"; +import { useState, useCallback, useEffect } from "react"; +import ColorizeIcon from "@mui/icons-material/Colorize"; // safari and firefox do not support the eye dropper API, only edge, chrome and opera do. // the button is hidden in the UI if the API is not supported. -const supportsEyeDropper = typeof window !== 'undefined' && 'EyeDropper' in window; +const supportsEyeDropper = typeof window !== "undefined" && "EyeDropper" in window; interface EyeDropper { open(): Promise<{ sRGBHex: string }>; } -declare const EyeDropper: { new(): EyeDropper }; +declare const EyeDropper: { new (): EyeDropper }; interface ColorControlProps { value: string; @@ -24,7 +24,9 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color // Only propagate to the parent (which triggers expensive annotation updates) // on onChangeEnd (mouse-up / swatch click), preventing infinite re-render loops. const [localColor, setLocalColor] = useState(value); - useEffect(() => { setLocalColor(value); }, [value]); + useEffect(() => { + setLocalColor(value); + }, [value]); const handleEyeDropper = useCallback(async () => { if (!supportsEyeDropper) return; @@ -50,13 +52,13 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color styles={{ root: { flexShrink: 0, - backgroundColor: 'var(--bg-raised)', - border: '1px solid var(--border-default)', - color: 'var(--text-secondary)', - '&:hover': { - backgroundColor: 'var(--hover-bg)', - borderColor: 'var(--border-strong)', - color: 'var(--text-primary)', + backgroundColor: "var(--bg-raised)", + border: "1px solid var(--border-default)", + color: "var(--text-secondary)", + "&:hover": { + backgroundColor: "var(--hover-bg)", + borderColor: "var(--border-strong)", + color: "var(--text-primary)", }, }, }} @@ -73,8 +75,16 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color onChange={setLocalColor} onChangeEnd={onChange} swatches={[ - '#000000', '#ffffff', '#ff0000', '#00ff00', '#0000ff', - '#ffff00', '#ff00ff', '#00ffff', '#ffa500', 'transparent' + "#000000", + "#ffffff", + "#ff0000", + "#00ff00", + "#0000ff", + "#ffff00", + "#ff00ff", + "#00ffff", + "#ffa500", + "transparent", ]} swatchesPerRow={5} size="sm" @@ -82,7 +92,13 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color {supportsEyeDropper && ( - + diff --git a/frontend/src/core/components/annotation/shared/ColorPicker.tsx b/frontend/src/core/components/annotation/shared/ColorPicker.tsx index 21656b1f23..71f326a7d5 100644 --- a/frontend/src/core/components/annotation/shared/ColorPicker.tsx +++ b/frontend/src/core/components/annotation/shared/ColorPicker.tsx @@ -1,6 +1,6 @@ -import React from 'react'; -import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch, Slider, Text } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; +import React from "react"; +import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch, Slider, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; interface ColorPickerProps { isOpen: boolean; @@ -26,48 +26,42 @@ export const ColorPicker: React.FC = ({ opacityLabel, }) => { const { t } = useTranslation(); - const resolvedTitle = title ?? t('colorPicker.title', 'Choose colour'); - const resolvedOpacityLabel = opacityLabel ?? t('annotation.opacity', 'Opacity'); + const resolvedTitle = title ?? t("colorPicker.title", "Choose colour"); + const resolvedOpacityLabel = opacityLabel ?? t("annotation.opacity", "Opacity"); return ( - + {showOpacity && onOpacityChange && opacity !== undefined && ( - {resolvedOpacityLabel} + + {resolvedOpacityLabel} + )} - + @@ -80,18 +74,6 @@ interface ColorSwatchButtonProps { size?: number; } -export const ColorSwatchButton: React.FC = ({ - color, - onClick, - size = 24 -}) => { - return ( - - ); +export const ColorSwatchButton: React.FC = ({ color, onClick, size = 24 }) => { + return ; }; diff --git a/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx b/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx index fd8864be26..080a05c83a 100644 --- a/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx +++ b/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx @@ -1,10 +1,10 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { Paper, Button, Modal, Stack, Text, Group } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import { ColorSwatchButton } from '@app/components/annotation/shared/ColorPicker'; -import PenSizeSelector from '@app/components/tools/sign/PenSizeSelector'; -import SignaturePad from 'signature_pad'; -import { PrivateContent } from '@app/components/shared/PrivateContent'; +import React, { useEffect, useRef, useState } from "react"; +import { Paper, Button, Modal, Stack, Text, Group } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { ColorSwatchButton } from "@app/components/annotation/shared/ColorPicker"; +import PenSizeSelector from "@app/components/tools/sign/PenSizeSelector"; +import SignaturePad from "signature_pad"; +import { PrivateContent } from "@app/components/shared/PrivateContent"; interface DrawingCanvasProps { selectedColor: string; @@ -68,7 +68,7 @@ export const DrawingCanvas: React.FC = ({ if (savedSignatureData) { const img = new Image(); img.onload = () => { - const ctx = canvas.getContext('2d'); + const ctx = canvas.getContext("2d"); if (ctx) { ctx.drawImage(img, 0, 0, canvas.width, canvas.height); } @@ -92,13 +92,16 @@ export const DrawingCanvas: React.FC = ({ }, [autoOpen]); const trimCanvas = (canvas: HTMLCanvasElement): string => { - const ctx = canvas.getContext('2d'); - if (!ctx) return canvas.toDataURL('image/png'); + const ctx = canvas.getContext("2d"); + if (!ctx) return canvas.toDataURL("image/png"); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const pixels = imageData.data; - let minX = canvas.width, minY = canvas.height, maxX = 0, maxY = 0; + let minX = canvas.width, + minY = canvas.height, + maxX = 0, + maxY = 0; // Find bounds of non-transparent pixels for (let y = 0; y < canvas.height; y++) { @@ -117,21 +120,21 @@ export const DrawingCanvas: React.FC = ({ const trimHeight = maxY - minY + 1; // Create trimmed canvas - const trimmedCanvas = document.createElement('canvas'); + const trimmedCanvas = document.createElement("canvas"); trimmedCanvas.width = trimWidth; trimmedCanvas.height = trimHeight; - const trimmedCtx = trimmedCanvas.getContext('2d'); + const trimmedCtx = trimmedCanvas.getContext("2d"); if (trimmedCtx) { trimmedCtx.drawImage(canvas, minX, minY, trimWidth, trimHeight, 0, 0, trimWidth, trimHeight); } - return trimmedCanvas.toDataURL('image/png'); + return trimmedCanvas.toDataURL("image/png"); }; const renderPreview = (dataUrl: string) => { const canvas = previewCanvasRef.current; if (!canvas) return; - const ctx = canvas.getContext('2d'); + const ctx = canvas.getContext("2d"); if (!ctx) return; const img = new Image(); @@ -153,7 +156,7 @@ export const DrawingCanvas: React.FC = ({ const canvas = modalCanvasRef.current; if (canvas) { const trimmedPng = trimCanvas(canvas); - const untrimmedPng = canvas.toDataURL('image/png'); + const untrimmedPng = canvas.toDataURL("image/png"); setSavedSignatureData(untrimmedPng); // Save untrimmed for restoration onSignatureDataChange(trimmedPng); renderPreview(trimmedPng); @@ -176,7 +179,7 @@ export const DrawingCanvas: React.FC = ({ padRef.current.clear(); } if (previewCanvasRef.current) { - const ctx = previewCanvasRef.current.getContext('2d'); + const ctx = previewCanvasRef.current.getContext("2d"); if (ctx) { ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height); } @@ -209,7 +212,7 @@ export const DrawingCanvas: React.FC = ({ useEffect(() => { const canvas = previewCanvasRef.current; if (!canvas) return; - const ctx = canvas.getContext('2d'); + const ctx = canvas.getContext("2d"); if (!ctx) return; if (!initialSignatureData) { @@ -227,42 +230,45 @@ export const DrawingCanvas: React.FC = ({ - {t('sign.canvas.heading', 'Draw your signature')} - + {t("sign.canvas.heading", "Draw your signature")} + - {t('sign.canvas.clickToOpen', 'Click to open the drawing canvas')} + {t("sign.canvas.clickToOpen", "Click to open the drawing canvas")} - + - {t('sign.canvas.colorLabel', 'Colour')} + {t("sign.canvas.colorLabel", "Colour")} - + - {t('sign.canvas.penSizeLabel', 'Pen size')} + {t("sign.canvas.penSizeLabel", "Pen size")} = ({ updatePenSize(size); }} onInputChange={onPenSizeInputChange} - placeholder={t('sign.canvas.penSizePlaceholder', 'Size')} + placeholder={t("sign.canvas.penSizePlaceholder", "Size")} size="compact-sm" - style={{ width: '80px' }} + style={{ width: "80px" }} /> @@ -286,26 +292,24 @@ export const DrawingCanvas: React.FC = ({ if (el) initPad(el); }} style={{ - border: '1px solid #ccc', - borderRadius: '4px', - display: 'block', - touchAction: 'none', - backgroundColor: 'white', - width: '100%', - maxWidth: '50rem', - height: '25rem', - cursor: 'crosshair', + border: "1px solid #ccc", + borderRadius: "4px", + display: "block", + touchAction: "none", + backgroundColor: "white", + width: "100%", + maxWidth: "50rem", + height: "25rem", + cursor: "crosshair", }} /> -
+
- +
diff --git a/frontend/src/core/components/annotation/shared/DrawingControls.tsx b/frontend/src/core/components/annotation/shared/DrawingControls.tsx index 3c28a594e0..9de5e45b9a 100644 --- a/frontend/src/core/components/annotation/shared/DrawingControls.tsx +++ b/frontend/src/core/components/annotation/shared/DrawingControls.tsx @@ -1,7 +1,7 @@ -import React from 'react'; -import { Group, Button, ActionIcon, Tooltip } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import { LocalIcon } from '@app/components/shared/LocalIcon'; +import React from "react"; +import { Group, Button, ActionIcon, Tooltip } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { LocalIcon } from "@app/components/shared/LocalIcon"; interface DrawingControlsProps { onUndo?: () => void; @@ -35,30 +35,30 @@ export const DrawingControls: React.FC = ({ return ( {onUndo && ( - + - + )} {onRedo && ( - + - + )} @@ -67,13 +67,7 @@ export const DrawingControls: React.FC = ({ {/* Place Signature Button */} {showPlaceButton && onPlaceSignature && ( - )} diff --git a/frontend/src/core/components/annotation/shared/ImageUploader.tsx b/frontend/src/core/components/annotation/shared/ImageUploader.tsx index ee2cdd123f..86bbbe7b96 100644 --- a/frontend/src/core/components/annotation/shared/ImageUploader.tsx +++ b/frontend/src/core/components/annotation/shared/ImageUploader.tsx @@ -1,9 +1,9 @@ -import React, { useState } from 'react'; -import { FileInput, Text, Stack, Checkbox } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import { PrivateContent } from '@app/components/shared/PrivateContent'; -import { removeWhiteBackground } from '@app/utils/imageTransparency'; -import { alert } from '@app/components/toast'; +import React, { useState } from "react"; +import { FileInput, Text, Stack, Checkbox } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { PrivateContent } from "@app/components/shared/PrivateContent"; +import { removeWhiteBackground } from "@app/utils/imageTransparency"; +import { alert } from "@app/components/toast"; interface ImageUploaderProps { onImageChange: (file: File | null) => void; @@ -22,7 +22,7 @@ export const ImageUploader: React.FC = ({ placeholder, hint, allowBackgroundRemoval = false, - onProcessedImageData + onProcessedImageData, }) => { const { t } = useTranslation(); const [removeBackground, setRemoveBackground] = useState(false); @@ -36,15 +36,18 @@ export const ImageUploader: React.FC = ({ try { const transparentImageDataUrl = await removeWhiteBackground(imageSource, { autoDetectCorner: true, - tolerance: 15 + tolerance: 15, }); onProcessedImageData?.(transparentImageDataUrl); } catch (error) { - console.error('Error removing background:', error); + console.error("Error removing background:", error); alert({ - title: t('sign.image.backgroundRemovalFailedTitle', 'Background removal failed'), - body: t('sign.image.backgroundRemovalFailedMessage', 'Could not remove the background from the image. Using original image instead.'), - alertType: 'error' + title: t("sign.image.backgroundRemovalFailedTitle", "Background removal failed"), + body: t( + "sign.image.backgroundRemovalFailedMessage", + "Could not remove the background from the image. Using original image instead.", + ), + alertType: "error", }); onProcessedImageData?.(null); } finally { @@ -52,7 +55,7 @@ export const ImageUploader: React.FC = ({ } } else { // When background removal is disabled, return the original image data - if (typeof imageSource === 'string') { + if (typeof imageSource === "string") { onProcessedImageData?.(imageSource); } else { // Convert File to data URL if needed @@ -69,8 +72,8 @@ export const ImageUploader: React.FC = ({ if (file && !disabled) { try { // Validate that it's actually an image file or SVG - if (!file.type.startsWith('image/') && !file.name.toLowerCase().endsWith('.svg')) { - console.error('Selected file is not an image or SVG'); + if (!file.type.startsWith("image/") && !file.name.toLowerCase().endsWith(".svg")) { + console.error("Selected file is not an image or SVG"); return; } @@ -78,10 +81,10 @@ export const ImageUploader: React.FC = ({ onImageChange(file); let dataUrlToProcess: string; - + // Check if file is SVG - const isSvg = file.type === 'image/svg+xml' || file.name.toLowerCase().endsWith('.svg'); - + const isSvg = file.type === "image/svg+xml" || file.name.toLowerCase().endsWith(".svg"); + if (isSvg) { // For SVG, convert to PNG so it can be embedded in PDF dataUrlToProcess = await convertSvgToPng(file); @@ -98,7 +101,7 @@ export const ImageUploader: React.FC = ({ setOriginalImageData(dataUrlToProcess); await processImage(dataUrlToProcess, removeBackground); } catch (error) { - console.error('Error processing image file:', error); + console.error("Error processing image file:", error); } } else if (!file) { // Clear image data when no file is selected @@ -116,33 +119,33 @@ export const ImageUploader: React.FC = ({ reader.onload = async (e) => { try { const svgText = e.target?.result as string; - + // Parse SVG to get dimensions const parser = new DOMParser(); - const svgDoc = parser.parseFromString(svgText, 'image/svg+xml'); + const svgDoc = parser.parseFromString(svgText, "image/svg+xml"); const svgElement = svgDoc.documentElement; - + // Get SVG dimensions - let width = 800; // Default width + let width = 800; // Default width let height = 600; // Default height - - if (svgElement.hasAttribute('width') && svgElement.hasAttribute('height')) { - width = parseFloat(svgElement.getAttribute('width') || '800'); - height = parseFloat(svgElement.getAttribute('height') || '600'); - } else if (svgElement.hasAttribute('viewBox')) { - const viewBox = svgElement.getAttribute('viewBox')?.split(/\s+|,/); + + if (svgElement.hasAttribute("width") && svgElement.hasAttribute("height")) { + width = parseFloat(svgElement.getAttribute("width") || "800"); + height = parseFloat(svgElement.getAttribute("height") || "600"); + } else if (svgElement.hasAttribute("viewBox")) { + const viewBox = svgElement.getAttribute("viewBox")?.split(/\s+|,/); if (viewBox && viewBox.length === 4) { width = parseFloat(viewBox[2]); height = parseFloat(viewBox[3]); } } - + // Ensure reasonable dimensions if (width === 0 || height === 0 || !isFinite(width) || !isFinite(height)) { width = 800; height = 600; } - + // Scale large SVGs down const maxDimension = 2048; if (width > maxDimension || height > maxDimension) { @@ -150,68 +153,73 @@ export const ImageUploader: React.FC = ({ width *= scale; height *= scale; } - - console.log('Converting SVG to PNG:', { width, height }); - + + console.log("Converting SVG to PNG:", { width, height }); + // Create an image element to render SVG const img = new Image(); - const blob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' }); + const blob = new Blob([svgText], { type: "image/svg+xml;charset=utf-8" }); const url = URL.createObjectURL(blob); - + img.onload = () => { try { // Use computed dimensions or image natural dimensions const finalWidth = img.naturalWidth || img.width || width; const finalHeight = img.naturalHeight || img.height || height; - - console.log('Image loaded:', { naturalWidth: img.naturalWidth, naturalHeight: img.naturalHeight, finalWidth, finalHeight }); - + + console.log("Image loaded:", { + naturalWidth: img.naturalWidth, + naturalHeight: img.naturalHeight, + finalWidth, + finalHeight, + }); + // Create canvas to convert to PNG - const canvas = document.createElement('canvas'); + const canvas = document.createElement("canvas"); canvas.width = finalWidth; canvas.height = finalHeight; - - const ctx = canvas.getContext('2d'); + + const ctx = canvas.getContext("2d"); if (!ctx) { URL.revokeObjectURL(url); - reject(new Error('Failed to get canvas context')); + reject(new Error("Failed to get canvas context")); return; } - + // Fill with white background (optional, for transparency support) - ctx.fillStyle = 'white'; + ctx.fillStyle = "white"; ctx.fillRect(0, 0, finalWidth, finalHeight); - + // Draw SVG ctx.drawImage(img, 0, 0, finalWidth, finalHeight); URL.revokeObjectURL(url); - + // Convert canvas to PNG data URL - const pngDataUrl = canvas.toDataURL('image/png'); - console.log('SVG converted to PNG successfully'); + const pngDataUrl = canvas.toDataURL("image/png"); + console.log("SVG converted to PNG successfully"); resolve(pngDataUrl); } catch (error) { URL.revokeObjectURL(url); - console.error('Error during canvas rendering:', error); + console.error("Error during canvas rendering:", error); reject(error); } }; - + img.onerror = (error) => { URL.revokeObjectURL(url); - console.error('Failed to load SVG image:', error); - reject(new Error('Failed to load SVG image')); + console.error("Failed to load SVG image:", error); + reject(new Error("Failed to load SVG image")); }; - + img.src = url; } catch (error) { - console.error('Error parsing SVG:', error); + console.error("Error parsing SVG:", error); reject(error); } }; - + reader.onerror = () => { - console.error('Error reading file:', reader.error); + console.error("Error reading file:", reader.error); reject(reader.error); }; reader.readAsText(file); @@ -231,7 +239,7 @@ export const ImageUploader: React.FC = ({ = ({ {allowBackgroundRemoval && ( handleBackgroundRemovalChange(event.currentTarget.checked)} disabled={disabled || !currentFile || isProcessing} @@ -252,7 +260,7 @@ export const ImageUploader: React.FC = ({ )} {isProcessing && ( - {t('sign.image.processing', 'Processing image...')} + {t("sign.image.processing", "Processing image...")} )} diff --git a/frontend/src/core/components/annotation/shared/OpacityControl.tsx b/frontend/src/core/components/annotation/shared/OpacityControl.tsx index 27b1f10dd9..914d4f038d 100644 --- a/frontend/src/core/components/annotation/shared/OpacityControl.tsx +++ b/frontend/src/core/components/annotation/shared/OpacityControl.tsx @@ -1,7 +1,7 @@ -import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import { useState } from 'react'; -import OpacityIcon from '@mui/icons-material/Opacity'; +import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { useState } from "react"; +import OpacityIcon from "@mui/icons-material/Opacity"; interface OpacityControlProps { value: number; // 0-100 @@ -16,7 +16,7 @@ export function OpacityControl({ value, onChange, disabled = false }: OpacityCon return ( - + - {t('annotation.opacity', 'Opacity')} + {t("annotation.opacity", "Opacity")} - `${val}%`} - /> + `${val}%`} /> diff --git a/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx b/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx index 2f5d424ab6..8828f3b937 100644 --- a/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx +++ b/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx @@ -1,15 +1,15 @@ -import { ActionIcon, Tooltip, Popover, Stack, Slider, Text, Group, Button } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import { useState } from 'react'; -import type { TrackedAnnotation } from '@embedpdf/plugin-annotation'; -import type { PdfAnnotationObject } from '@embedpdf/models'; -import type { AnnotationPatch } from '@app/components/viewer/viewerTypes'; -import TuneIcon from '@mui/icons-material/Tune'; -import FormatAlignLeftIcon from '@mui/icons-material/FormatAlignLeft'; -import FormatAlignCenterIcon from '@mui/icons-material/FormatAlignCenter'; -import FormatAlignRightIcon from '@mui/icons-material/FormatAlignRight'; +import { ActionIcon, Tooltip, Popover, Stack, Slider, Text, Group, Button } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { useState } from "react"; +import type { TrackedAnnotation } from "@embedpdf/plugin-annotation"; +import type { PdfAnnotationObject } from "@embedpdf/models"; +import type { AnnotationPatch } from "@app/components/viewer/viewerTypes"; +import TuneIcon from "@mui/icons-material/Tune"; +import FormatAlignLeftIcon from "@mui/icons-material/FormatAlignLeft"; +import FormatAlignCenterIcon from "@mui/icons-material/FormatAlignCenter"; +import FormatAlignRightIcon from "@mui/icons-material/FormatAlignRight"; -export type PropertiesAnnotationType = 'text' | 'note' | 'shape'; +export type PropertiesAnnotationType = "text" | "note" | "shape"; interface PropertiesPopoverProps { annotationType: PropertiesAnnotationType; @@ -18,12 +18,7 @@ interface PropertiesPopoverProps { disabled?: boolean; } -export function PropertiesPopover({ - annotationType, - annotation, - onUpdate, - disabled = false, -}: PropertiesPopoverProps) { +export function PropertiesPopover({ annotationType, annotation, onUpdate, disabled = false }: PropertiesPopoverProps) { const { t } = useTranslation(); const [opened, setOpened] = useState(false); @@ -41,17 +36,17 @@ export function PropertiesPopover({ const fontSize = obj?.fontSize ?? 14; const textAlign = obj?.textAlign; const currentAlign = - typeof textAlign === 'number' + typeof textAlign === "number" ? textAlign === 1 - ? 'center' + ? "center" : textAlign === 2 - ? 'right' - : 'left' - : textAlign === 'center' - ? 'center' - : textAlign === 'right' - ? 'right' - : 'left'; + ? "right" + : "left" + : textAlign === "center" + ? "center" + : textAlign === "right" + ? "right" + : "left"; // For shapes const opacity = Math.round((obj?.opacity ?? 1) * 100); @@ -63,7 +58,7 @@ export function PropertiesPopover({ {/* Font Size */}
- {t('annotation.fontSize', 'Font size')} + {t("annotation.fontSize", "Font size")} - {t('annotation.opacity', 'Opacity')} + {t("annotation.opacity", "Opacity")} - {t('annotation.textAlignment', 'Text Alignment')} + {t("annotation.textAlignment", "Text Alignment")} onUpdate({ textAlign: 0 })} size="md" > onUpdate({ textAlign: 1 })} size="md" > onUpdate({ textAlign: 2 })} size="md" > @@ -125,7 +120,7 @@ export function PropertiesPopover({ {/* Opacity */}
- {t('annotation.opacity', 'Opacity')} + {t("annotation.opacity", "Opacity")}
- {t('annotation.strokeWidth', 'Stroke')} + {t("annotation.strokeWidth", "Stroke")}
@@ -188,7 +181,7 @@ export function PropertiesPopover({ return ( - + - {(annotationType === 'text' || annotationType === 'note') && renderTextNoteControls()} - {annotationType === 'shape' && renderShapeControls()} + {(annotationType === "text" || annotationType === "note") && renderTextNoteControls()} + {annotationType === "shape" && renderShapeControls()} ); diff --git a/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx b/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx index 2385357171..59bcc57564 100644 --- a/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx +++ b/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx @@ -1,7 +1,7 @@ -import React, { useState, useEffect } from 'react'; -import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box, SegmentedControl } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import { ColorPicker } from '@app/components/annotation/shared/ColorPicker'; +import React, { useState, useEffect } from "react"; +import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box, SegmentedControl } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { ColorPicker } from "@app/components/annotation/shared/ColorPicker"; interface TextInputWithFontProps { text: string; @@ -12,8 +12,8 @@ interface TextInputWithFontProps { onFontFamilyChange: (family: string) => void; textColor?: string; onTextColorChange?: (color: string) => void; - textAlign?: 'left' | 'center' | 'right'; - onTextAlignChange?: (align: 'left' | 'center' | 'right') => void; + textAlign?: "left" | "center" | "right"; + onTextAlignChange?: (align: "left" | "center" | "right") => void; disabled?: boolean; label: string; placeholder: string; @@ -31,9 +31,9 @@ export const TextInputWithFont: React.FC = ({ onFontSizeChange, fontFamily, onFontFamilyChange, - textColor = '#000000', + textColor = "#000000", onTextColorChange, - textAlign = 'left', + textAlign = "left", onTextAlignChange, disabled = false, label, @@ -42,7 +42,7 @@ export const TextInputWithFont: React.FC = ({ fontSizeLabel, fontSizePlaceholder, colorLabel, - onAnyChange + onAnyChange, }) => { const { t } = useTranslation(); const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString()); @@ -61,14 +61,37 @@ export const TextInputWithFont: React.FC = ({ }, [textColor]); const fontOptions = [ - { value: 'Helvetica', label: 'Helvetica' }, - { value: 'Times-Roman', label: 'Times' }, - { value: 'Courier', label: 'Courier' }, - { value: 'Arial', label: 'Arial' }, - { value: 'Georgia', label: 'Georgia' }, + { value: "Helvetica", label: "Helvetica" }, + { value: "Times-Roman", label: "Times" }, + { value: "Courier", label: "Courier" }, + { value: "Arial", label: "Arial" }, + { value: "Georgia", label: "Georgia" }, ]; - const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '96', '112', '128', '144', '160', '176', '192', '200']; + const fontSizeOptions = [ + "8", + "12", + "16", + "20", + "24", + "28", + "32", + "36", + "40", + "48", + "56", + "64", + "72", + "80", + "96", + "112", + "128", + "144", + "160", + "176", + "192", + "200", + ]; // Validate hex color const isValidHexColor = (color: string): boolean => { @@ -94,7 +117,7 @@ export const TextInputWithFont: React.FC = ({ label={fontLabel} value={fontFamily} onChange={(value) => { - onFontFamilyChange(value || 'Helvetica'); + onFontFamilyChange(value || "Helvetica"); onAnyChange?.(); }} data={fontOptions} @@ -187,7 +210,7 @@ export const TextInputWithFont: React.FC = ({ setColorInput(textColor); } }} - style={{ width: '100%' }} + style={{ width: "100%" }} rightSection={ !disabled && setIsColorPickerOpen(true)} @@ -195,9 +218,9 @@ export const TextInputWithFont: React.FC = ({ width: 24, height: 24, backgroundColor: textColor, - border: '1px solid #ccc', + border: "1px solid #ccc", borderRadius: 4, - cursor: disabled ? 'default' : 'pointer' + cursor: disabled ? "default" : "pointer", }} /> } @@ -224,14 +247,14 @@ export const TextInputWithFont: React.FC = ({ { - onTextAlignChange(value as 'left' | 'center' | 'right'); + onTextAlignChange(value as "left" | "center" | "right"); onAnyChange?.(); }} disabled={disabled} data={[ - { label: t('textAlign.left', 'Left'), value: 'left' }, - { label: t('textAlign.center', 'Center'), value: 'center' }, - { label: t('textAlign.right', 'Right'), value: 'right' }, + { label: t("textAlign.left", "Left"), value: "left" }, + { label: t("textAlign.center", "Center"), value: "center" }, + { label: t("textAlign.right", "Right"), value: "right" }, ]} /> )} diff --git a/frontend/src/core/components/annotation/shared/WidthControl.tsx b/frontend/src/core/components/annotation/shared/WidthControl.tsx index b99d35c996..58553e64b8 100644 --- a/frontend/src/core/components/annotation/shared/WidthControl.tsx +++ b/frontend/src/core/components/annotation/shared/WidthControl.tsx @@ -1,7 +1,7 @@ -import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import { useState } from 'react'; -import LineWeightIcon from '@mui/icons-material/LineWeight'; +import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { useState } from "react"; +import LineWeightIcon from "@mui/icons-material/LineWeight"; interface WidthControlProps { value: number; @@ -18,7 +18,7 @@ export function WidthControl({ value, onChange, min, max, disabled = false }: Wi return ( - + - {t('annotation.width', 'Width')} + {t("annotation.width", "Width")} - `${val}pt`} - /> + `${val}pt`} /> diff --git a/frontend/src/core/components/annotation/tools/DrawingTool.tsx b/frontend/src/core/components/annotation/tools/DrawingTool.tsx index f3643de35c..2699418eed 100644 --- a/frontend/src/core/components/annotation/tools/DrawingTool.tsx +++ b/frontend/src/core/components/annotation/tools/DrawingTool.tsx @@ -1,33 +1,26 @@ -import React, { useState } from 'react'; -import { Stack } from '@mantine/core'; -import { BaseAnnotationTool } from '@app/components/annotation/shared/BaseAnnotationTool'; -import { DrawingCanvas } from '@app/components/annotation/shared/DrawingCanvas'; +import React, { useState } from "react"; +import { Stack } from "@mantine/core"; +import { BaseAnnotationTool } from "@app/components/annotation/shared/BaseAnnotationTool"; +import { DrawingCanvas } from "@app/components/annotation/shared/DrawingCanvas"; interface DrawingToolProps { onDrawingChange?: (data: string | null) => void; disabled?: boolean; } -export const DrawingTool: React.FC = ({ - onDrawingChange, - disabled = false -}) => { - const [selectedColor] = useState('#000000'); +export const DrawingTool: React.FC = ({ onDrawingChange, disabled = false }) => { + const [selectedColor] = useState("#000000"); const [penSize, setPenSize] = useState(2); - const [penSizeInput, setPenSizeInput] = useState('2'); + const [penSizeInput, setPenSizeInput] = useState("2"); const toolConfig = { enableDrawing: true, showPlaceButton: true, - placeButtonText: "Place Drawing" + placeButtonText: "Place Drawing", }; return ( - + = ({ ); -}; \ No newline at end of file +}; diff --git a/frontend/src/core/components/annotation/tools/ImageTool.tsx b/frontend/src/core/components/annotation/tools/ImageTool.tsx index 0704546965..fa59e272e7 100644 --- a/frontend/src/core/components/annotation/tools/ImageTool.tsx +++ b/frontend/src/core/components/annotation/tools/ImageTool.tsx @@ -1,17 +1,14 @@ -import React, { useState } from 'react'; -import { Stack } from '@mantine/core'; -import { BaseAnnotationTool } from '@app/components/annotation/shared/BaseAnnotationTool'; -import { ImageUploader } from '@app/components/annotation/shared/ImageUploader'; +import React, { useState } from "react"; +import { Stack } from "@mantine/core"; +import { BaseAnnotationTool } from "@app/components/annotation/shared/BaseAnnotationTool"; +import { ImageUploader } from "@app/components/annotation/shared/ImageUploader"; interface ImageToolProps { onImageChange?: (data: string | null) => void; disabled?: boolean; } -export const ImageTool: React.FC = ({ - onImageChange, - disabled = false -}) => { +export const ImageTool: React.FC = ({ onImageChange, disabled = false }) => { const [, setImageData] = useState(null); const handleImageUpload = async (file: File | null) => { @@ -23,7 +20,7 @@ export const ImageTool: React.FC = ({ if (e.target?.result) { resolve(e.target.result as string); } else { - reject(new Error('Failed to read file')); + reject(new Error("Failed to read file")); } }; reader.onerror = () => reject(reader.error); @@ -33,7 +30,7 @@ export const ImageTool: React.FC = ({ setImageData(result); onImageChange?.(result); } catch (error) { - console.error('Error reading file:', error); + console.error("Error reading file:", error); } } else if (!file) { setImageData(null); @@ -44,15 +41,11 @@ export const ImageTool: React.FC = ({ const toolConfig = { enableImageUpload: true, showPlaceButton: true, - placeButtonText: "Place Image" + placeButtonText: "Place Image", }; return ( - + = ({ ); -}; \ No newline at end of file +}; diff --git a/frontend/src/core/components/fileEditor/AddFileCard.tsx b/frontend/src/core/components/fileEditor/AddFileCard.tsx index c5cafd7561..8e321cc62c 100644 --- a/frontend/src/core/components/fileEditor/AddFileCard.tsx +++ b/frontend/src/core/components/fileEditor/AddFileCard.tsx @@ -1,14 +1,14 @@ -import React, { useRef, useState } from 'react'; -import { Button, Group, useMantineColorScheme } 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 { useLogoAssets } from '@app/hooks/useLogoAssets'; -import styles from '@app/components/fileEditor/FileEditor.module.css'; -import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology'; -import { useFileActionIcons } from '@app/hooks/useFileActionIcons'; -import { openFilesFromDisk } from '@app/services/openFilesFromDisk'; +import React, { useRef, useState } from "react"; +import { Button, Group, useMantineColorScheme } 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 { useLogoAssets } from "@app/hooks/useLogoAssets"; +import styles from "@app/components/fileEditor/FileEditor.module.css"; +import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology"; +import { useFileActionIcons } from "@app/hooks/useFileActionIcons"; +import { openFilesFromDisk } from "@app/services/openFilesFromDisk"; interface AddFileCardProps { onFileSelect: (files: File[]) => void; @@ -16,11 +16,7 @@ interface AddFileCardProps { multiple?: boolean; } -const AddFileCard = ({ - onFileSelect, - accept, - multiple = true -}: AddFileCardProps) => { +const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps) => { const { t } = useTranslation(); const fileInputRef = useRef(null); const { openFilesModal } = useFilesModalContext(); @@ -38,7 +34,7 @@ const AddFileCard = ({ e.stopPropagation(); const files = await openFilesFromDisk({ multiple, - onFallbackOpen: () => fileInputRef.current?.click() + onFallbackOpen: () => fileInputRef.current?.click(), }); if (files.length > 0) { onFileSelect(files); @@ -56,7 +52,7 @@ const AddFileCard = ({ onFileSelect(files); } // Reset input so same files can be selected again - event.target.value = ''; + event.target.value = ""; }; return ( @@ -67,17 +63,17 @@ const AddFileCard = ({ accept={accept} multiple={multiple} onChange={handleFileChange} - style={{ display: 'none' }} + style={{ display: "none" }} />
{ - if (e.key === 'Enter' || e.key === ' ') { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleCardClick(); } @@ -86,11 +82,9 @@ const AddFileCard = ({ {/* Header bar - matches FileEditorThumbnail structure */}
- -
-
- {t('fileEditor.addFiles', 'Add Files')} +
+
{t("fileEditor.addFiles", "Add Files")}
@@ -99,84 +93,81 @@ const AddFileCard = ({ {/* Stirling PDF Branding */} Stirling PDF {/* Add Files + Native Upload Buttons - styled like LandingPage */}
setIsUploadHover(false)} >
{/* Instruction Text */} {terminology.dropFilesHere} diff --git a/frontend/src/core/components/fileEditor/FileEditor.module.css b/frontend/src/core/components/fileEditor/FileEditor.module.css index 4f26c8bce5..43ef0d1af9 100644 --- a/frontend/src/core/components/fileEditor/FileEditor.module.css +++ b/frontend/src/core/components/fileEditor/FileEditor.module.css @@ -6,7 +6,10 @@ background: var(--file-card-bg); border-radius: 0.0625rem; cursor: pointer; - transition: box-shadow 0.18s ease, outline-color 0.18s ease, transform 0.18s ease; + transition: + box-shadow 0.18s ease, + outline-color 0.18s ease, + transform 0.18s ease; max-width: 100%; max-height: 100%; overflow: visible; @@ -45,8 +48,8 @@ } .headerResting { - background: #3B4B6E; /* dark blue for unselected in light mode */ - color: #FFFFFF; + background: #3b4b6e; /* dark blue for unselected in light mode */ + color: #ffffff; border-bottom: 1px solid var(--border-default); } @@ -66,7 +69,7 @@ /* Unsupported (but not errored) header appearance */ .headerUnsupported { background: var(--unsupported-bar-bg); /* neutral gray */ - color: #FFFFFF; + color: #ffffff; border-bottom: 1px solid var(--unsupported-bar-border); } @@ -103,7 +106,7 @@ } .headerIconButton { - color: #FFFFFF !important; + color: #ffffff !important; } /* Menu dropdown */ @@ -226,14 +229,13 @@ } .pinned { - color: #FFC107 !important; + color: #ffc107 !important; } - /* Unsupported file indicator */ .unsupportedPill { margin-left: 1.75rem; - background: #6B7280; + background: #6b7280; color: white; padding: 4px 8px; border-radius: 12px; @@ -264,7 +266,8 @@ /* Animations */ @keyframes pulse { - 0%, 100% { + 0%, + 100% { opacity: 1; } 50% { @@ -288,15 +291,15 @@ DARK MODE OVERRIDES ========================= */ :global([data-mantine-color-scheme="dark"]) .card { - outline-color: #3A4047; /* deselected stroke */ + outline-color: #3a4047; /* deselected stroke */ } :global([data-mantine-color-scheme="dark"]) .card[data-selected="true"] { - outline-color: #4B525A; /* selected stroke (subtle grey) */ + outline-color: #4b525a; /* selected stroke (subtle grey) */ } :global([data-mantine-color-scheme="dark"]) .headerResting { - background: #1F2329; /* requested default unselected color */ + background: #1f2329; /* requested default unselected color */ color: var(--tool-header-text); /* #D0D6DC */ border-bottom-color: var(--tool-header-border); /* #3A4047 */ } @@ -308,16 +311,16 @@ } :global([data-mantine-color-scheme="dark"]) .title { - color: #D0D6DC; /* title text */ + color: #d0d6dc; /* title text */ } :global([data-mantine-color-scheme="dark"]) .meta { - color: #6B7280; /* subtitle text */ + color: #6b7280; /* subtitle text */ } /* Light mode selected header stroke override */ :global([data-mantine-color-scheme="light"]) .card[data-selected="true"] { - outline-color: #3B4B6E; + outline-color: #3b4b6e; } /* ========================= diff --git a/frontend/src/core/components/fileEditor/FileEditor.tsx b/frontend/src/core/components/fileEditor/FileEditor.tsx index c230495e1a..64a64b6c46 100644 --- a/frontend/src/core/components/fileEditor/FileEditor.tsx +++ b/frontend/src/core/components/fileEditor/FileEditor.tsx @@ -1,22 +1,19 @@ -import { useState, useCallback, useRef, useMemo, useEffect } from 'react'; -import { - Text, Center, Box, LoadingOverlay, Stack -} 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'; -import { zipFileService } from '@app/services/zipFileService'; -import { detectFileExtension } from '@app/utils/fileUtils'; -import FileEditorThumbnail from '@app/components/fileEditor/FileEditorThumbnail'; -import AddFileCard from '@app/components/fileEditor/AddFileCard'; -import FilePickerModal from '@app/components/shared/FilePickerModal'; -import { FileId, StirlingFile } from '@app/types/fileContext'; -import { alert } from '@app/components/toast'; -import { downloadFile } from '@app/services/downloadService'; -import { useFileEditorRightRailButtons } from '@app/components/fileEditor/fileEditorRightRailButtons'; -import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; - +import { useState, useCallback, useRef, useMemo, useEffect } from "react"; +import { Text, Center, Box, LoadingOverlay, Stack } 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"; +import { zipFileService } from "@app/services/zipFileService"; +import { detectFileExtension } from "@app/utils/fileUtils"; +import FileEditorThumbnail from "@app/components/fileEditor/FileEditorThumbnail"; +import AddFileCard from "@app/components/fileEditor/AddFileCard"; +import FilePickerModal from "@app/components/shared/FilePickerModal"; +import { FileId, StirlingFile } from "@app/types/fileContext"; +import { alert } from "@app/components/toast"; +import { downloadFile } from "@app/services/downloadService"; +import { useFileEditorRightRailButtons } from "@app/components/fileEditor/fileEditorRightRailButtons"; +import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; interface FileEditorProps { onOpenPageEditor?: () => void; @@ -25,16 +22,15 @@ interface FileEditorProps { supportedExtensions?: string[]; } -const FileEditor = ({ - toolMode = false, - supportedExtensions = ["pdf"] -}: FileEditorProps) => { - +const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEditorProps) => { // Utility function to check if a file extension is supported - const isFileSupported = useCallback((fileName: string): boolean => { - const extension = detectFileExtension(fileName); - return extension ? supportedExtensions.includes(extension) : false; - }, [supportedExtensions]); + const isFileSupported = useCallback( + (fileName: string): boolean => { + const extension = detectFileExtension(fileName); + return extension ? supportedExtensions.includes(extension) : false; + }, + [supportedExtensions], + ); // Use optimized FileContext hooks const { state, selectors } = useFileState(); @@ -62,11 +58,11 @@ const FileEditor = ({ const [_error, _setError] = useState(null); // Toast helpers - const showStatus = useCallback((message: string, type: 'neutral' | 'success' | 'warning' | 'error' = 'neutral') => { + const showStatus = useCallback((message: string, type: "neutral" | "success" | "warning" | "error" = "neutral") => { alert({ alertType: type, title: message, expandable: false, durationMs: 4000 }); }, []); const showError = useCallback((message: string) => { - alert({ alertType: 'error', title: 'Error', body: message, expandable: true }); + alert({ alertType: "error", title: "Error", body: message, expandable: true }); }, []); const [selectionMode, setSelectionMode] = useState(toolMode); @@ -76,7 +72,7 @@ const FileEditor = ({ // Compute effective max allowed files based on the active tool and mode const maxAllowed = useMemo(() => { const rawMax = selectedTool?.maxFiles; - return (!toolMode || rawMax == null || rawMax < 0) ? Infinity : rawMax; + return !toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax; }, [selectedTool?.maxFiles, toolMode]); // Enable selection mode automatically in tool mode @@ -104,8 +100,8 @@ const FileEditor = ({ try { clearAllFileErrors(); } catch (error) { - if (process.env.NODE_ENV === 'development') { - console.warn('Failed to clear file errors on select all:', error); + if (process.env.NODE_ENV === "development") { + console.warn("Failed to clear file errors on select all:", error); } } }, [state.files.ids, setSelectedFiles, clearAllFileErrors, maxAllowed]); @@ -115,8 +111,8 @@ const FileEditor = ({ try { clearAllFileErrors(); } catch (error) { - if (process.env.NODE_ENV === 'development') { - console.warn('Failed to clear file errors on deselect:', error); + if (process.env.NODE_ENV === "development") { + console.warn("Failed to clear file errors on deselect:", error); } } }, [setSelectedFiles, clearAllFileErrors]); @@ -137,69 +133,75 @@ const FileEditor = ({ // Process uploaded files using context // ZIP extraction is now handled automatically in FileContext based on user preferences - const handleFileUpload = useCallback(async (uploadedFiles: File[]) => { - _setError(null); + const handleFileUpload = useCallback( + async (uploadedFiles: File[]) => { + _setError(null); - try { - if (uploadedFiles.length > 0) { - // FileContext will automatically handle ZIP extraction based on user preferences - // - Respects autoUnzip setting - // - Respects autoUnzipFileLimit - // - HTML ZIPs stay intact - // - Non-ZIP files pass through unchanged - await addFiles(uploadedFiles, { selectFiles: true }); - // After auto-selection, enforce maxAllowed if needed - if (Number.isFinite(maxAllowed)) { - const nowSelectedIds = selectors.getSelectedStirlingFileStubs().map(r => r.id); - if (nowSelectedIds.length > maxAllowed) { - setSelectedFiles(nowSelectedIds.slice(-maxAllowed)); + try { + if (uploadedFiles.length > 0) { + // FileContext will automatically handle ZIP extraction based on user preferences + // - Respects autoUnzip setting + // - Respects autoUnzipFileLimit + // - HTML ZIPs stay intact + // - Non-ZIP files pass through unchanged + await addFiles(uploadedFiles, { selectFiles: true }); + // After auto-selection, enforce maxAllowed if needed + if (Number.isFinite(maxAllowed)) { + const nowSelectedIds = selectors.getSelectedStirlingFileStubs().map((r) => r.id); + if (nowSelectedIds.length > maxAllowed) { + setSelectedFiles(nowSelectedIds.slice(-maxAllowed)); + } + } + showStatus(`Added ${uploadedFiles.length} file(s)`, "success"); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Failed to process files"; + showError(errorMessage); + console.error("File processing error:", err); + } + }, + [addFiles, showStatus, showError, selectors, maxAllowed, setSelectedFiles], + ); + + 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]; } } - showStatus(`Added ${uploadedFiles.length} file(s)`, 'success'); } - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to process files'; - showError(errorMessage); - console.error('File processing error:', err); - } - }, [addFiles, showStatus, showError, selectors, maxAllowed, setSelectedFiles]); - 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]); + // 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(() => { @@ -208,154 +210,174 @@ const FileEditor = ({ } }, [maxAllowed, selectedFileIds, setSelectedFiles]); - // File reordering handler for drag and drop - const handleReorderFiles = useCallback((sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => { - const currentIds = activeStirlingFileStubs.map(r => r.id); + const handleReorderFiles = useCallback( + (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => { + const currentIds = activeStirlingFileStubs.map((r) => r.id); - // Find indices - const sourceIndex = currentIds.findIndex(id => id === sourceFileId); - const targetIndex = currentIds.findIndex(id => id === targetFileId); + // Find indices + const sourceIndex = currentIds.findIndex((id) => id === sourceFileId); + const targetIndex = currentIds.findIndex((id) => id === targetFileId); - if (sourceIndex === -1 || targetIndex === -1) { - console.warn('Could not find source or target file for reordering'); - return; - } - - // Handle multi-file selection reordering - const filesToMove = selectedFileIds.length > 1 - ? selectedFileIds.filter(id => currentIds.includes(id)) - : [sourceFileId]; - - // Create new order - const newOrder = [...currentIds]; - - // Remove files to move from their current positions (in reverse order to maintain indices) - const sourceIndices = filesToMove.map(id => newOrder.findIndex(nId => nId === id)) - .sort((a, b) => b - a); // Sort descending - - sourceIndices.forEach(index => { - newOrder.splice(index, 1); - }); - - // Calculate insertion index after removals - let insertIndex = newOrder.findIndex(id => id === targetFileId); - if (insertIndex !== -1) { - // Determine if moving forward or backward - const isMovingForward = sourceIndex < targetIndex; - if (isMovingForward) { - // Moving forward: insert after target - insertIndex += 1; - } else { - // Moving backward: insert before target (insertIndex already correct) + if (sourceIndex === -1 || targetIndex === -1) { + console.warn("Could not find source or target file for reordering"); + return; } - } else { - // Target was moved, insert at end - insertIndex = newOrder.length; - } - // Insert files at the calculated position - newOrder.splice(insertIndex, 0, ...filesToMove); + // Handle multi-file selection reordering + const filesToMove = + selectedFileIds.length > 1 ? selectedFileIds.filter((id) => currentIds.includes(id)) : [sourceFileId]; - // Update file order - reorderFiles(newOrder); + // Create new order + const newOrder = [...currentIds]; - // Update status - const moveCount = filesToMove.length; - showStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`); - }, [activeStirlingFileStubs, reorderFiles, _setStatus]); + // Remove files to move from their current positions (in reverse order to maintain indices) + const sourceIndices = filesToMove.map((id) => newOrder.findIndex((nId) => nId === id)).sort((a, b) => b - a); // Sort descending + sourceIndices.forEach((index) => { + newOrder.splice(index, 1); + }); + // Calculate insertion index after removals + let insertIndex = newOrder.findIndex((id) => id === targetFileId); + if (insertIndex !== -1) { + // Determine if moving forward or backward + const isMovingForward = sourceIndex < targetIndex; + if (isMovingForward) { + // Moving forward: insert after target + insertIndex += 1; + } else { + // Moving backward: insert before target (insertIndex already correct) + } + } else { + // Target was moved, insert at end + insertIndex = newOrder.length; + } + + // Insert files at the calculated position + newOrder.splice(insertIndex, 0, ...filesToMove); + + // Update file order + reorderFiles(newOrder); + + // Update status + const moveCount = filesToMove.length; + showStatus(`${moveCount > 1 ? `${moveCount} files` : "File"} reordered`); + }, + [activeStirlingFileStubs, reorderFiles, _setStatus], + ); // File operations using context - const handleCloseFile = useCallback((fileId: FileId) => { - const record = activeStirlingFileStubs.find(r => r.id === fileId); - const file = record ? selectors.getFile(record.id) : null; - if (record && file) { - // Remove file from context but keep in storage (close, don't delete) - const contextFileId = record.id; - removeFiles([contextFileId], false); + const handleCloseFile = useCallback( + (fileId: FileId) => { + const record = activeStirlingFileStubs.find((r) => r.id === fileId); + const file = record ? selectors.getFile(record.id) : null; + if (record && file) { + // Remove file from context but keep in storage (close, don't delete) + const contextFileId = record.id; + removeFiles([contextFileId], false); - // Remove from context selections - const currentSelected = selectedFileIds.filter(id => id !== contextFileId); - setSelectedFiles(currentSelected); - } - }, [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds]); - - const handleDownloadFile = useCallback(async (fileId: FileId) => { - const record = activeStirlingFileStubs.find(r => r.id === fileId); - const file = record ? selectors.getFile(record.id) : null; - console.log('[FileEditor] handleDownloadFile called:', { fileId, hasRecord: !!record, hasFile: !!file, localFilePath: record?.localFilePath, isDirty: record?.isDirty }); - if (record && file) { - const result = await downloadFile({ - data: file, - filename: file.name, - localPath: record.localFilePath - }); - console.log('[FileEditor] Download complete, checking dirty state:', { localFilePath: record.localFilePath, isDirty: record.isDirty, savedPath: result.savedPath }); - // Mark file as clean after successful save to disk - if (result.savedPath) { - console.log('[FileEditor] Marking file as clean:', fileId); - fileActions.updateStirlingFileStub(fileId, { - localFilePath: record.localFilePath ?? result.savedPath, - isDirty: false - }); - } else { - console.log('[FileEditor] Skipping clean mark:', { savedPath: result.savedPath, isDirty: record.isDirty }); + // Remove from context selections + const currentSelected = selectedFileIds.filter((id) => id !== contextFileId); + setSelectedFiles(currentSelected); } - } - }, [activeStirlingFileStubs, selectors, fileActions]); + }, + [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds], + ); - const handleUnzipFile = useCallback(async (fileId: FileId) => { - const record = activeStirlingFileStubs.find(r => r.id === fileId); - const file = record ? selectors.getFile(record.id) : null; - if (record && file) { - try { - // Extract and store files using shared service method - const result = await zipFileService.extractAndStoreFilesWithHistory(file, record); - - if (result.success && result.extractedStubs.length > 0) { - // Add extracted file stubs to FileContext - await fileActions.addStirlingFileStubs(result.extractedStubs); - - // Remove the original ZIP file - removeFiles([fileId], false); - - alert({ - alertType: 'success', - title: `Extracted ${result.extractedStubs.length} file(s) from ${file.name}`, - expandable: false, - durationMs: 3500 + const handleDownloadFile = useCallback( + async (fileId: FileId) => { + const record = activeStirlingFileStubs.find((r) => r.id === fileId); + const file = record ? selectors.getFile(record.id) : null; + console.log("[FileEditor] handleDownloadFile called:", { + fileId, + hasRecord: !!record, + hasFile: !!file, + localFilePath: record?.localFilePath, + isDirty: record?.isDirty, + }); + if (record && file) { + const result = await downloadFile({ + data: file, + filename: file.name, + localPath: record.localFilePath, + }); + console.log("[FileEditor] Download complete, checking dirty state:", { + localFilePath: record.localFilePath, + isDirty: record.isDirty, + savedPath: result.savedPath, + }); + // Mark file as clean after successful save to disk + if (result.savedPath) { + console.log("[FileEditor] Marking file as clean:", fileId); + fileActions.updateStirlingFileStub(fileId, { + localFilePath: record.localFilePath ?? result.savedPath, + isDirty: false, }); } else { + console.log("[FileEditor] Skipping clean mark:", { savedPath: result.savedPath, isDirty: record.isDirty }); + } + } + }, + [activeStirlingFileStubs, selectors, fileActions], + ); + + const handleUnzipFile = useCallback( + async (fileId: FileId) => { + const record = activeStirlingFileStubs.find((r) => r.id === fileId); + const file = record ? selectors.getFile(record.id) : null; + if (record && file) { + try { + // Extract and store files using shared service method + const result = await zipFileService.extractAndStoreFilesWithHistory(file, record); + + if (result.success && result.extractedStubs.length > 0) { + // Add extracted file stubs to FileContext + await fileActions.addStirlingFileStubs(result.extractedStubs); + + // Remove the original ZIP file + removeFiles([fileId], false); + + alert({ + alertType: "success", + title: `Extracted ${result.extractedStubs.length} file(s) from ${file.name}`, + expandable: false, + durationMs: 3500, + }); + } else { + alert({ + alertType: "error", + title: `Failed to extract files from ${file.name}`, + body: result.errors.join("\n"), + expandable: true, + durationMs: 3500, + }); + } + } catch (error) { + console.error("Failed to unzip file:", error); alert({ - alertType: 'error', - title: `Failed to extract files from ${file.name}`, - body: result.errors.join('\n'), - expandable: true, - durationMs: 3500 + alertType: "error", + title: `Error unzipping ${file.name}`, + expandable: false, + durationMs: 3500, }); } - } catch (error) { - console.error('Failed to unzip file:', error); - alert({ - alertType: 'error', - title: `Error unzipping ${file.name}`, - expandable: false, - durationMs: 3500 - }); } - } - }, [activeStirlingFileStubs, selectors, fileActions, removeFiles]); + }, + [activeStirlingFileStubs, selectors, fileActions, removeFiles], + ); - const handleViewFile = useCallback((fileId: FileId) => { - const index = activeStirlingFileStubs.findIndex(r => r.id === fileId); - if (index !== -1) { - setActiveFileId(fileId as string); - setActiveFileIndex(index); - navActions.setWorkbench('viewer'); - } - }, [activeStirlingFileStubs, setActiveFileId, setActiveFileIndex, navActions.setWorkbench]); + const handleViewFile = useCallback( + (fileId: FileId) => { + const index = activeStirlingFileStubs.findIndex((r) => r.id === fileId); + if (index !== -1) { + setActiveFileId(fileId as string); + setActiveFileIndex(index); + navActions.setWorkbench("viewer"); + } + }, + [activeStirlingFileStubs, setActiveFileId, setActiveFileIndex, navActions.setWorkbench], + ); const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => { if (selectedFiles.length === 0) return; @@ -365,91 +387,85 @@ const FileEditor = ({ // The files are already in FileContext, just need to add them to active files showStatus(`Loaded ${selectedFiles.length} files from storage`); } catch (err) { - console.error('Error loading files from storage:', err); - showError('Failed to load some files from storage'); + console.error("Error loading files from storage:", err); + showError("Failed to load some files from storage"); } }, []); - return ( - + + {activeStirlingFileStubs.length === 0 ? ( +
+ + + 📁 + + No files loaded + + Upload PDF files, ZIP archives, or load from storage to get started + + +
+ ) : ( +
+ {/* Add File Card - only show when files exist */} + {activeStirlingFileStubs.length > 0 && } + {activeStirlingFileStubs.map((record, index) => { + return ( + + ); + })} +
+ )} +
- {activeStirlingFileStubs.length === 0 ? ( -
- - 📁 - No files loaded - Upload PDF files, ZIP archives, or load from storage to get started - -
- ) : ( -
- {/* Add File Card - only show when files exist */} - {activeStirlingFileStubs.length > 0 && ( - - )} - - {activeStirlingFileStubs.map((record, index) => { - return ( - - ); - })} -
- )} -
- - {/* File Picker Modal */} - setShowFilePickerModal(false)} - storedFiles={[]} // FileEditor doesn't have access to stored files, needs to be passed from parent - onSelectFiles={handleLoadFromStorage} - /> - - + {/* File Picker Modal */} + setShowFilePickerModal(false)} + storedFiles={[]} // FileEditor doesn't have access to stored files, needs to be passed from parent + onSelectFiles={handleLoadFromStorage} + />
); diff --git a/frontend/src/core/components/fileEditor/FileEditorFileName.tsx b/frontend/src/core/components/fileEditor/FileEditorFileName.tsx index a3e4cd4493..82911a9339 100644 --- a/frontend/src/core/components/fileEditor/FileEditorFileName.tsx +++ b/frontend/src/core/components/fileEditor/FileEditorFileName.tsx @@ -1,13 +1,11 @@ -import React from 'react'; -import { StirlingFileStub } from '@app/types/fileContext'; -import { PrivateContent } from '@app/components/shared/PrivateContent'; +import React from "react"; +import { StirlingFileStub } from "@app/types/fileContext"; +import { PrivateContent } from "@app/components/shared/PrivateContent"; interface FileEditorFileNameProps { file: StirlingFileStub; } -const FileEditorFileName = ({ file }: FileEditorFileNameProps) => ( - {file.name} -); +const FileEditorFileName = ({ file }: FileEditorFileNameProps) => {file.name}; export default FileEditorFileName; diff --git a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx index a2973bb7a7..307b498f26 100644 --- a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -1,38 +1,36 @@ -import React, { useState, useCallback, useRef, useMemo } from 'react'; -import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack, Loader } from '@mantine/core'; -import { useIsMobile } from '@app/hooks/useIsMobile'; -import { alert } from '@app/components/toast'; -import { useTranslation } from 'react-i18next'; -import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology'; -import { useFileActionIcons } from '@app/hooks/useFileActionIcons'; -import CloseIcon from '@mui/icons-material/Close'; -import VisibilityIcon from '@mui/icons-material/Visibility'; -import UnarchiveIcon from '@mui/icons-material/Unarchive'; -import CloudUploadIcon from '@mui/icons-material/CloudUpload'; -import LinkIcon from '@mui/icons-material/Link'; -import PushPinIcon from '@mui/icons-material/PushPin'; -import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined'; -import LockOpenIcon from '@mui/icons-material/LockOpen'; -import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; -import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; -import { StirlingFileStub } from '@app/types/fileContext'; -import { zipFileService } from '@app/services/zipFileService'; - -import styles from '@app/components/fileEditor/FileEditor.module.css'; -import { useFileContext } from '@app/contexts/FileContext'; -import { useFileState } from '@app/contexts/file/fileHooks'; -import { FileId } from '@app/types/file'; -import { formatFileSize } from '@app/utils/fileUtils'; -import ToolChain from '@app/components/shared/ToolChain'; -import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu'; -import { downloadFile } from '@app/services/downloadService'; -import { PrivateContent } from '@app/components/shared/PrivateContent'; -import UploadToServerModal from '@app/components/shared/UploadToServerModal'; -import ShareFileModal from '@app/components/shared/ShareFileModal'; -import { useAppConfig } from '@app/contexts/AppConfigContext'; -import { truncateCenter } from '@app/utils/textUtils'; - +import React, { useState, useCallback, useRef, useMemo } from "react"; +import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack, Loader } from "@mantine/core"; +import { useIsMobile } from "@app/hooks/useIsMobile"; +import { alert } from "@app/components/toast"; +import { useTranslation } from "react-i18next"; +import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology"; +import { useFileActionIcons } from "@app/hooks/useFileActionIcons"; +import CloseIcon from "@mui/icons-material/Close"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import UnarchiveIcon from "@mui/icons-material/Unarchive"; +import CloudUploadIcon from "@mui/icons-material/CloudUpload"; +import LinkIcon from "@mui/icons-material/Link"; +import PushPinIcon from "@mui/icons-material/PushPin"; +import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined"; +import LockOpenIcon from "@mui/icons-material/LockOpen"; +import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; +import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { StirlingFileStub } from "@app/types/fileContext"; +import { zipFileService } from "@app/services/zipFileService"; +import styles from "@app/components/fileEditor/FileEditor.module.css"; +import { useFileContext } from "@app/contexts/FileContext"; +import { useFileState } from "@app/contexts/file/fileHooks"; +import { FileId } from "@app/types/file"; +import { formatFileSize } from "@app/utils/fileUtils"; +import ToolChain from "@app/components/shared/ToolChain"; +import HoverActionMenu, { HoverAction } from "@app/components/shared/HoverActionMenu"; +import { downloadFile } from "@app/services/downloadService"; +import { PrivateContent } from "@app/components/shared/PrivateContent"; +import UploadToServerModal from "@app/components/shared/UploadToServerModal"; +import ShareFileModal from "@app/components/shared/ShareFileModal"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { truncateCenter } from "@app/utils/textUtils"; interface FileEditorThumbnailProps { file: StirlingFileStub; @@ -69,14 +67,7 @@ const FileEditorThumbnail = ({ const terminology = useFileActionTerminology(); const icons = useFileActionIcons(); const DownloadOutlinedIcon = icons.download; - const { - pinFile, - unpinFile, - isFilePinned, - activeFiles, - actions: fileActions, - openEncryptedUnlockPrompt, - } = useFileContext(); + const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions, openEncryptedUnlockPrompt } = useFileContext(); const { state, selectors } = useFileState(); const hasError = state.ui.errorFileIds.includes(file.id); @@ -93,7 +84,7 @@ const FileEditorThumbnail = ({ // Resolve the actual File object for pin/unpin operations const actualFile = useMemo(() => { - return activeFiles.find(f => f.fileId === file.id); + return activeFiles.find((f) => f.fileId === file.id); }, [activeFiles, file.id]); const isPinned = actualFile ? isFilePinned(actualFile) : false; @@ -114,17 +105,17 @@ const FileEditorThumbnail = ({ }, [file.size]); const extUpper = useMemo(() => { - const m = /\.([a-z0-9]+)$/i.exec(file.name ?? ''); - return (m?.[1] || '').toUpperCase(); + const m = /\.([a-z0-9]+)$/i.exec(file.name ?? ""); + return (m?.[1] || "").toUpperCase(); }, [file.name]); const extLower = useMemo(() => { - const m = /\.([a-z0-9]+)$/i.exec(file.name ?? ''); - return (m?.[1] || '').toLowerCase(); + const m = /\.([a-z0-9]+)$/i.exec(file.name ?? ""); + return (m?.[1] || "").toLowerCase(); }, [file.name]); - const isCBZ = extLower === 'cbz'; - const isCBR = extLower === 'cbr'; + const isCBZ = extLower === "cbz"; + const isCBR = extLower === "cbr"; const uploadEnabled = config?.storageEnabled === true; const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true; const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true; @@ -137,71 +128,68 @@ const FileEditorThumbnail = ({ const canUpload = uploadEnabled && isOwnedOrLocal && file.isLeaf && (!isUploaded || !isUpToDate); const canShare = shareLinksEnabled && isOwnedOrLocal && file.isLeaf; - const pageLabel = useMemo( - () => - pageCount > 0 - ? `${pageCount} ${pageCount === 1 ? 'Page' : 'Pages'}` - : '', - [pageCount] - ); + const pageLabel = useMemo(() => (pageCount > 0 ? `${pageCount} ${pageCount === 1 ? "Page" : "Pages"}` : ""), [pageCount]); const dateLabel = useMemo(() => { const d = new Date(file.lastModified); - if (Number.isNaN(d.getTime())) return ''; + if (Number.isNaN(d.getTime())) return ""; return new Intl.DateTimeFormat(undefined, { - month: 'short', - day: '2-digit', - year: 'numeric', + month: "short", + day: "2-digit", + year: "numeric", }).format(d); }, [file.lastModified]); // ---- Drag & drop wiring ---- - const fileElementRef = useCallback((element: HTMLDivElement | null) => { - if (!element) return; + const fileElementRef = useCallback( + (element: HTMLDivElement | null) => { + if (!element) return; - dragElementRef.current = element; + dragElementRef.current = element; - const dragCleanup = draggable({ - element, - getInitialData: () => ({ - type: 'file', - fileId: file.id, - fileName: file.name, - selectedFiles: [file.id] // Always drag only this file, ignore selection state - }), - onDragStart: () => { - setIsDragging(true); - }, - onDrop: () => { - setIsDragging(false); - } - }); + const dragCleanup = draggable({ + element, + getInitialData: () => ({ + type: "file", + fileId: file.id, + fileName: file.name, + selectedFiles: [file.id], // Always drag only this file, ignore selection state + }), + onDragStart: () => { + setIsDragging(true); + }, + onDrop: () => { + setIsDragging(false); + }, + }); - const dropCleanup = dropTargetForElements({ - element, - getData: () => ({ - type: 'file', - fileId: file.id - }), - canDrop: ({ source }) => { - const sourceData = source.data; - return sourceData.type === 'file' && sourceData.fileId !== file.id; - }, - onDrop: ({ source }) => { - const sourceData = source.data; - if (sourceData.type === 'file' && onReorderFiles) { - const sourceFileId = sourceData.fileId as FileId; - const selectedFileIds = sourceData.selectedFiles as FileId[]; - onReorderFiles(sourceFileId, file.id, selectedFileIds); - } - } - }); + const dropCleanup = dropTargetForElements({ + element, + getData: () => ({ + type: "file", + fileId: file.id, + }), + canDrop: ({ source }) => { + const sourceData = source.data; + return sourceData.type === "file" && sourceData.fileId !== file.id; + }, + onDrop: ({ source }) => { + const sourceData = source.data; + if (sourceData.type === "file" && onReorderFiles) { + const sourceFileId = sourceData.fileId as FileId; + const selectedFileIds = sourceData.selectedFiles as FileId[]; + onReorderFiles(sourceFileId, file.id, selectedFileIds); + } + }, + }); - return () => { - dragCleanup(); - dropCleanup(); - }; - }, [file.id, file.name, selectedFiles, onReorderFiles]); + return () => { + dragCleanup(); + dropCleanup(); + }; + }, + [file.id, file.name, selectedFiles, onReorderFiles], + ); // Handle close with confirmation const handleCloseWithConfirmation = useCallback(() => { @@ -210,7 +198,7 @@ const FileEditorThumbnail = ({ const handleConfirmClose = useCallback(() => { onCloseFile(file.id); - alert({ alertType: 'neutral', title: `Closed ${file.name}`, expandable: false, durationMs: 3500 }); + alert({ alertType: "neutral", title: `Closed ${file.name}`, expandable: false, durationMs: 3500 }); setShowCloseModal(false); }, [file.id, file.name, onCloseFile]); @@ -221,12 +209,12 @@ const FileEditorThumbnail = ({ const result = await downloadFile({ data: fileToSave, filename: file.name, - localPath: file.localFilePath + localPath: file.localFilePath, }); if (!result.cancelled && result.savedPath) { fileActions.updateStirlingFileStub(file.id, { localFilePath: file.localFilePath ?? result.savedPath, - isDirty: false + isDirty: false, }); } else if (result.cancelled) { setShowCloseModal(false); @@ -234,14 +222,14 @@ const FileEditorThumbnail = ({ } } catch (error) { console.error(`Failed to save ${file.name}:`, error); - alert({ alertType: 'error', title: 'Save failed', body: `Could not save ${file.name}`, expandable: true }); + alert({ alertType: "error", title: "Save failed", body: `Could not save ${file.name}`, expandable: true }); setShowCloseModal(false); return; } } // Then close onCloseFile(file.id); - alert({ alertType: 'success', title: `Saved and closed ${file.name}`, expandable: false, durationMs: 3500 }); + alert({ alertType: "success", title: `Saved and closed ${file.name}`, expandable: false, durationMs: 3500 }); setShowCloseModal(false); }, [file.id, file.name, file.localFilePath, onCloseFile, selectors, fileActions]); @@ -250,95 +238,110 @@ const FileEditorThumbnail = ({ }, []); // Build hover menu actions - const hoverActions = useMemo(() => [ - { - id: 'view', - icon: , - label: t('openInViewer', 'Open in Viewer'), - onClick: (e) => { - e.stopPropagation(); - onViewFile(file.id); + const hoverActions = useMemo( + () => [ + { + id: "view", + icon: , + label: t("openInViewer", "Open in Viewer"), + onClick: (e) => { + e.stopPropagation(); + onViewFile(file.id); + }, }, - }, - { - id: 'download', - icon: , - label: terminology.download, - onClick: (e) => { - e.stopPropagation(); - onDownloadFile(file.id); + { + id: "download", + icon: , + label: terminology.download, + onClick: (e) => { + e.stopPropagation(); + onDownloadFile(file.id); + }, }, - }, - ...(canUpload || canShare - ? [ - ...(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: 'unzip', - icon: , - label: t('fileManager.unzip', 'Unzip'), - onClick: (e) => { - e.stopPropagation(); - if (onUnzipFile) { - onUnzipFile(file.id); - alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 }); - } + ...(canUpload || canShare + ? [ + ...(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: "unzip", + icon: , + label: t("fileManager.unzip", "Unzip"), + onClick: (e) => { + e.stopPropagation(); + if (onUnzipFile) { + onUnzipFile(file.id); + alert({ alertType: "success", title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 }); + } + }, + hidden: !isZipFile || !onUnzipFile || isCBZ || isCBR, }, - hidden: !isZipFile || !onUnzipFile || isCBZ || isCBR, - }, - { - id: 'close', - icon: , - label: t('close', 'Close'), - onClick: (e) => { - e.stopPropagation(); - handleCloseWithConfirmation(); + { + id: "close", + icon: , + label: t("close", "Close"), + onClick: (e) => { + e.stopPropagation(); + handleCloseWithConfirmation(); + }, + color: "red", }, - color: 'red', - } - ], [ - t, - file.id, - file.name, - isZipFile, - isCBZ, - isCBR, - terminology, - onViewFile, - onDownloadFile, - onUnzipFile, - handleCloseWithConfirmation, - canUpload, - canShare, - isUploaded - ]); + ], + [ + t, + file.id, + file.name, + isZipFile, + isCBZ, + isCBR, + terminology, + onViewFile, + onDownloadFile, + onUnzipFile, + handleCloseWithConfirmation, + canUpload, + canShare, + isUploaded, + ], + ); // ---- 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); } catch (_e) { void _e; } + try { + fileActions.clearFileError(file.id); + } catch (_e) { + void _e; + } } if (isSharedFile && !sharedEditNoticeShownRef.current) { sharedEditNoticeShownRef.current = true; @@ -359,7 +362,6 @@ const FileEditorThumbnail = ({ return isSelected ? styles.headerSelected : styles.headerResting; }; - return (
{/* Header bar */} -
+
{/* Logo/checkbox area */}
{hasError ? (
- {t('error._value', 'Error')} + {t("error._value", "Error")}
) : isSupported ? ( ) : (
- - {t('unsupported', 'Unsupported')} - + {t("unsupported", "Unsupported")}
)}
@@ -412,9 +409,9 @@ const FileEditorThumbnail = ({ {/* Action buttons group */}
{isEncrypted && ( - + { @@ -427,9 +424,17 @@ const FileEditorThumbnail = ({ )} {/* Pin/Unpin icon */} - + + style={{ + padding: "0.5rem", + textAlign: "center", + background: "var(--file-card-bg)", + marginTop: "0.5rem", + marginBottom: "0.5rem", + }} + > {truncateCenter(file.name, 40)} - + {/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */} {`v${file.versionNumber} - `} {dateLabel} - {extUpper ? ` - ${extUpper} file` : ''} - {pageLabel ? ` - ${pageLabel}` : ''} + {extUpper ? ` - ${extUpper} file` : ""} + {pageLabel ? ` - ${pageLabel}` : ""}
{/* Preview area */}
{file.thumbnailUrl ? ( @@ -495,27 +495,29 @@ const FileEditorThumbnail = ({ decoding="async" onError={(e) => { const img = e.currentTarget; - img.style.display = 'none'; - img.parentElement?.setAttribute('data-thumb-missing', 'true'); + 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' - }} - /> + maxWidth: "80%", + maxHeight: "80%", + objectFit: "contain", + borderRadius: 0, + background: "#ffffff", + border: "1px solid var(--border-default)", + display: "block", + marginLeft: "auto", + marginRight: "auto", + alignSelf: "start", + }} + /> - ) : file.type?.startsWith('application/pdf') ? ( - + ) : file.type?.startsWith("application/pdf") ? ( + - Loading thumbnail... + + Loading thumbnail... + ) : null}
@@ -527,74 +529,72 @@ const FileEditorThumbnail = ({ {/* Tool chain display at bottom */} {file.toolHistory && ( -
+
)}
{/* Hover Menu */} - + {/* Close Confirmation Modal */} {file.isDirty && file.localFilePath ? ( <> - {t('confirmCloseUnsaved', 'This file has unsaved changes.')} + {t("confirmCloseUnsaved", "This file has unsaved changes.")} {file.name} ) : ( <> - {t('confirmCloseMessage', 'Are you sure you want to close this file?')} + {t("confirmCloseMessage", "Are you sure you want to close this file?")} {file.name} @@ -604,39 +604,27 @@ const FileEditorThumbnail = ({ setShowSharedEditNotice(false)} - title={t('fileManager.sharedEditNoticeTitle', 'Read-only server copy')} + title={t("fileManager.sharedEditNoticeTitle", "Read-only server copy")} centered size="auto" > {t( - 'fileManager.sharedEditNoticeBody', - 'You do not have edit rights to the server version of this file. Any edits you make will be saved as a local copy.' + "fileManager.sharedEditNoticeBody", + "You do not have edit rights to the server version of this file. Any edits you make will be saved as a local copy.", )} - {canUpload && ( - setShowUploadModal(false)} - file={file} - /> - )} - {canShare && ( - setShowShareModal(false)} - file={file} - /> - )} + {canUpload && setShowUploadModal(false)} file={file} />} + {canShare && setShowShareModal(false)} file={file} />}
); }; diff --git a/frontend/src/core/components/fileEditor/fileEditorRightRailButtons.tsx b/frontend/src/core/components/fileEditor/fileEditorRightRailButtons.tsx index 122023af6f..95de665902 100644 --- a/frontend/src/core/components/fileEditor/fileEditorRightRailButtons.tsx +++ b/frontend/src/core/components/fileEditor/fileEditorRightRailButtons.tsx @@ -1,7 +1,7 @@ -import { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useRightRailButtons, RightRailButtonWithAction } from '@app/hooks/useRightRailButtons'; -import LocalIcon from '@app/components/shared/LocalIcon'; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useRightRailButtons, RightRailButtonWithAction } from "@app/hooks/useRightRailButtons"; +import LocalIcon from "@app/components/shared/LocalIcon"; interface FileEditorRightRailButtonsParams { totalItems: number; @@ -20,41 +20,44 @@ export function useFileEditorRightRailButtons({ }: FileEditorRightRailButtonsParams) { const { t, i18n } = useTranslation(); - const buttons = useMemo(() => [ - { - id: 'file-select-all', - icon: , - tooltip: t('rightRail.selectAll', 'Select All'), - ariaLabel: typeof t === 'function' ? t('rightRail.selectAll', 'Select All') : 'Select All', - section: 'top' as const, - order: 10, - disabled: totalItems === 0 || selectedCount === totalItems, - visible: totalItems > 0, - onClick: onSelectAll, - }, - { - id: 'file-deselect-all', - icon: , - tooltip: t('rightRail.deselectAll', 'Deselect All'), - ariaLabel: typeof t === 'function' ? t('rightRail.deselectAll', 'Deselect All') : 'Deselect All', - section: 'top' as const, - order: 20, - disabled: selectedCount === 0, - visible: totalItems > 0, - onClick: onDeselectAll, - }, - { - id: 'file-close-selected', - icon: , - tooltip: t('rightRail.closeSelected', 'Close Selected Files'), - ariaLabel: typeof t === 'function' ? t('rightRail.closeSelected', 'Close Selected Files') : 'Close Selected Files', - section: 'top' as const, - order: 30, - disabled: selectedCount === 0, - visible: totalItems > 0, - onClick: onCloseSelected, - }, - ], [t, i18n.language, totalItems, selectedCount, onSelectAll, onDeselectAll, onCloseSelected]); + const buttons = useMemo( + () => [ + { + id: "file-select-all", + icon: , + tooltip: t("rightRail.selectAll", "Select All"), + ariaLabel: typeof t === "function" ? t("rightRail.selectAll", "Select All") : "Select All", + section: "top" as const, + order: 10, + disabled: totalItems === 0 || selectedCount === totalItems, + visible: totalItems > 0, + onClick: onSelectAll, + }, + { + id: "file-deselect-all", + icon: , + tooltip: t("rightRail.deselectAll", "Deselect All"), + ariaLabel: typeof t === "function" ? t("rightRail.deselectAll", "Deselect All") : "Deselect All", + section: "top" as const, + order: 20, + disabled: selectedCount === 0, + visible: totalItems > 0, + onClick: onDeselectAll, + }, + { + id: "file-close-selected", + icon: , + tooltip: t("rightRail.closeSelected", "Close Selected Files"), + ariaLabel: typeof t === "function" ? t("rightRail.closeSelected", "Close Selected Files") : "Close Selected Files", + section: "top" as const, + order: 30, + disabled: selectedCount === 0, + visible: totalItems > 0, + onClick: onCloseSelected, + }, + ], + [t, i18n.language, totalItems, selectedCount, onSelectAll, onDeselectAll, onCloseSelected], + ); useRightRailButtons(buttons); } diff --git a/frontend/src/core/components/fileManager/CompactFileDetails.tsx b/frontend/src/core/components/fileManager/CompactFileDetails.tsx index 5156dccff9..9e60b8d8e6 100644 --- a/frontend/src/core/components/fileManager/CompactFileDetails.tsx +++ b/frontend/src/core/components/fileManager/CompactFileDetails.tsx @@ -1,12 +1,12 @@ -import React from 'react'; -import { Stack, Box, Text, Button, ActionIcon, Center } from '@mantine/core'; -import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; -import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; -import ChevronRightIcon from '@mui/icons-material/ChevronRight'; -import { useTranslation } from 'react-i18next'; -import { getFileSize } from '@app/utils/fileUtils'; -import { StirlingFileStub } from '@app/types/fileContext'; -import { PrivateContent } from '@app/components/shared/PrivateContent'; +import React from "react"; +import { Stack, Box, Text, Button, ActionIcon, Center } from "@mantine/core"; +import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf"; +import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; +import { useTranslation } from "react-i18next"; +import { getFileSize } from "@app/utils/fileUtils"; +import { StirlingFileStub } from "@app/types/fileContext"; +import { PrivateContent } from "@app/components/shared/PrivateContent"; interface CompactFileDetailsProps { currentFile: StirlingFileStub | null; @@ -29,47 +29,56 @@ const CompactFileDetails: React.FC = ({ isAnimating, onPrevious, onNext, - onOpenFiles + onOpenFiles, }) => { const { t } = useTranslation(); const hasSelection = selectedFiles.length > 0; const hasMultipleFiles = numberOfFiles > 1; const showOwner = Boolean( - currentFile && - (currentFile.remoteOwnedByCurrentUser === false || currentFile.remoteSharedViaLink) + currentFile && (currentFile.remoteOwnedByCurrentUser === false || currentFile.remoteSharedViaLink), ); - const ownerLabel = currentFile - ? currentFile.remoteOwnerUsername || t('fileManager.ownerUnknown', 'Unknown') - : ''; + const ownerLabel = currentFile ? currentFile.remoteOwnerUsername || t("fileManager.ownerUnknown", "Unknown") : ""; return ( - + {/* Compact mobile layout */} - + {/* Small preview */} - + {currentFile && thumbnail ? ( {currentFile.name} ) : currentFile ? ( -
- +
+
) : null} @@ -77,10 +86,10 @@ const CompactFileDetails: React.FC = ({ {/* File info */} - {currentFile ? currentFile.name : 'No file selected'} + {currentFile ? currentFile.name : "No file selected"} - {currentFile ? getFileSize(currentFile) : ''} + {currentFile ? getFileSize(currentFile) : ""} {selectedFiles.length > 1 && ` • ${selectedFiles.length} files`} {currentFile && ` • v${currentFile.versionNumber || 1}`} @@ -92,33 +101,23 @@ const CompactFileDetails: React.FC = ({ {/* Compact tool chain for mobile */} {currentFile?.toolHistory && currentFile.toolHistory.length > 0 && ( - {currentFile.toolHistory.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId)).join(' → ')} + {currentFile.toolHistory.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId)).join(" → ")} )} {currentFile && showOwner && ( - {t('fileManager.owner', 'Owner')}: {ownerLabel} + {t("fileManager.owner", "Owner")}: {ownerLabel} )} {/* Navigation arrows for multiple files */} {hasMultipleFiles && ( - - + + - + @@ -132,14 +131,13 @@ const CompactFileDetails: React.FC = ({ disabled={!hasSelection} fullWidth style={{ - backgroundColor: hasSelection ? 'var(--btn-open-file)' : 'var(--mantine-color-gray-4)', - color: 'white' + backgroundColor: hasSelection ? "var(--btn-open-file)" : "var(--mantine-color-gray-4)", + color: "white", }} > {selectedFiles.length > 1 - ? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`) - : t('fileManager.openFile', 'Open File') - } + ? t("fileManager.openFiles", `Open ${selectedFiles.length} Files`) + : t("fileManager.openFile", "Open File")} ); diff --git a/frontend/src/core/components/fileManager/DesktopLayout.tsx b/frontend/src/core/components/fileManager/DesktopLayout.tsx index 9926592c84..057b70c021 100644 --- a/frontend/src/core/components/fileManager/DesktopLayout.tsx +++ b/frontend/src/core/components/fileManager/DesktopLayout.tsx @@ -1,79 +1,85 @@ -import React from 'react'; -import { Grid } from '@mantine/core'; -import FileSourceButtons from '@app/components/fileManager/FileSourceButtons'; -import FileDetails from '@app/components/fileManager/FileDetails'; -import SearchInput from '@app/components/fileManager/SearchInput'; -import FileListArea from '@app/components/fileManager/FileListArea'; -import FileActions from '@app/components/fileManager/FileActions'; -import HiddenFileInput from '@app/components/fileManager/HiddenFileInput'; -import { useFileManagerContext } from '@app/contexts/FileManagerContext'; +import React from "react"; +import { Grid } from "@mantine/core"; +import FileSourceButtons from "@app/components/fileManager/FileSourceButtons"; +import FileDetails from "@app/components/fileManager/FileDetails"; +import SearchInput from "@app/components/fileManager/SearchInput"; +import FileListArea from "@app/components/fileManager/FileListArea"; +import FileActions from "@app/components/fileManager/FileActions"; +import HiddenFileInput from "@app/components/fileManager/HiddenFileInput"; +import { useFileManagerContext } from "@app/contexts/FileManagerContext"; const DesktopLayout: React.FC = () => { - const { - activeSource, - recentFiles, - modalHeight, - } = useFileManagerContext(); + const { activeSource, recentFiles, modalHeight } = useFileManagerContext(); return ( - + {/* Column 1: File Sources */} - + {/* Column 2: File List */} - -
- {activeSource === 'recent' && ( + +
+ {activeSource === "recent" && ( <> -
+
-
+
)} -
+
0 - ? `calc(${modalHeight} - 7rem)` - : '100%'} + scrollAreaHeight={activeSource === "recent" && recentFiles.length > 0 ? `calc(${modalHeight} - 7rem)` : "100%"} scrollAreaStyle={{ - height: activeSource === 'recent' && recentFiles.length > 0 - ? `calc(${modalHeight} - 7rem)` - : '100%', - backgroundColor: 'transparent', - border: 'none', - borderRadius: 0 + height: activeSource === "recent" && recentFiles.length > 0 ? `calc(${modalHeight} - 7rem)` : "100%", + backgroundColor: "transparent", + border: "none", + borderRadius: 0, }} />
@@ -81,14 +87,18 @@ const DesktopLayout: React.FC = () => { {/* Column 3: File Details */} - -
+ +
diff --git a/frontend/src/core/components/fileManager/DragOverlay.tsx b/frontend/src/core/components/fileManager/DragOverlay.tsx index 976bb940e9..de3409d90d 100644 --- a/frontend/src/core/components/fileManager/DragOverlay.tsx +++ b/frontend/src/core/components/fileManager/DragOverlay.tsx @@ -1,7 +1,7 @@ -import React from 'react'; -import { Stack, Text, useMantineTheme, alpha } from '@mantine/core'; -import UploadFileIcon from '@mui/icons-material/UploadFile'; -import { useTranslation } from 'react-i18next'; +import React from "react"; +import { Stack, Text, useMantineTheme, alpha } from "@mantine/core"; +import UploadFileIcon from "@mui/icons-material/UploadFile"; +import { useTranslation } from "react-i18next"; interface DragOverlayProps { isVisible: boolean; @@ -16,29 +16,29 @@ const DragOverlay: React.FC = ({ isVisible }) => { return (
- + - {t('fileManager.dropFilesHere', 'Drop files here to upload')} + {t("fileManager.dropFilesHere", "Drop files here to upload")}
); }; -export default DragOverlay; \ No newline at end of file +export default DragOverlay; diff --git a/frontend/src/core/components/fileManager/EmptyFilesState.tsx b/frontend/src/core/components/fileManager/EmptyFilesState.tsx index 24e79fb120..b774f06042 100644 --- a/frontend/src/core/components/fileManager/EmptyFilesState.tsx +++ b/frontend/src/core/components/fileManager/EmptyFilesState.tsx @@ -1,12 +1,12 @@ -import React, { useState } from 'react'; -import { Button, Group, Text, Stack, useMantineColorScheme } from '@mantine/core'; -import HistoryIcon from '@mui/icons-material/History'; -import { useTranslation } from 'react-i18next'; -import { useFileManagerContext } from '@app/contexts/FileManagerContext'; -import LocalIcon from '@app/components/shared/LocalIcon'; -import { useLogoAssets } from '@app/hooks/useLogoAssets'; -import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology'; -import { useFileActionIcons } from '@app/hooks/useFileActionIcons'; +import React, { useState } from "react"; +import { Button, Group, Text, Stack, useMantineColorScheme } from "@mantine/core"; +import HistoryIcon from "@mui/icons-material/History"; +import { useTranslation } from "react-i18next"; +import { useFileManagerContext } from "@app/contexts/FileManagerContext"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { useLogoAssets } from "@app/hooks/useLogoAssets"; +import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology"; +import { useFileActionIcons } from "@app/hooks/useFileActionIcons"; const EmptyFilesState: React.FC = () => { const { t } = useTranslation(); @@ -24,92 +24,90 @@ const EmptyFilesState: React.FC = () => { return (
{/* Container */}
{/* No Recent Files Message */} - + - {t('fileManager.noRecentFiles', 'No recent files')} + {t("fileManager.noRecentFiles", "No recent files")} {/* Stirling PDF Logo */} Stirling PDF {/* Upload Button */}
setIsUploadHover(false)} >
{/* Instruction Text */} - + {terminology.dropFilesHere}
diff --git a/frontend/src/core/components/fileManager/FileActions.tsx b/frontend/src/core/components/fileManager/FileActions.tsx index 2e69c32d35..e39113fe6d 100644 --- a/frontend/src/core/components/fileManager/FileActions.tsx +++ b/frontend/src/core/components/fileManager/FileActions.tsx @@ -30,9 +30,8 @@ const FileActions: React.FC = () => { onDownloadSelected, refreshRecentFiles, storageFilter, - onStorageFilterChange - } = - useFileManagerContext(); + onStorageFilterChange, + } = useFileManagerContext(); const uploadEnabled = config?.storageEnabled === true; const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true; const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true; @@ -42,11 +41,11 @@ const FileActions: React.FC = () => { { value: "all", label: t("fileManager.filterAll", "All") }, { value: "local", label: t("fileManager.filterLocal", "Local") }, { value: "sharedWithMe", label: t("fileManager.filterSharedWithMe", "Shared with me") }, - { value: "sharedByMe", label: t("fileManager.filterSharedByMe", "Shared by me") } + { value: "sharedByMe", label: t("fileManager.filterSharedByMe", "Shared by me") }, ] : [ { value: "all", label: t("fileManager.filterAll", "All") }, - { value: "local", label: t("fileManager.filterLocal", "Local") } + { value: "local", label: t("fileManager.filterLocal", "Local") }, ]; useEffect(() => { if (!sharingEnabled && (storageFilter === "sharedWithMe" || storageFilter === "sharedByMe")) { @@ -56,10 +55,8 @@ const FileActions: React.FC = () => { const hasSelection = selectedFileIds.length > 0; const hasOnlyOwnedSelection = selectedFiles.every((file) => file.remoteOwnedByCurrentUser !== false); const hasDownloadAccess = selectedFiles.every((file) => { - const role = (file.remoteOwnedByCurrentUser !== false - ? 'editor' - : (file.remoteAccessRole ?? 'viewer')).toLowerCase(); - return role === 'editor' || role === 'commenter' || role === 'viewer'; + const role = (file.remoteOwnedByCurrentUser !== false ? "editor" : (file.remoteAccessRole ?? "viewer")).toLowerCase(); + return role === "editor" || role === "commenter" || role === "viewer"; }); const canBulkUpload = uploadEnabled && hasSelection && hasOnlyOwnedSelection; const canBulkShare = shareLinksEnabled && hasSelection && hasOnlyOwnedSelection; @@ -80,7 +77,6 @@ const FileActions: React.FC = () => { } }; - // Only show actions if there are files if (recentFiles.length === 0) { return null; @@ -120,9 +116,7 @@ const FileActions: React.FC = () => { - onStorageFilterChange(value as "all" | "local" | "sharedWithMe" | "sharedByMe") - } + onChange={(value) => onStorageFilterChange(value as "all" | "local" | "sharedWithMe" | "sharedByMe")} data={storageFilterOptions} /> )} diff --git a/frontend/src/core/components/fileManager/FileDetails.tsx b/frontend/src/core/components/fileManager/FileDetails.tsx index c2afc47769..969b98e699 100644 --- a/frontend/src/core/components/fileManager/FileDetails.tsx +++ b/frontend/src/core/components/fileManager/FileDetails.tsx @@ -1,19 +1,17 @@ -import React, { useEffect, useState } from 'react'; -import { Stack, Button, Box } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import { useIndexedDBThumbnail } from '@app/hooks/useIndexedDBThumbnail'; -import { useFileManagerContext } from '@app/contexts/FileManagerContext'; -import FilePreview from '@app/components/shared/FilePreview'; -import FileInfoCard from '@app/components/fileManager/FileInfoCard'; -import CompactFileDetails from '@app/components/fileManager/CompactFileDetails'; +import React, { useEffect, useState } from "react"; +import { Stack, Button, Box } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { useIndexedDBThumbnail } from "@app/hooks/useIndexedDBThumbnail"; +import { useFileManagerContext } from "@app/contexts/FileManagerContext"; +import FilePreview from "@app/components/shared/FilePreview"; +import FileInfoCard from "@app/components/fileManager/FileInfoCard"; +import CompactFileDetails from "@app/components/fileManager/CompactFileDetails"; interface FileDetailsProps { compact?: boolean; } -const FileDetails: React.FC = ({ - compact = false -}) => { +const FileDetails: React.FC = ({ compact = false }) => { const { selectedFiles, onOpenFiles, modalHeight } = useFileManagerContext(); const { t } = useTranslation(); const [currentFileIndex, setCurrentFileIndex] = useState(0); @@ -35,7 +33,7 @@ const FileDetails: React.FC = ({ if (isAnimating) return; setIsAnimating(true); setTimeout(() => { - setCurrentFileIndex(prev => prev > 0 ? prev - 1 : selectedFiles.length - 1); + setCurrentFileIndex((prev) => (prev > 0 ? prev - 1 : selectedFiles.length - 1)); setIsAnimating(false); }, 150); }; @@ -44,7 +42,7 @@ const FileDetails: React.FC = ({ if (isAnimating) return; setIsAnimating(true); setTimeout(() => { - setCurrentFileIndex(prev => prev < selectedFiles.length - 1 ? prev + 1 : 0); + setCurrentFileIndex((prev) => (prev < selectedFiles.length - 1 ? prev + 1 : 0)); setIsAnimating(false); }, 150); }; @@ -75,7 +73,7 @@ const FileDetails: React.FC = ({ return ( {/* Section 1: Thumbnail Preview */} - + = ({ {/* Section 2: File Details */} - + ); diff --git a/frontend/src/core/components/fileManager/FileHistoryGroup.tsx b/frontend/src/core/components/fileManager/FileHistoryGroup.tsx index 75d8f0e50d..58edbc46cc 100644 --- a/frontend/src/core/components/fileManager/FileHistoryGroup.tsx +++ b/frontend/src/core/components/fileManager/FileHistoryGroup.tsx @@ -1,8 +1,8 @@ -import React from 'react'; -import { Box, Text, Collapse, Group } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import { StirlingFileStub } from '@app/types/fileContext'; -import FileListItem from '@app/components/fileManager/FileListItem'; +import React from "react"; +import { Box, Text, Collapse, Group } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { StirlingFileStub } from "@app/types/fileContext"; +import FileListItem from "@app/components/fileManager/FileListItem"; interface FileHistoryGroupProps { leafFile: StirlingFileStub; @@ -27,7 +27,7 @@ const FileHistoryGroup: React.FC = ({ // Sort history files by version number (oldest first, excluding the current leaf file) const sortedHistory = historyFiles - .filter(file => file.id !== leafFile.id) // Exclude the leaf file itself + .filter((file) => file.id !== leafFile.id) // Exclude the leaf file itself .sort((a, b) => (b.versionNumber || 1) - (a.versionNumber || 1)); if (!isExpanded || sortedHistory.length === 0) { @@ -39,7 +39,7 @@ const FileHistoryGroup: React.FC = ({ - {t('fileManager.fileHistory', 'File History')} ({sortedHistory.length}) + {t("fileManager.fileHistory", "File History")} ({sortedHistory.length}) diff --git a/frontend/src/core/components/fileManager/FileInfoCard.tsx b/frontend/src/core/components/fileManager/FileInfoCard.tsx index 182fcded9d..1c47c71bd2 100644 --- a/frontend/src/core/components/fileManager/FileInfoCard.tsx +++ b/frontend/src/core/components/fileManager/FileInfoCard.tsx @@ -1,23 +1,20 @@ -import React, { useMemo, useState } from 'react'; -import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea, Button } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import { detectFileExtension, getFileSize } from '@app/utils/fileUtils'; -import { StirlingFileStub } from '@app/types/fileContext'; -import ToolChain from '@app/components/shared/ToolChain'; -import { PrivateContent } from '@app/components/shared/PrivateContent'; -import { useFileManagerContext } from '@app/contexts/FileManagerContext'; -import ShareManagementModal from '@app/components/shared/ShareManagementModal'; -import { useAppConfig } from '@app/contexts/AppConfigContext'; +import React, { useMemo, useState } from "react"; +import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea, Button } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { detectFileExtension, getFileSize } from "@app/utils/fileUtils"; +import { StirlingFileStub } from "@app/types/fileContext"; +import ToolChain from "@app/components/shared/ToolChain"; +import { PrivateContent } from "@app/components/shared/PrivateContent"; +import { useFileManagerContext } from "@app/contexts/FileManagerContext"; +import ShareManagementModal from "@app/components/shared/ShareManagementModal"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; interface FileInfoCardProps { currentFile: StirlingFileStub | null; modalHeight: string; } -const FileInfoCard: React.FC = ({ - currentFile, - modalHeight -}) => { +const FileInfoCard: React.FC = ({ currentFile, modalHeight }) => { const { t } = useTranslation(); const { config } = useAppConfig(); const { onMakeCopy } = useFileManagerContext(); @@ -43,38 +40,53 @@ const FileInfoCard: React.FC = ({ const uploadEnabled = config?.storageEnabled === true; const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true; const ownerLabel = useMemo(() => { - if (!currentFile) return ''; + if (!currentFile) return ""; if (currentFile.remoteOwnerUsername) { return currentFile.remoteOwnerUsername; } - return t('fileManager.ownerUnknown', 'Unknown'); + return t("fileManager.ownerUnknown", "Unknown"); }, [currentFile, t]); const lastSyncedLabel = useMemo(() => { - if (!currentFile?.remoteStorageUpdatedAt) return ''; + if (!currentFile?.remoteStorageUpdatedAt) return ""; return new Date(currentFile.remoteStorageUpdatedAt).toLocaleString(); }, [currentFile?.remoteStorageUpdatedAt]); return ( - - + + - {t('fileManager.details', 'File Details')} + {t("fileManager.details", "File Details")} - {t('fileManager.fileName', 'Name')} + {t("fileManager.fileName", "Name")} - - {currentFile ? currentFile.name : ''} + + {currentFile ? currentFile.name : ""} - {t('fileManager.fileFormat', 'Format')} + + {t("fileManager.fileFormat", "Format")} + {currentFile ? ( {detectFileExtension(currentFile.name).toUpperCase()} @@ -86,38 +98,48 @@ const FileInfoCard: React.FC = ({ - {t('fileManager.fileSize', 'Size')} + + {t("fileManager.fileSize", "Size")} + - {currentFile ? getFileSize(currentFile) : ''} + {currentFile ? getFileSize(currentFile) : ""} - {t('fileManager.lastModified', 'Last modified')} + + {t("fileManager.lastModified", "Last modified")} + - {currentFile ? new Date(currentFile.lastModified).toLocaleDateString() : ''} + {currentFile ? new Date(currentFile.lastModified).toLocaleDateString() : ""} - {t('fileManager.fileVersion', 'Version')} - {currentFile && - - v{currentFile ? (currentFile.versionNumber || 1) : ''} - } - + + {t("fileManager.fileVersion", "Version")} + + {currentFile && ( + + v{currentFile ? currentFile.versionNumber || 1 : ""} + + )} {sharingEnabled && isSharedWithYou && ( <> - {t('fileManager.owner', 'Owner')} + + {t("fileManager.owner", "Owner")} + - {ownerLabel} + + {ownerLabel} + - {t('fileManager.sharedWithYou', 'Shared with you')} + {t("fileManager.sharedWithYou", "Shared with you")} @@ -129,12 +151,10 @@ const FileInfoCard: React.FC = ({ <> - {t('fileManager.toolChain', 'Tools Applied')} - + + {t("fileManager.toolChain", "Tools Applied")} + + )} @@ -142,13 +162,8 @@ const FileInfoCard: React.FC = ({ {currentFile && isSharedWithYou && ( <> - )} @@ -157,39 +172,42 @@ const FileInfoCard: React.FC = ({ <> - {t('fileManager.cloudFile', 'Cloud file')} + + {t("fileManager.cloudFile", "Cloud file")} + {uploadEnabled && isOutOfSync ? ( - {t('fileManager.changesNotUploaded', 'Changes not uploaded')} + {t("fileManager.changesNotUploaded", "Changes not uploaded")} ) : uploadEnabled ? ( - {t('fileManager.synced', 'Synced')} + {t("fileManager.synced", "Synced")} ) : null} {lastSyncedLabel && ( - {t('fileManager.lastSynced', 'Last synced')} - {lastSyncedLabel} + + {t("fileManager.lastSynced", "Last synced")} + + + {lastSyncedLabel} + )} {isSharedByYou && sharingEnabled && ( <> - {t('fileManager.sharing', 'Sharing')} + + {t("fileManager.sharing", "Sharing")} + - {t('fileManager.sharedByYou', 'Shared by you')} + {t("fileManager.sharedByYou", "Shared by you")} - )} @@ -199,9 +217,11 @@ const FileInfoCard: React.FC = ({ <> - {t('fileManager.storageState', 'Storage')} + + {t("fileManager.storageState", "Storage")} + - {t('fileManager.localOnly', 'Local only')} + {t("fileManager.localOnly", "Local only")} diff --git a/frontend/src/core/components/fileManager/FileListArea.tsx b/frontend/src/core/components/fileManager/FileListArea.tsx index 1964dad542..b088a55d53 100644 --- a/frontend/src/core/components/fileManager/FileListArea.tsx +++ b/frontend/src/core/components/fileManager/FileListArea.tsx @@ -1,21 +1,18 @@ -import React from 'react'; -import { Center, ScrollArea, Text, Stack } from '@mantine/core'; -import CloudIcon from '@mui/icons-material/Cloud'; -import { useTranslation } from 'react-i18next'; -import FileListItem from '@app/components/fileManager/FileListItem'; -import FileHistoryGroup from '@app/components/fileManager/FileHistoryGroup'; -import EmptyFilesState from '@app/components/fileManager/EmptyFilesState'; -import { useFileManagerContext } from '@app/contexts/FileManagerContext'; +import React from "react"; +import { Center, ScrollArea, Text, Stack } from "@mantine/core"; +import CloudIcon from "@mui/icons-material/Cloud"; +import { useTranslation } from "react-i18next"; +import FileListItem from "@app/components/fileManager/FileListItem"; +import FileHistoryGroup from "@app/components/fileManager/FileHistoryGroup"; +import EmptyFilesState from "@app/components/fileManager/EmptyFilesState"; +import { useFileManagerContext } from "@app/contexts/FileManagerContext"; interface FileListAreaProps { scrollAreaHeight: string; scrollAreaStyle?: React.CSSProperties; } -const FileListArea: React.FC = ({ - scrollAreaHeight, - scrollAreaStyle = {}, -}) => { +const FileListArea: React.FC = ({ scrollAreaHeight, scrollAreaStyle = {} }) => { const { activeSource, recentFiles, @@ -34,12 +31,12 @@ const FileListArea: React.FC = ({ } = useFileManagerContext(); const { t } = useTranslation(); - if (activeSource === 'recent') { + if (activeSource === "recent") { return ( = ({ {recentFiles.length === 0 && !isLoading ? ( ) : recentFiles.length === 0 && isLoading ? ( -
- {t('fileManager.loadingFiles', 'Loading files...')} +
+ + {t("fileManager.loadingFiles", "Loading files...")} +
) : ( filteredFiles.map((file, index) => { @@ -93,10 +92,12 @@ const FileListArea: React.FC = ({ // Google Drive placeholder return ( -
+
- - {t('fileManager.googleDriveNotAvailable', 'Google Drive integration coming soon')} + + + {t("fileManager.googleDriveNotAvailable", "Google Drive integration coming soon")} +
); diff --git a/frontend/src/core/components/fileManager/FileListItem.tsx b/frontend/src/core/components/fileManager/FileListItem.tsx index 73ae8bd2a2..02c3c07f7d 100644 --- a/frontend/src/core/components/fileManager/FileListItem.tsx +++ b/frontend/src/core/components/fileManager/FileListItem.tsx @@ -1,31 +1,31 @@ -import React, { useCallback, useMemo, useState } from 'react'; -import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge } from '@mantine/core'; -import MoreVertIcon from '@mui/icons-material/MoreVert'; -import DeleteIcon from '@mui/icons-material/Delete'; -import DownloadIcon from '@mui/icons-material/Download'; -import HistoryIcon from '@mui/icons-material/History'; -import RestoreIcon from '@mui/icons-material/Restore'; -import UnarchiveIcon from '@mui/icons-material/Unarchive'; -import CloseIcon from '@mui/icons-material/Close'; -import CloudUploadIcon from '@mui/icons-material/CloudUpload'; -import CloudDoneIcon from '@mui/icons-material/CloudDone'; -import LinkIcon from '@mui/icons-material/Link'; -import { useTranslation } from 'react-i18next'; -import { getFileSize, getFileDate } from '@app/utils/fileUtils'; -import { FileId, StirlingFileStub } from '@app/types/fileContext'; -import { useFileManagerContext } from '@app/contexts/FileManagerContext'; -import { zipFileService } from '@app/services/zipFileService'; -import ToolChain from '@app/components/shared/ToolChain'; -import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex'; -import { PrivateContent } from '@app/components/shared/PrivateContent'; -import { useFileManagement } from '@app/contexts/FileContext'; -import UploadToServerModal from '@app/components/shared/UploadToServerModal'; -import ShareFileModal from '@app/components/shared/ShareFileModal'; -import { useAppConfig } from '@app/contexts/AppConfigContext'; -import ShareManagementModal from '@app/components/shared/ShareManagementModal'; -import apiClient from '@app/services/apiClient'; -import { absoluteWithBasePath } from '@app/constants/app'; -import { alert } from '@app/components/toast'; +import React, { useCallback, useMemo, useState } from "react"; +import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge } from "@mantine/core"; +import MoreVertIcon from "@mui/icons-material/MoreVert"; +import DeleteIcon from "@mui/icons-material/Delete"; +import DownloadIcon from "@mui/icons-material/Download"; +import HistoryIcon from "@mui/icons-material/History"; +import RestoreIcon from "@mui/icons-material/Restore"; +import UnarchiveIcon from "@mui/icons-material/Unarchive"; +import CloseIcon from "@mui/icons-material/Close"; +import CloudUploadIcon from "@mui/icons-material/CloudUpload"; +import CloudDoneIcon from "@mui/icons-material/CloudDone"; +import LinkIcon from "@mui/icons-material/Link"; +import { useTranslation } from "react-i18next"; +import { getFileSize, getFileDate } from "@app/utils/fileUtils"; +import { FileId, StirlingFileStub } from "@app/types/fileContext"; +import { useFileManagerContext } from "@app/contexts/FileManagerContext"; +import { zipFileService } from "@app/services/zipFileService"; +import ToolChain from "@app/components/shared/ToolChain"; +import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex"; +import { PrivateContent } from "@app/components/shared/PrivateContent"; +import { useFileManagement } from "@app/contexts/FileContext"; +import UploadToServerModal from "@app/components/shared/UploadToServerModal"; +import ShareFileModal from "@app/components/shared/ShareFileModal"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import ShareManagementModal from "@app/components/shared/ShareManagementModal"; +import apiClient from "@app/services/apiClient"; +import { absoluteWithBasePath } from "@app/constants/app"; +import { alert } from "@app/components/toast"; interface FileListItemProps { file: StirlingFileStub; @@ -51,7 +51,7 @@ const FileListItem: React.FC = ({ onDoubleClick, isHistoryFile = false, isLatestVersion = false, - isActive = false + isActive = false, }) => { const [isHovered, setIsHovered] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -60,22 +60,22 @@ const FileListItem: React.FC = ({ const [showShareManageModal, setShowShareManageModal] = useState(false); const { t } = useTranslation(); const { config } = useAppConfig(); - const {expandedFileIds, onToggleExpansion, onUnzipFile, refreshRecentFiles } = useFileManagerContext(); + const { expandedFileIds, onToggleExpansion, onUnzipFile, refreshRecentFiles } = useFileManagerContext(); const { removeFiles } = useFileManagement(); // Check if this is a ZIP file const isZipFile = zipFileService.isZipFileStub(file); // Check file extension - const extLower = (file.name?.match(/\.([a-z0-9]+)$/i)?.[1] || '').toLowerCase(); - const isCBZ = extLower === 'cbz'; - const isCBR = extLower === 'cbr'; + const extLower = (file.name?.match(/\.([a-z0-9]+)$/i)?.[1] || "").toLowerCase(); + const isCBZ = extLower === "cbz"; + const isCBR = extLower === "cbr"; // Keep item in hovered state if menu is open const shouldShowHovered = isHovered || isMenuOpen; // Get version information for this file - const leafFileId = (isLatestVersion ? file.id : (file.originalFileId || file.id)) as FileId; + const leafFileId = (isLatestVersion ? file.id : file.originalFileId || file.id) as FileId; const hasVersionHistory = (file.versionNumber || 1) > 1; // Show history for any processed file (v2+) const currentVersion = file.versionNumber || 1; // Display original files as v1 const isExpanded = expandedFileIds.has(leafFileId); @@ -83,32 +83,28 @@ const FileListItem: React.FC = ({ const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true; const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true; const isOwnedOrLocal = file.remoteOwnedByCurrentUser !== false; - const isSharedWithYou = - sharingEnabled && (file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink); + const isSharedWithYou = sharingEnabled && (file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink); const localUpdatedAt = file.createdAt ?? file.lastModified ?? 0; const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0; const isUploaded = Boolean(file.remoteStorageId); const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt; const isOutOfSync = isUploaded && !isUpToDate && isOwnedOrLocal; const isLocalOnly = !file.remoteStorageId && !file.remoteSharedViaLink; - const accessRole = (isOwnedOrLocal ? 'editor' : (file.remoteAccessRole ?? 'viewer')).toLowerCase(); - const hasReadAccess = isOwnedOrLocal || accessRole === 'editor' || accessRole === 'commenter' || accessRole === 'viewer'; + const accessRole = (isOwnedOrLocal ? "editor" : (file.remoteAccessRole ?? "viewer")).toLowerCase(); + const hasReadAccess = isOwnedOrLocal || accessRole === "editor" || accessRole === "commenter" || accessRole === "viewer"; const canUpload = uploadEnabled && isOwnedOrLocal && isLatestVersion && (!isUploaded || !isUpToDate); const canShare = shareLinksEnabled && isOwnedOrLocal && isLatestVersion; const canManageShare = sharingEnabled && isOwnedOrLocal && Boolean(file.remoteStorageId); - const canCopyShareLink = - shareLinksEnabled && Boolean(file.remoteHasShareLinks) && Boolean(file.remoteStorageId); + const canCopyShareLink = shareLinksEnabled && Boolean(file.remoteHasShareLinks) && Boolean(file.remoteStorageId); const canDownloadFile = Boolean(onDownload) && hasReadAccess; const shareBaseUrl = useMemo(() => { - const frontendUrl = (config?.frontendUrl || '').trim(); + const frontendUrl = (config?.frontendUrl || "").trim(); if (frontendUrl) { - const normalized = frontendUrl.endsWith('/') - ? frontendUrl.slice(0, -1) - : frontendUrl; + const normalized = frontendUrl.endsWith("/") ? frontendUrl.slice(0, -1) : frontendUrl; return `${normalized}/share/`; } - return absoluteWithBasePath('/share/'); + return absoluteWithBasePath("/share/"); }, [config?.frontendUrl]); const handleCopyShareLink = useCallback(async () => { @@ -116,14 +112,14 @@ const FileListItem: React.FC = ({ try { const response = await apiClient.get<{ shareLinks?: Array<{ token?: string }> }>( `/api/v1/storage/files/${file.remoteStorageId}`, - { suppressErrorToast: true } as any + { suppressErrorToast: true } as any, ); const links = response.data?.shareLinks ?? []; const token = links[links.length - 1]?.token; if (!token) { alert({ - alertType: 'warning', - title: t('storageShare.noLinks', 'No active share links yet.'), + alertType: "warning", + title: t("storageShare.noLinks", "No active share links yet."), expandable: false, durationMs: 2500, }); @@ -131,16 +127,16 @@ const FileListItem: React.FC = ({ } await navigator.clipboard.writeText(`${shareBaseUrl}${token}`); alert({ - alertType: 'success', - title: t('storageShare.copied', 'Link copied to clipboard'), + alertType: "success", + title: t("storageShare.copied", "Link copied to clipboard"), expandable: false, durationMs: 2000, }); } catch (error) { - console.error('Failed to copy share link:', error); + console.error("Failed to copy share link:", error); alert({ - alertType: 'warning', - title: t('storageShare.copyFailed', 'Copy failed'), + alertType: "warning", + title: t("storageShare.copyFailed", "Copy failed"), expandable: false, durationMs: 2500, }); @@ -152,20 +148,22 @@ const FileListItem: React.FC = ({ onSelect(e.shiftKey)} onDoubleClick={onDoubleClick} @@ -186,8 +184,8 @@ const FileListItem: React.FC = ({ color={isActive ? "green" : undefined} styles={{ input: { - cursor: isActive ? 'not-allowed' : 'pointer' - } + cursor: isActive ? "not-allowed" : "pointer", + }, }} /> @@ -203,12 +201,12 @@ const FileListItem: React.FC = ({ size="xs" variant="light" style={{ - backgroundColor: 'var(--file-active-badge-bg)', - color: 'var(--file-active-badge-fg)', - border: '1px solid var(--file-active-badge-border)' + backgroundColor: "var(--file-active-badge-bg)", + color: "var(--file-active-badge-fg)", + border: "1px solid var(--file-active-badge-border)", }} > - {t('fileManager.active', 'Active')} + {t("fileManager.active", "Active")} )} @@ -216,44 +214,33 @@ const FileListItem: React.FC = ({ {sharingEnabled && isSharedWithYou ? ( - {t('fileManager.sharedWithYou', 'Shared with you')} + {t("fileManager.sharedWithYou", "Shared with you")} ) : null} - {sharingEnabled && isSharedWithYou && accessRole && accessRole !== 'editor' ? ( + {sharingEnabled && isSharedWithYou && accessRole && accessRole !== "editor" ? ( - {accessRole === 'commenter' - ? t('storageShare.roleCommenter', 'Commenter') - : t('storageShare.roleViewer', 'Viewer')} + {accessRole === "commenter" + ? t("storageShare.roleCommenter", "Commenter") + : t("storageShare.roleViewer", "Viewer")} ) : isLocalOnly ? ( - {t('fileManager.localOnly', 'Local only')} + {t("fileManager.localOnly", "Local only")} ) : uploadEnabled && isOutOfSync ? ( - } - > - {t('fileManager.changesNotUploaded', 'Changes not uploaded')} + }> + {t("fileManager.changesNotUploaded", "Changes not uploaded")} ) : uploadEnabled && isUploaded ? ( - } - > - {t('fileManager.synced', 'Synced')} + }> + {t("fileManager.synced", "Synced")} ) : null} {sharingEnabled && file.remoteOwnedByCurrentUser !== false && file.remoteHasShareLinks && ( - {t('fileManager.sharedByYou', 'Shared by you')} + {t("fileManager.sharedByYou", "Shared by you")} )} - @@ -262,12 +249,7 @@ const FileListItem: React.FC = ({ {/* Tool chain for processed files */} {file.toolHistory && file.toolHistory.length > 0 && ( - + )} @@ -288,9 +270,9 @@ const FileListItem: React.FC = ({ onClick={(e) => e.stopPropagation()} style={{ opacity: shouldShowHovered ? 1 : 0, - transform: shouldShowHovered ? 'scale(1)' : 'scale(0.8)', - transition: 'opacity 0.3s ease, transform 0.3s ease', - pointerEvents: shouldShowHovered ? 'auto' : 'none' + transform: shouldShowHovered ? "scale(1)" : "scale(0.8)", + transition: "opacity 0.3s ease, transform 0.3s ease", + pointerEvents: shouldShowHovered ? "auto" : "none", }} > @@ -308,7 +290,7 @@ const FileListItem: React.FC = ({ removeFiles([file.id]); }} > - {t('fileManager.closeFile', 'Close File')} + {t("fileManager.closeFile", "Close File")} @@ -322,7 +304,7 @@ const FileListItem: React.FC = ({ onDownload?.(); }} > - {t('fileManager.download', 'Download')} + {t("fileManager.download", "Download")} )} @@ -335,8 +317,8 @@ const FileListItem: React.FC = ({ }} > {isUploaded - ? t('fileManager.updateOnServer', 'Update on Server') - : t('fileManager.uploadToServer', 'Upload to Server')} + ? t("fileManager.updateOnServer", "Update on Server") + : t("fileManager.uploadToServer", "Upload to Server")} )} @@ -348,7 +330,7 @@ const FileListItem: React.FC = ({ setShowShareModal(true); }} > - {t('fileManager.share', 'Share')} + {t("fileManager.share", "Share")} )} @@ -360,7 +342,7 @@ const FileListItem: React.FC = ({ void handleCopyShareLink(); }} > - {t('storageShare.copyLink', 'Copy share link')} + {t("storageShare.copyLink", "Copy share link")} )} @@ -372,7 +354,7 @@ const FileListItem: React.FC = ({ setShowShareManageModal(true); }} > - {t('storageShare.manage', 'Manage sharing')} + {t("storageShare.manage", "Manage sharing")} )} @@ -380,20 +362,13 @@ const FileListItem: React.FC = ({ {isLatestVersion && hasVersionHistory && ( <> - } + leftSection={} onClick={(e) => { e.stopPropagation(); onToggleExpansion(leafFileId); }} > - { - (isExpanded ? - t('fileManager.hideHistory', 'Hide History') : - t('fileManager.showHistory', 'Show History') - ) - } + {isExpanded ? t("fileManager.hideHistory", "Hide History") : t("fileManager.showHistory", "Show History")} @@ -408,7 +383,7 @@ const FileListItem: React.FC = ({ e.stopPropagation(); }} > - {t('fileManager.restore', 'Restore')} + {t("fileManager.restore", "Restore")} @@ -424,7 +399,7 @@ const FileListItem: React.FC = ({ onUnzipFile(file); }} > - {t('fileManager.unzip', 'Unzip')} + {t("fileManager.unzip", "Unzip")} @@ -437,14 +412,13 @@ const FileListItem: React.FC = ({ onRemove(); }} > - {t('fileManager.delete', 'Delete')} + {t("fileManager.delete", "Delete")} - - { } + {} {canUpload && ( = ({ /> )} {canManageShare && ( - setShowShareManageModal(false)} - file={file} - /> + setShowShareManageModal(false)} file={file} /> )} ); diff --git a/frontend/src/core/components/fileManager/FileSourceButtons.tsx b/frontend/src/core/components/fileManager/FileSourceButtons.tsx index 8695821efd..51076ee954 100644 --- a/frontend/src/core/components/fileManager/FileSourceButtons.tsx +++ b/frontend/src/core/components/fileManager/FileSourceButtons.tsx @@ -1,15 +1,15 @@ -import React, { useState } from 'react'; -import { Stack, Text, Button, Group } from '@mantine/core'; -import HistoryIcon from '@mui/icons-material/History'; -import PhonelinkIcon from '@mui/icons-material/Phonelink'; -import { useTranslation } from 'react-i18next'; -import { useFileManagerContext } from '@app/contexts/FileManagerContext'; -import { useGoogleDrivePicker } from '@app/hooks/useGoogleDrivePicker'; -import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology'; -import { useFileActionIcons } from '@app/hooks/useFileActionIcons'; -import { useAppConfig } from '@app/contexts/AppConfigContext'; -import { useIsMobile } from '@app/hooks/useIsMobile'; -import MobileUploadModal from '@app/components/shared/MobileUploadModal'; +import React, { useState } from "react"; +import { Stack, Text, Button, Group } from "@mantine/core"; +import HistoryIcon from "@mui/icons-material/History"; +import PhonelinkIcon from "@mui/icons-material/Phonelink"; +import { useTranslation } from "react-i18next"; +import { useFileManagerContext } from "@app/contexts/FileManagerContext"; +import { useGoogleDrivePicker } from "@app/hooks/useGoogleDrivePicker"; +import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology"; +import { useFileActionIcons } from "@app/hooks/useFileActionIcons"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useIsMobile } from "@app/hooks/useIsMobile"; +import MobileUploadModal from "@app/components/shared/MobileUploadModal"; interface FileSourceButtonsProps { horizontal?: boolean; @@ -24,17 +24,15 @@ const GoogleDriveIcon: React.FC<{ disabled?: boolean }> = ({ disabled }) => ( src="/images/google-drive.svg" alt="Google Drive" style={{ - width: '20px', - height: '20px', + width: "20px", + height: "20px", opacity: disabled ? 0.5 : 1, - filter: disabled ? 'grayscale(100%)' : 'none', + filter: disabled ? "grayscale(100%)" : "none", }} /> ); -const FileSourceButtons: React.FC = ({ - horizontal = false -}) => { +const FileSourceButtons: React.FC = ({ horizontal = false }) => { const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect, onNewFilesSelect } = useFileManagerContext(); const { t } = useTranslation(); const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker(); @@ -53,7 +51,7 @@ const FileSourceButtons: React.FC = ({ onGoogleDriveSelect(files); } } catch (error) { - console.error('Failed to pick files from Google Drive:', error); + console.error("Failed to pick files from Google Drive:", error); } }; @@ -74,18 +72,18 @@ const FileSourceButtons: React.FC = ({ const shouldHideMobileQR = !isMobileUploadEnabled && config?.hideDisabledToolsMobileQRScanner; const buttonProps = { - variant: (source: string) => activeSource === source ? 'filled' : 'subtle', - getColor: (source: string) => activeSource === source ? 'var(--mantine-color-gray-2)' : undefined, + variant: (source: string) => (activeSource === source ? "filled" : "subtle"), + getColor: (source: string) => (activeSource === source ? "var(--mantine-color-gray-2)" : undefined), getStyles: (source: string) => ({ root: { - backgroundColor: activeSource === source ? undefined : 'transparent', - color: activeSource === source ? 'var(--mantine-color-gray-9)' : 'var(--mantine-color-gray-6)', - border: 'none', - '&:hover': { - backgroundColor: activeSource === source ? undefined : 'var(--mantine-color-gray-0)' - } - } - }) + backgroundColor: activeSource === source ? undefined : "transparent", + color: activeSource === source ? "var(--mantine-color-gray-9)" : "var(--mantine-color-gray-6)", + border: "none", + "&:hover": { + backgroundColor: activeSource === source ? undefined : "var(--mantine-color-gray-0)", + }, + }, + }), }; const buttons = ( @@ -93,18 +91,18 @@ const FileSourceButtons: React.FC = ({ )} {!shouldHideMobileQR && ( )} @@ -178,7 +180,7 @@ const FileSourceButtons: React.FC = ({ if (horizontal) { return ( <> - + {buttons} = ({ return ( <> - - - {t('fileManager.myFiles', 'My Files')} + + + {t("fileManager.myFiles", "My Files")} {buttons} diff --git a/frontend/src/core/components/fileManager/HiddenFileInput.tsx b/frontend/src/core/components/fileManager/HiddenFileInput.tsx index 27482df519..fce23187cd 100644 --- a/frontend/src/core/components/fileManager/HiddenFileInput.tsx +++ b/frontend/src/core/components/fileManager/HiddenFileInput.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { useFileManagerContext } from '@app/contexts/FileManagerContext'; +import React from "react"; +import { useFileManagerContext } from "@app/contexts/FileManagerContext"; const HiddenFileInput: React.FC = () => { const { fileInputRef, onFileInputChange } = useFileManagerContext(); @@ -10,7 +10,7 @@ const HiddenFileInput: React.FC = () => { type="file" multiple={true} onChange={onFileInputChange} - style={{ display: 'none' }} + style={{ display: "none" }} data-testid="file-input" /> ); diff --git a/frontend/src/core/components/fileManager/MobileLayout.tsx b/frontend/src/core/components/fileManager/MobileLayout.tsx index 0701874852..759fb2c7f9 100644 --- a/frontend/src/core/components/fileManager/MobileLayout.tsx +++ b/frontend/src/core/components/fileManager/MobileLayout.tsx @@ -1,19 +1,15 @@ -import React from 'react'; -import { Box } from '@mantine/core'; -import FileSourceButtons from '@app/components/fileManager/FileSourceButtons'; -import FileDetails from '@app/components/fileManager/FileDetails'; -import SearchInput from '@app/components/fileManager/SearchInput'; -import FileListArea from '@app/components/fileManager/FileListArea'; -import FileActions from '@app/components/fileManager/FileActions'; -import HiddenFileInput from '@app/components/fileManager/HiddenFileInput'; -import { useFileManagerContext } from '@app/contexts/FileManagerContext'; +import React from "react"; +import { Box } from "@mantine/core"; +import FileSourceButtons from "@app/components/fileManager/FileSourceButtons"; +import FileDetails from "@app/components/fileManager/FileDetails"; +import SearchInput from "@app/components/fileManager/SearchInput"; +import FileListArea from "@app/components/fileManager/FileListArea"; +import FileActions from "@app/components/fileManager/FileActions"; +import HiddenFileInput from "@app/components/fileManager/HiddenFileInput"; +import { useFileManagerContext } from "@app/contexts/FileManagerContext"; const MobileLayout: React.FC = () => { - const { - activeSource, - selectedFiles, - modalHeight, - } = useFileManagerContext(); + const { activeSource, selectedFiles, modalHeight } = useFileManagerContext(); // Calculate the height more accurately based on actual content const calculateFileListHeight = () => { @@ -21,17 +17,17 @@ const MobileLayout: React.FC = () => { const baseHeight = `calc(${modalHeight} - 2rem)`; // Account for Stack padding // Estimate heights of fixed components - const fileSourceHeight = '3rem'; // FileSourceButtons height - const fileDetailsHeight = selectedFiles.length > 0 ? '10rem' : '8rem'; // FileDetails compact height - const fileActionsHeight = activeSource === 'recent' ? '3rem' : '0rem'; // FileActions height (now at bottom) - const searchHeight = activeSource === 'recent' ? '3rem' : '0rem'; // SearchInput height - const gapHeight = activeSource === 'recent' ? '3.75rem' : '2rem'; // Stack gaps + const fileSourceHeight = "3rem"; // FileSourceButtons height + const fileDetailsHeight = selectedFiles.length > 0 ? "10rem" : "8rem"; // FileDetails compact height + const fileActionsHeight = activeSource === "recent" ? "3rem" : "0rem"; // FileActions height (now at bottom) + const searchHeight = activeSource === "recent" ? "3rem" : "0rem"; // SearchInput height + const gapHeight = activeSource === "recent" ? "3.75rem" : "2rem"; // Stack gaps return `calc(${baseHeight} - ${fileSourceHeight} - ${fileDetailsHeight} - ${fileActionsHeight} - ${searchHeight} - ${gapHeight})`; }; return ( - + {/* Section 1: File Sources - Fixed at top */} @@ -42,28 +38,34 @@ const MobileLayout: React.FC = () => { {/* Section 3 & 4: Search Bar + File List - Unified background extending to modal edge */} - - {activeSource === 'recent' && ( + + {activeSource === "recent" && ( <> - + - + @@ -74,11 +76,11 @@ const MobileLayout: React.FC = () => { scrollAreaHeight={calculateFileListHeight()} scrollAreaStyle={{ height: calculateFileListHeight(), - maxHeight: '60vh', - minHeight: '9.375rem', - backgroundColor: 'transparent', - border: 'none', - borderRadius: 0 + maxHeight: "60vh", + minHeight: "9.375rem", + backgroundColor: "transparent", + border: "none", + borderRadius: 0, }} /> diff --git a/frontend/src/core/components/fileManager/SearchInput.tsx b/frontend/src/core/components/fileManager/SearchInput.tsx index 2b318604c6..b7dbf9306c 100644 --- a/frontend/src/core/components/fileManager/SearchInput.tsx +++ b/frontend/src/core/components/fileManager/SearchInput.tsx @@ -1,8 +1,8 @@ -import React from 'react'; -import { TextInput } from '@mantine/core'; -import SearchIcon from '@mui/icons-material/Search'; -import { useTranslation } from 'react-i18next'; -import { useFileManagerContext } from '@app/contexts/FileManagerContext'; +import React from "react"; +import { TextInput } from "@mantine/core"; +import SearchIcon from "@mui/icons-material/Search"; +import { useTranslation } from "react-i18next"; +import { useFileManagerContext } from "@app/contexts/FileManagerContext"; interface SearchInputProps { style?: React.CSSProperties; @@ -14,20 +14,19 @@ const SearchInput: React.FC = ({ style }) => { return ( } value={searchTerm} onChange={(e) => onSearchChange(e.target.value)} - - style={{ padding: '0.5rem', ...style }} + style={{ padding: "0.5rem", ...style }} styles={{ input: { - border: 'none', - backgroundColor: 'transparent' - } + border: "none", + backgroundColor: "transparent", + }, }} /> ); }; -export default SearchInput; \ No newline at end of file +export default SearchInput; diff --git a/frontend/src/core/components/hotkeys/HotkeyDisplay.tsx b/frontend/src/core/components/hotkeys/HotkeyDisplay.tsx index 7f6d9f26dd..357b4a2f4a 100644 --- a/frontend/src/core/components/hotkeys/HotkeyDisplay.tsx +++ b/frontend/src/core/components/hotkeys/HotkeyDisplay.tsx @@ -1,29 +1,29 @@ -import React from 'react'; -import { HotkeyBinding } from '@app/utils/hotkeys'; -import { useHotkeys } from '@app/contexts/HotkeyContext'; +import React from "react"; +import { HotkeyBinding } from "@app/utils/hotkeys"; +import { useHotkeys } from "@app/contexts/HotkeyContext"; interface HotkeyDisplayProps { binding: HotkeyBinding | null | undefined; - size?: 'sm' | 'md'; + size?: "sm" | "md"; muted?: boolean; } const baseKeyStyle: React.CSSProperties = { - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - borderRadius: '0.375rem', - background: 'var(--mantine-color-gray-1)', - border: '1px solid var(--mantine-color-gray-3)', - padding: '0.125rem 0.35rem', - fontSize: '0.75rem', + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + borderRadius: "0.375rem", + background: "var(--mantine-color-gray-1)", + border: "1px solid var(--mantine-color-gray-3)", + padding: "0.125rem 0.35rem", + fontSize: "0.75rem", lineHeight: 1, - fontFamily: 'var(--mantine-font-family-monospace, monospace)', - minWidth: '1.35rem', - color: 'var(--mantine-color-text)', + fontFamily: "var(--mantine-font-family-monospace, monospace)", + minWidth: "1.35rem", + color: "var(--mantine-color-text)", }; -export const HotkeyDisplay: React.FC = ({ binding, size = 'sm', muted = false }) => { +export const HotkeyDisplay: React.FC = ({ binding, size = "sm", muted = false }) => { const { getDisplayParts } = useHotkeys(); const parts = getDisplayParts(binding); @@ -31,24 +31,26 @@ export const HotkeyDisplay: React.FC = ({ binding, size = 's return null; } - const keyStyle = size === 'md' - ? { ...baseKeyStyle, fontSize: '0.85rem', padding: '0.2rem 0.5rem' } - : baseKeyStyle; + const keyStyle = size === "md" ? { ...baseKeyStyle, fontSize: "0.85rem", padding: "0.2rem 0.5rem" } : baseKeyStyle; return ( {parts.map((part, index) => ( {part} - {index < parts.length - 1 && +} + {index < parts.length - 1 && ( + + + + + )} ))} diff --git a/frontend/src/core/components/layout/Workbench.tsx b/frontend/src/core/components/layout/Workbench.tsx index 521ac5db15..89c04dc670 100644 --- a/frontend/src/core/components/layout/Workbench.tsx +++ b/frontend/src/core/components/layout/Workbench.tsx @@ -1,24 +1,24 @@ -import { useCallback } from 'react'; -import { Box } from '@mantine/core'; -import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider'; -import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; -import { useFileHandler } from '@app/hooks/useFileHandler'; -import { useFileState, useFileActions } from '@app/contexts/FileContext'; -import { useNavigationState, useNavigationActions, useNavigationGuard } from '@app/contexts/NavigationContext'; -import { isBaseWorkbench } from '@app/types/workbench'; -import { useViewer } from '@app/contexts/ViewerContext'; -import { useAppConfig } from '@app/contexts/AppConfigContext'; -import { FileId } from '@app/types/file'; -import styles from '@app/components/layout/Workbench.module.css'; +import { useCallback } from "react"; +import { Box } from "@mantine/core"; +import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider"; +import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; +import { useFileHandler } from "@app/hooks/useFileHandler"; +import { useFileState, useFileActions } from "@app/contexts/FileContext"; +import { useNavigationState, useNavigationActions, useNavigationGuard } from "@app/contexts/NavigationContext"; +import { isBaseWorkbench } from "@app/types/workbench"; +import { useViewer } from "@app/contexts/ViewerContext"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { FileId } from "@app/types/file"; +import styles from "@app/components/layout/Workbench.module.css"; -import TopControls from '@app/components/shared/TopControls'; -import FileEditor from '@app/components/fileEditor/FileEditor'; -import PageEditor from '@app/components/pageEditor/PageEditor'; -import PageEditorControls from '@app/components/pageEditor/PageEditorControls'; -import Viewer from '@app/components/viewer/Viewer'; -import LandingPage from '@app/components/shared/LandingPage'; -import Footer from '@app/components/shared/Footer'; -import DismissAllErrorsButton from '@app/components/shared/DismissAllErrorsButton'; +import TopControls from "@app/components/shared/TopControls"; +import FileEditor from "@app/components/fileEditor/FileEditor"; +import PageEditor from "@app/components/pageEditor/PageEditor"; +import PageEditorControls from "@app/components/pageEditor/PageEditorControls"; +import Viewer from "@app/components/viewer/Viewer"; +import LandingPage from "@app/components/shared/LandingPage"; +import Footer from "@app/components/shared/Footer"; +import DismissAllErrorsButton from "@app/components/shared/DismissAllErrorsButton"; // No props needed - component uses contexts directly export default function Workbench() { @@ -54,41 +54,47 @@ export default function Workbench() { // Get active file index from ViewerContext const { activeFileIndex, setActiveFileIndex } = useViewer(); - + // Get navigation guard for unsaved changes check when switching files const { requestNavigation } = useNavigationGuard(); // Wrap file selection to check for unsaved changes before switching // requestNavigation will show the modal if there are unsaved changes, otherwise navigate immediately - const handleFileSelect = useCallback((index: number) => { - // Don't do anything if selecting the same file - if (index === activeFileIndex) return; + const handleFileSelect = useCallback( + (index: number) => { + // Don't do anything if selecting the same file + if (index === activeFileIndex) return; - // requestNavigation handles the unsaved changes check internally - requestNavigation(() => { - setActiveFileIndex(index); - }); - }, [activeFileIndex, requestNavigation, setActiveFileIndex]); + // requestNavigation handles the unsaved changes check internally + requestNavigation(() => { + setActiveFileIndex(index); + }); + }, + [activeFileIndex, requestNavigation, setActiveFileIndex], + ); - const handleFileRemove = useCallback(async (fileId: FileId) => { - await fileActions.removeFiles([fileId], false); // false = don't delete from IndexedDB, just remove from context - }, [fileActions]); + const handleFileRemove = useCallback( + async (fileId: FileId) => { + await fileActions.removeFiles([fileId], false); // false = don't delete from IndexedDB, just remove from context + }, + [fileActions], + ); const handlePreviewClose = () => { setPreviewFile(null); - const previousMode = sessionStorage.getItem('previousMode'); - if (previousMode === 'split') { + const previousMode = sessionStorage.getItem("previousMode"); + if (previousMode === "split") { // Use context's handleToolSelect which coordinates tool selection and view changes - handleToolSelect('split'); - sessionStorage.removeItem('previousMode'); - } else if (previousMode === 'compress') { - handleToolSelect('compress'); - sessionStorage.removeItem('previousMode'); - } else if (previousMode === 'convert') { - handleToolSelect('convert'); - sessionStorage.removeItem('previousMode'); + handleToolSelect("split"); + sessionStorage.removeItem("previousMode"); + } else if (previousMode === "compress") { + handleToolSelect("compress"); + sessionStorage.removeItem("previousMode"); + } else if (previousMode === "convert") { + handleToolSelect("convert"); + sessionStorage.removeItem("previousMode"); } else { - setCurrentView('fileEditor'); + setCurrentView("fileEditor"); } }; @@ -104,15 +110,11 @@ export default function Workbench() { } if (activeFiles.length === 0) { - return ( - - ); + return ; } switch (currentView) { case "fileEditor": - return ( { addFiles(filesToMerge); setCurrentView("viewer"); - } + }, })} /> ); case "viewer": - return ( - +
+ {pageEditorFunctions && ( -
+
+ onClosePdf={pageEditorFunctions.closePdf} + onUndo={pageEditorFunctions.handleUndo} + onRedo={pageEditorFunctions.handleRedo} + canUndo={pageEditorFunctions.canUndo} + canRedo={pageEditorFunctions.canRedo} + onRotate={pageEditorFunctions.handleRotate} + onDelete={pageEditorFunctions.handleDelete} + onSplit={pageEditorFunctions.handleSplit} + onSplitAll={pageEditorFunctions.handleSplitAll} + onPageBreak={pageEditorFunctions.handlePageBreak} + onPageBreakAll={pageEditorFunctions.handlePageBreakAll} + onExportAll={pageEditorFunctions.onExportAll} + exportLoading={pageEditorFunctions.exportLoading} + selectionMode={pageEditorFunctions.selectionMode} + selectedPageIds={pageEditorFunctions.selectedPageIds} + displayDocument={pageEditorFunctions.displayDocument} + splitPositions={pageEditorFunctions.splitPositions} + totalPages={pageEditorFunctions.totalPages} + />
)}
@@ -188,16 +186,16 @@ export default function Workbench() { style={ isRainbowMode ? {} // No background color in rainbow mode - : { backgroundColor: 'var(--bg-background)' } + : { backgroundColor: "var(--bg-background)" } } > {/* Top Controls */} - {activeFiles.length > 0 && !customWorkbenchViews.find(v => v.workbenchId === currentView)?.hideTopControls && ( + {activeFiles.length > 0 && !customWorkbenchViews.find((v) => v.workbenchId === currentView)?.hideTopControls && ( { + activeFiles={activeFiles.map((f) => { const stub = selectors.getStirlingFileStub(f.fileId); return { fileId: f.fileId, name: f.name, versionNumber: stub?.versionNumber }; })} @@ -212,10 +210,10 @@ export default function Workbench() { {/* Main content area */} {renderMainContent()} diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css b/frontend/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css index c507653092..5b3aec2d91 100644 --- a/frontend/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css +++ b/frontend/src/core/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css @@ -118,7 +118,6 @@ } } - .heroIconsContainer { display: flex; gap: 32px; @@ -141,7 +140,9 @@ border: none; padding: 0; cursor: pointer; - transition: transform 0.2s ease, opacity 0.2s ease; + transition: + transform 0.2s ease, + opacity 0.2s ease; display: flex; align-items: center; justify-content: center; @@ -181,7 +182,14 @@ } .iconLabel { - font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif; + font-family: + "Inter", + system-ui, + -apple-system, + "Segoe UI", + Roboto, + Arial, + sans-serif; font-size: 14px; font-weight: 500; color: rgba(255, 255, 255, 0.9); @@ -266,7 +274,7 @@ opacity: 1; border: 1px solid rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.9); - color: #1F2933; + color: #1f2933; box-shadow: 0 0 8px rgba(255, 255, 255, 0.7); } @@ -282,7 +290,14 @@ /* Title styles */ .titleText { - font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif; + font-family: + "Inter", + system-ui, + -apple-system, + "Segoe UI", + Roboto, + Arial, + sans-serif; font-weight: 600; font-size: 22px; color: var(--onboarding-title); @@ -290,7 +305,14 @@ /* Body text styles */ .bodyText { - font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif; + font-family: + "Inter", + system-ui, + -apple-system, + "Segoe UI", + Roboto, + Arial, + sans-serif; font-size: 16px; color: var(--onboarding-body); line-height: 1.5; @@ -314,8 +336,8 @@ } .v2Badge { - background: #DBEFFF; - color: #2A4BFF; + background: #dbefff; + color: #2a4bff; padding: 4px 12px; border-radius: 6px; font-size: 14px; diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx b/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx index c50ae7b43e..e0a71a3e69 100644 --- a/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx +++ b/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx @@ -1,10 +1,10 @@ -import React from 'react'; -import { Button, Group, ActionIcon } from '@mantine/core'; -import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; -import { useTranslation } from 'react-i18next'; -import { ButtonDefinition, type FlowState } from '@app/components/onboarding/onboardingFlowConfig'; -import type { LicenseNotice } from '@app/types/types'; -import type { ButtonAction } from '@app/components/onboarding/onboardingFlowConfig'; +import React from "react"; +import { Button, Group, ActionIcon } from "@mantine/core"; +import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; +import { useTranslation } from "react-i18next"; +import { ButtonDefinition, type FlowState } from "@app/components/onboarding/onboardingFlowConfig"; +import type { LicenseNotice } from "@app/types/types"; +import type { ButtonAction } from "@app/components/onboarding/onboardingFlowConfig"; interface SlideButtonsProps { slideDefinition: { @@ -18,49 +18,49 @@ interface SlideButtonsProps { export function SlideButtons({ slideDefinition, licenseNotice, flowState, onAction }: SlideButtonsProps) { const { t } = useTranslation(); - const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === 'left'); - const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === 'right'); + const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === "left"); + const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === "right"); - const buttonStyles = (variant: ButtonDefinition['variant']) => - variant === 'primary' + const buttonStyles = (variant: ButtonDefinition["variant"]) => + variant === "primary" ? { root: { - background: 'var(--onboarding-primary-button-bg)', - color: 'var(--onboarding-primary-button-text)', + background: "var(--onboarding-primary-button-bg)", + color: "var(--onboarding-primary-button-text)", }, } : { root: { - background: 'var(--onboarding-secondary-button-bg)', - border: '1px solid var(--onboarding-secondary-button-border)', - color: 'var(--onboarding-secondary-button-text)', + background: "var(--onboarding-secondary-button-bg)", + border: "1px solid var(--onboarding-secondary-button-border)", + color: "var(--onboarding-secondary-button-text)", }, }; const resolveButtonLabel = (button: ButtonDefinition) => { // Special case: override "See Plans" with "Upgrade now" when over limit if ( - button.type === 'button' && - slideDefinition.id === 'server-license' && - button.action === 'see-plans' && + button.type === "button" && + slideDefinition.id === "server-license" && + button.action === "see-plans" && licenseNotice.isOverLimit ) { - return t('onboarding.serverLicense.upgrade', 'Upgrade now →'); + return t("onboarding.serverLicense.upgrade", "Upgrade now →"); } // Translate the label (it's a translation key) - const label = button.label ?? ''; - if (!label) return ''; + const label = button.label ?? ""; + if (!label) return ""; // Extract fallback text from translation key (e.g., 'onboarding.buttons.next' -> 'Next') - const fallback = label.split('.').pop() || label; + const fallback = label.split(".").pop() || label; return t(label, fallback); }; const renderButton = (button: ButtonDefinition) => { const disabled = button.disabledWhen?.(flowState) ?? false; - if (button.type === 'icon') { + if (button.type === "icon") { return ( - {button.icon === 'chevron-left' && } + {button.icon === "chevron-left" && } ); } - const variant = button.variant ?? 'secondary'; + const variant = button.variant ?? "secondary"; const label = resolveButtonLabel(button); return ( diff --git a/frontend/src/core/components/onboarding/Onboarding.tsx b/frontend/src/core/components/onboarding/Onboarding.tsx index d098a801d5..e62ae9cedf 100644 --- a/frontend/src/core/components/onboarding/Onboarding.tsx +++ b/frontend/src/core/components/onboarding/Onboarding.tsx @@ -1,33 +1,30 @@ -import { useEffect, useMemo, useCallback, useState } from 'react'; -import { type StepType } from '@reactour/tour'; -import { useTranslation } from 'react-i18next'; -import { useNavigate, useLocation } from 'react-router-dom'; -import { isAuthRoute } from '@app/constants/routes'; -import { dispatchTourState } from '@app/constants/events'; -import { useOnboardingOrchestrator } from '@app/components/onboarding/orchestrator/useOnboardingOrchestrator'; -import { useBypassOnboarding } from '@app/components/onboarding/useBypassOnboarding'; -import OnboardingTour, { type AdvanceArgs, type CloseArgs } from '@app/components/onboarding/OnboardingTour'; -import OnboardingModalSlide from '@app/components/onboarding/OnboardingModalSlide'; -import { - useServerLicenseRequest, - useTourRequest, -} from '@app/components/onboarding/useOnboardingEffects'; -import { useOnboardingDownload } from '@app/components/onboarding/useOnboardingDownload'; -import { SLIDE_DEFINITIONS, type SlideId, type ButtonAction } from '@app/components/onboarding/onboardingFlowConfig'; -import ToolPanelModePrompt from '@app/components/tools/ToolPanelModePrompt'; -import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext'; -import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext'; -import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig'; -import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig'; -import { createWhatsNewStepsConfig } from '@app/components/onboarding/whatsNewStepsConfig'; -import { removeAllGlows } from '@app/components/onboarding/tourGlow'; -import { useFilesModalContext } from '@app/contexts/FilesModalContext'; -import { useServerExperience } from '@app/hooks/useServerExperience'; -import { useAppConfig } from '@app/contexts/AppConfigContext'; -import apiClient from '@app/services/apiClient'; -import '@app/components/onboarding/OnboardingTour.css'; -import { useAccountLogout } from '@app/extensions/accountLogout'; -import { useAuth } from '@app/auth/UseSession'; +import { useEffect, useMemo, useCallback, useState } from "react"; +import { type StepType } from "@reactour/tour"; +import { useTranslation } from "react-i18next"; +import { useNavigate, useLocation } from "react-router-dom"; +import { isAuthRoute } from "@app/constants/routes"; +import { dispatchTourState } from "@app/constants/events"; +import { useOnboardingOrchestrator } from "@app/components/onboarding/orchestrator/useOnboardingOrchestrator"; +import { useBypassOnboarding } from "@app/components/onboarding/useBypassOnboarding"; +import OnboardingTour, { type AdvanceArgs, type CloseArgs } from "@app/components/onboarding/OnboardingTour"; +import OnboardingModalSlide from "@app/components/onboarding/OnboardingModalSlide"; +import { useServerLicenseRequest, useTourRequest } from "@app/components/onboarding/useOnboardingEffects"; +import { useOnboardingDownload } from "@app/components/onboarding/useOnboardingDownload"; +import { SLIDE_DEFINITIONS, type SlideId, type ButtonAction } from "@app/components/onboarding/onboardingFlowConfig"; +import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt"; +import { useTourOrchestration } from "@app/contexts/TourOrchestrationContext"; +import { useAdminTourOrchestration } from "@app/contexts/AdminTourOrchestrationContext"; +import { createUserStepsConfig } from "@app/components/onboarding/userStepsConfig"; +import { createAdminStepsConfig } from "@app/components/onboarding/adminStepsConfig"; +import { createWhatsNewStepsConfig } from "@app/components/onboarding/whatsNewStepsConfig"; +import { removeAllGlows } from "@app/components/onboarding/tourGlow"; +import { useFilesModalContext } from "@app/contexts/FilesModalContext"; +import { useServerExperience } from "@app/hooks/useServerExperience"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import apiClient from "@app/services/apiClient"; +import "@app/components/onboarding/OnboardingTour.css"; +import { useAccountLogout } from "@app/extensions/accountLogout"; +import { useAuth } from "@app/auth/UseSession"; export default function Onboarding() { const { t } = useTranslation(); @@ -52,13 +49,16 @@ export default function Onboarding() { const accountLogout = useAccountLogout(); const { signOut } = useAuth(); - const handleRoleSelect = useCallback((role: 'admin' | 'user' | null) => { - actions.updateRuntimeState({ selectedRole: role }); - serverExperience.setSelfReportedAdmin(role === 'admin'); - }, [actions, serverExperience]); + const handleRoleSelect = useCallback( + (role: "admin" | "user" | null) => { + actions.updateRuntimeState({ selectedRole: role }); + serverExperience.setSelfReportedAdmin(role === "admin"); + }, + [actions, serverExperience], + ); const redirectToLogin = useCallback(() => { - window.location.assign('/login'); + window.location.assign("/login"); }, []); const handlePasswordChanged = useCallback(async () => { @@ -80,84 +80,97 @@ export default function Onboarding() { } }, [isLoading, analyticsModalDismissed, serverExperience.effectiveIsAdmin, config?.enableAnalytics]); - const handleAnalyticsChoice = useCallback(async (enableAnalytics: boolean) => { - if (analyticsLoading) return; - setAnalyticsLoading(true); - setAnalyticsError(null); + const handleAnalyticsChoice = useCallback( + async (enableAnalytics: boolean) => { + if (analyticsLoading) return; + setAnalyticsLoading(true); + setAnalyticsError(null); - const formData = new FormData(); - formData.append('enabled', enableAnalytics.toString()); + const formData = new FormData(); + formData.append("enabled", enableAnalytics.toString()); - try { - await apiClient.post('/api/v1/settings/update-enable-analytics', formData); - await refetchConfig(); - setShowAnalyticsModal(false); - setAnalyticsModalDismissed(true); - } catch (error) { - setAnalyticsError(error instanceof Error ? error.message : 'Unknown error'); - } finally { - setAnalyticsLoading(false); - } - }, [analyticsLoading, refetchConfig]); - - const handleButtonAction = useCallback(async (action: ButtonAction) => { - switch (action) { - case 'next': - case 'complete-close': - actions.complete(); - break; - case 'prev': - actions.prev(); - break; - case 'close': - actions.skip(); - break; - case 'download-selected': - handleDownloadSelected(); - actions.complete(); - break; - case 'security-next': - if (!runtimeState.selectedRole) return; - if (runtimeState.selectedRole !== 'admin') { - actions.updateRuntimeState({ tourType: 'whatsnew' }); - setIsTourOpen(true); - } - actions.complete(); - break; - case 'launch-admin': - actions.updateRuntimeState({ tourType: 'admin' }); - setIsTourOpen(true); - break; - case 'launch-tools': - actions.updateRuntimeState({ tourType: 'whatsnew' }); - setIsTourOpen(true); - break; - case 'launch-auto': { - const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === 'admin' ? 'admin' : 'whatsnew'; - actions.updateRuntimeState({ tourType }); - setIsTourOpen(true); - break; + try { + await apiClient.post("/api/v1/settings/update-enable-analytics", formData); + await refetchConfig(); + setShowAnalyticsModal(false); + setAnalyticsModalDismissed(true); + } catch (error) { + setAnalyticsError(error instanceof Error ? error.message : "Unknown error"); + } finally { + setAnalyticsLoading(false); } - case 'skip-to-license': - actions.complete(); - break; - case 'skip-tour': - actions.complete(); - break; - case 'see-plans': - actions.complete(); - navigate('/settings/adminPlan'); - break; - case 'enable-analytics': - await handleAnalyticsChoice(true); - break; - case 'disable-analytics': - await handleAnalyticsChoice(false); - break; - } - }, [actions, handleAnalyticsChoice, handleDownloadSelected, navigate, runtimeState.selectedRole, serverExperience.effectiveIsAdmin]); + }, + [analyticsLoading, refetchConfig], + ); - const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false; + const handleButtonAction = useCallback( + async (action: ButtonAction) => { + switch (action) { + case "next": + case "complete-close": + actions.complete(); + break; + case "prev": + actions.prev(); + break; + case "close": + actions.skip(); + break; + case "download-selected": + handleDownloadSelected(); + actions.complete(); + break; + case "security-next": + if (!runtimeState.selectedRole) return; + if (runtimeState.selectedRole !== "admin") { + actions.updateRuntimeState({ tourType: "whatsnew" }); + setIsTourOpen(true); + } + actions.complete(); + break; + case "launch-admin": + actions.updateRuntimeState({ tourType: "admin" }); + setIsTourOpen(true); + break; + case "launch-tools": + actions.updateRuntimeState({ tourType: "whatsnew" }); + setIsTourOpen(true); + break; + case "launch-auto": { + const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === "admin" ? "admin" : "whatsnew"; + actions.updateRuntimeState({ tourType }); + setIsTourOpen(true); + break; + } + case "skip-to-license": + actions.complete(); + break; + case "skip-tour": + actions.complete(); + break; + case "see-plans": + actions.complete(); + navigate("/settings/adminPlan"); + break; + case "enable-analytics": + await handleAnalyticsChoice(true); + break; + case "disable-analytics": + await handleAnalyticsChoice(false); + break; + } + }, + [ + actions, + handleAnalyticsChoice, + handleDownloadSelected, + navigate, + runtimeState.selectedRole, + serverExperience.effectiveIsAdmin, + ], + ); + + const isRTL = typeof document !== "undefined" ? document.documentElement.dir === "rtl" : false; const [isTourOpen, setIsTourOpen] = useState(false); useEffect(() => dispatchTourState(isTourOpen), [isTourOpen]); @@ -167,60 +180,63 @@ export default function Onboarding() { const adminTourOrch = useAdminTourOrchestration(); const userStepsConfig = useMemo( - () => createUserStepsConfig({ - t, - actions: { - saveWorkbenchState: tourOrch.saveWorkbenchState, - closeFilesModal, - backToAllTools: tourOrch.backToAllTools, - selectCropTool: tourOrch.selectCropTool, - loadSampleFile: tourOrch.loadSampleFile, - switchToActiveFiles: tourOrch.switchToActiveFiles, - pinFile: tourOrch.pinFile, - modifyCropSettings: tourOrch.modifyCropSettings, - executeTool: tourOrch.executeTool, - openFilesModal, - }, - }), - [t, tourOrch, closeFilesModal, openFilesModal] + () => + createUserStepsConfig({ + t, + actions: { + saveWorkbenchState: tourOrch.saveWorkbenchState, + closeFilesModal, + backToAllTools: tourOrch.backToAllTools, + selectCropTool: tourOrch.selectCropTool, + loadSampleFile: tourOrch.loadSampleFile, + switchToActiveFiles: tourOrch.switchToActiveFiles, + pinFile: tourOrch.pinFile, + modifyCropSettings: tourOrch.modifyCropSettings, + executeTool: tourOrch.executeTool, + openFilesModal, + }, + }), + [t, tourOrch, closeFilesModal, openFilesModal], ); const whatsNewStepsConfig = useMemo( - () => createWhatsNewStepsConfig({ - t, - actions: { - saveWorkbenchState: tourOrch.saveWorkbenchState, - closeFilesModal, - backToAllTools: tourOrch.backToAllTools, - openFilesModal, - loadSampleFile: tourOrch.loadSampleFile, - switchToViewer: tourOrch.switchToViewer, - switchToPageEditor: tourOrch.switchToPageEditor, - switchToActiveFiles: tourOrch.switchToActiveFiles, - selectFirstFile: tourOrch.selectFirstFile, - }, - }), - [t, tourOrch, closeFilesModal, openFilesModal] + () => + createWhatsNewStepsConfig({ + t, + actions: { + saveWorkbenchState: tourOrch.saveWorkbenchState, + closeFilesModal, + backToAllTools: tourOrch.backToAllTools, + openFilesModal, + loadSampleFile: tourOrch.loadSampleFile, + switchToViewer: tourOrch.switchToViewer, + switchToPageEditor: tourOrch.switchToPageEditor, + switchToActiveFiles: tourOrch.switchToActiveFiles, + selectFirstFile: tourOrch.selectFirstFile, + }, + }), + [t, tourOrch, closeFilesModal, openFilesModal], ); const adminStepsConfig = useMemo( - () => createAdminStepsConfig({ - t, - actions: { - saveAdminState: adminTourOrch.saveAdminState, - openConfigModal: adminTourOrch.openConfigModal, - navigateToSection: adminTourOrch.navigateToSection, - scrollNavToSection: adminTourOrch.scrollNavToSection, - }, - }), - [t, adminTourOrch] + () => + createAdminStepsConfig({ + t, + actions: { + saveAdminState: adminTourOrch.saveAdminState, + openConfigModal: adminTourOrch.openConfigModal, + navigateToSection: adminTourOrch.navigateToSection, + scrollNavToSection: adminTourOrch.scrollNavToSection, + }, + }), + [t, adminTourOrch], ); const tourSteps = useMemo(() => { switch (runtimeState.tourType) { - case 'admin': + case "admin": return Object.values(adminStepsConfig); - case 'whatsnew': + case "whatsnew": return Object.values(whatsNewStepsConfig); default: return Object.values(userStepsConfig); @@ -242,8 +258,8 @@ export default function Onboarding() { // Handle first-login password change modal useEffect(() => { - if(runtimeState.requiresPasswordChange === true) { - console.log('[Onboarding] User requires password change on first login.'); + if (runtimeState.requiresPasswordChange === true) { + console.log("[Onboarding] User requires password change on first login."); setFirstLoginModalOpen(true); } else { setFirstLoginModalOpen(false); @@ -252,18 +268,18 @@ export default function Onboarding() { // Handle MFA setup modal useEffect(() => { - if(runtimeState.requiresMfaSetup === true) { - console.log('[Onboarding] User requires MFA setup.'); + if (runtimeState.requiresMfaSetup === true) { + console.log("[Onboarding] User requires MFA setup."); setMfaModalOpen(true); } else { - console.log('[Onboarding] User does not require MFA setup.'); + console.log("[Onboarding] User does not require MFA setup."); setMfaModalOpen(false); } }, [runtimeState.requiresMfaSetup]); const finishTour = useCallback(() => { setIsTourOpen(false); - if (runtimeState.tourType === 'admin') { + if (runtimeState.tourType === "admin") { adminTourOrch.restoreAdminState(); } else { tourOrch.restoreWorkbenchState(); @@ -272,23 +288,29 @@ export default function Onboarding() { actions.complete(); }, [actions, adminTourOrch, runtimeState.tourType, tourOrch]); - const handleAdvanceTour = useCallback((args: AdvanceArgs) => { - const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args; - if (steps && tourCurrentStep === steps.length - 1) { - setIsOpen(false); - finishTour(); - } else if (steps) { - setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1)); - } - }, [finishTour]); + const handleAdvanceTour = useCallback( + (args: AdvanceArgs) => { + const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args; + if (steps && tourCurrentStep === steps.length - 1) { + setIsOpen(false); + finishTour(); + } else if (steps) { + setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1)); + } + }, + [finishTour], + ); - const handleCloseTour = useCallback((args: CloseArgs) => { - args.setIsOpen(false); - finishTour(); - }, [finishTour]); + const handleCloseTour = useCallback( + (args: CloseArgs) => { + args.setIsOpen(false); + finishTour(); + }, + [finishTour], + ); const currentSlideDefinition = useMemo(() => { - if (!currentStep || currentStep.type !== 'modal-slide' || !currentStep.slideId) { + if (!currentStep || currentStep.type !== "modal-slide" || !currentStep.slideId) { return null; } return SLIDE_DEFINITIONS[currentStep.slideId as SlideId]; @@ -312,15 +334,29 @@ export default function Onboarding() { analyticsLoading, onMfaSetupComplete: handleMfaSetupComplete, }); - }, [analyticsError, analyticsLoading, currentSlideDefinition, osInfo, osOptions, runtimeState.selectedRole, runtimeState.licenseNotice, handleRoleSelect, serverExperience.loginEnabled, setSelectedDownloadUrl, runtimeState.firstLoginUsername, handlePasswordChanged, handleMfaSetupComplete]); + }, [ + analyticsError, + analyticsLoading, + currentSlideDefinition, + osInfo, + osOptions, + runtimeState.selectedRole, + runtimeState.licenseNotice, + handleRoleSelect, + serverExperience.loginEnabled, + setSelectedDownloadUrl, + runtimeState.firstLoginUsername, + handlePasswordChanged, + handleMfaSetupComplete, + ]); const modalSlideCount = useMemo(() => { - return activeFlow.filter((step) => step.type === 'modal-slide').length; + return activeFlow.filter((step) => step.type === "modal-slide").length; }, [activeFlow]); const currentModalSlideIndex = useMemo(() => { - if (!currentStep || currentStep.type !== 'modal-slide') return 0; - const modalSlides = activeFlow.filter((step) => step.type === 'modal-slide'); + if (!currentStep || currentStep.type !== "modal-slide") return 0; + const modalSlides = activeFlow.filter((step) => step.type === "modal-slide"); return modalSlides.findIndex((step) => step.id === currentStep.id); }, [activeFlow, currentStep]); @@ -334,10 +370,10 @@ export default function Onboarding() { // Show analytics modal before onboarding if needed if (showAnalyticsModal) { - const slideDefinition = SLIDE_DEFINITIONS['analytics-choice']; + const slideDefinition = SLIDE_DEFINITIONS["analytics-choice"]; const slideContent = slideDefinition.createSlide({ - osLabel: '', - osUrl: '', + osLabel: "", + osUrl: "", selectedRole: null, onRoleSelect: () => {}, analyticsError, @@ -353,9 +389,9 @@ export default function Onboarding() { currentModalSlideIndex={0} onSkip={() => {}} // No skip allowed onAction={async (action) => { - if (action === 'enable-analytics') { + if (action === "enable-analytics") { await handleAnalyticsChoice(true); - } else if (action === 'disable-analytics') { + } else if (action === "disable-analytics") { await handleAnalyticsChoice(false); } }} @@ -365,10 +401,10 @@ export default function Onboarding() { } if (firstLoginModalOpen) { - const baseSlideDefinition = SLIDE_DEFINITIONS['first-login']; + const baseSlideDefinition = SLIDE_DEFINITIONS["first-login"]; const slideContent = baseSlideDefinition.createSlide({ - osLabel: '', - osUrl: '', + osLabel: "", + osUrl: "", selectedRole: null, onRoleSelect: () => {}, firstLoginUsername: runtimeState.firstLoginUsername, @@ -385,7 +421,7 @@ export default function Onboarding() { currentModalSlideIndex={0} onSkip={() => {}} onAction={async (action) => { - if (action === 'complete-close') { + if (action === "complete-close") { handlePasswordChanged(); } }} @@ -395,11 +431,11 @@ export default function Onboarding() { } if (mfaModalOpen) { - console.log('[Onboarding] Rendering MFA setup modal slide.'); - const baseSlideDefinition = SLIDE_DEFINITIONS['mfa-setup']; + console.log("[Onboarding] Rendering MFA setup modal slide."); + const baseSlideDefinition = SLIDE_DEFINITIONS["mfa-setup"]; const slideContent = baseSlideDefinition.createSlide({ - osLabel: '', - osUrl: '', + osLabel: "", + osUrl: "", selectedRole: null, onRoleSelect: () => {}, onMfaSetupComplete: handleMfaSetupComplete, @@ -414,7 +450,7 @@ export default function Onboarding() { currentModalSlideIndex={0} onSkip={() => {}} onAction={async (action) => { - if (action === 'complete-close') { + if (action === "complete-close") { handleMfaSetupComplete(); } }} @@ -424,16 +460,16 @@ export default function Onboarding() { } if (showLicenseSlide) { - const baseSlideDefinition = SLIDE_DEFINITIONS['server-license']; + const baseSlideDefinition = SLIDE_DEFINITIONS["server-license"]; // Remove back button for external license notice const slideDefinition = { ...baseSlideDefinition, - buttons: baseSlideDefinition.buttons.filter(btn => btn.key !== 'license-back') + buttons: baseSlideDefinition.buttons.filter((btn) => btn.key !== "license-back"), }; const effectiveLicenseNotice = externalLicenseNotice || runtimeState.licenseNotice; const slideContent = slideDefinition.createSlide({ - osLabel: '', - osUrl: '', + osLabel: "", + osUrl: "", osOptions: [], onDownloadUrlChange: () => {}, selectedRole: null, @@ -451,9 +487,9 @@ export default function Onboarding() { currentModalSlideIndex={0} onSkip={closeLicenseSlide} onAction={(action) => { - if (action === 'see-plans') { + if (action === "see-plans") { closeLicenseSlide(); - navigate('/settings/adminPlan'); + navigate("/settings/adminPlan"); } else { closeLicenseSlide(); } @@ -487,10 +523,10 @@ export default function Onboarding() { // Render the current onboarding step switch (currentStep.type) { - case 'tool-prompt': + case "tool-prompt": return ; - case 'modal-slide': + case "modal-slide": if (!currentSlideDefinition || !currentSlideContent) return null; return ( { - if (slideDefinition.hero.type === 'dual-icon') { + if (slideDefinition.hero.type === "dual-icon") { return (
@@ -56,20 +55,20 @@ export default function OnboardingModalSlide({ return (
- {slideDefinition.hero.type === 'rocket' && ( + {slideDefinition.hero.type === "rocket" && ( )} - {slideDefinition.hero.type === 'shield' && ( + {slideDefinition.hero.type === "shield" && ( )} - {slideDefinition.hero.type === 'lock' && ( + {slideDefinition.hero.type === "lock" && ( )} - {slideDefinition.hero.type === 'analytics' && ( + {slideDefinition.hero.type === "analytics" && ( )} - {slideDefinition.hero.type === 'diamond' && } - {slideDefinition.hero.type === 'logo' && ( + {slideDefinition.hero.type === "diamond" && } + {slideDefinition.hero.type === "logo" && ( Stirling logo )}
@@ -88,8 +87,8 @@ export default function OnboardingModalSlide({ withCloseButton={false} zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE} styles={{ - body: { padding: 0, maxHeight: '90vh', overflow: 'hidden' }, - content: { overflow: 'hidden', border: 'none', background: 'var(--bg-surface)', maxHeight: '90vh' }, + body: { padding: 0, maxHeight: "90vh", overflow: "hidden" }, + content: { overflow: "hidden", border: "none", background: "var(--bg-surface)", maxHeight: "90vh" }, }} > @@ -106,18 +105,18 @@ export default function OnboardingModalSlide({ radius="md" size={36} style={{ - position: 'absolute', + position: "absolute", top: 16, right: 16, - backgroundColor: 'rgba(255, 255, 255, 0.2)', - color: 'white', - backdropFilter: 'blur(4px)', + backgroundColor: "rgba(255, 255, 255, 0.2)", + color: "white", + backdropFilter: "blur(4px)", zIndex: 10, }} styles={{ root: { - '&:hover': { - backgroundColor: 'rgba(255, 255, 255, 0.3)', + "&:hover": { + backgroundColor: "rgba(255, 255, 255, 0.3)", }, }, }} @@ -130,12 +129,9 @@ export default function OnboardingModalSlide({
-
+
-
+
{slideContent.title}
@@ -146,9 +142,7 @@ export default function OnboardingModalSlide({
- {modalSlideCount > 1 && ( - - )} + {modalSlideCount > 1 && }
); } - diff --git a/frontend/src/core/components/onboarding/OnboardingStepper.tsx b/frontend/src/core/components/onboarding/OnboardingStepper.tsx index ec6767d8ad..23c37643da 100644 --- a/frontend/src/core/components/onboarding/OnboardingStepper.tsx +++ b/frontend/src/core/components/onboarding/OnboardingStepper.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React from "react"; interface OnboardingStepperProps { totalSteps: number; @@ -17,18 +17,16 @@ export function OnboardingStepper({ totalSteps, activeStep, className }: Onboard
{items.map((index) => { const isActive = index === activeStep; const baseStyles: React.CSSProperties = { - background: isActive - ? 'var(--onboarding-step-active)' - : 'var(--onboarding-step-inactive)', + background: isActive ? "var(--onboarding-step-active)" : "var(--onboarding-step-inactive)", }; return ( @@ -48,5 +46,3 @@ export function OnboardingStepper({ totalSteps, activeStep, className }: Onboard } export default OnboardingStepper; - - diff --git a/frontend/src/core/components/onboarding/OnboardingTour.css b/frontend/src/core/components/onboarding/OnboardingTour.css index 54ad69d68d..a1cbd4f3d8 100644 --- a/frontend/src/core/components/onboarding/OnboardingTour.css +++ b/frontend/src/core/components/onboarding/OnboardingTour.css @@ -18,7 +18,8 @@ } @keyframes pulse-glow { - 0%, 100% { + 0%, + 100% { box-shadow: 0 0 0 3px var(--mantine-primary-color-filled), 0 0 20px var(--mantine-primary-color-filled), @@ -33,13 +34,13 @@ } /* RTL: mirror step indicator and controls in Reactour popovers */ -:root[dir='rtl'] .reactour__popover { +:root[dir="rtl"] .reactour__popover { direction: rtl; } /* Minimal overrides retained for glow only */ -:root[dir='rtl'] .reactour__badge { +:root[dir="rtl"] .reactour__badge { left: auto; right: 16px; } diff --git a/frontend/src/core/components/onboarding/OnboardingTour.tsx b/frontend/src/core/components/onboarding/OnboardingTour.tsx index 85df0a9fdb..9ddb4e4c04 100644 --- a/frontend/src/core/components/onboarding/OnboardingTour.tsx +++ b/frontend/src/core/components/onboarding/OnboardingTour.tsx @@ -1,19 +1,19 @@ /** * OnboardingTour Component - * + * * Reusable tour wrapper that encapsulates all Reactour configuration. * Used by the main Onboarding component for both the 'tour' step and * when the tour is open but onboarding is inactive. */ -import React from 'react'; -import { TourProvider, useTour, type StepType } from '@reactour/tour'; -import { CloseButton, ActionIcon } from '@mantine/core'; -import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import CheckIcon from '@mui/icons-material/Check'; -import type { TFunction } from 'i18next'; -import i18n from '@app/i18n'; +import React from "react"; +import { TourProvider, useTour, type StepType } from "@reactour/tour"; +import { CloseButton, ActionIcon } from "@mantine/core"; +import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import CheckIcon from "@mui/icons-material/Check"; +import type { TFunction } from "i18next"; +import i18n from "@app/i18n"; /** * TourContent - Controls the tour visibility @@ -49,7 +49,7 @@ interface CloseArgs { interface OnboardingTourProps { tourSteps: StepType[]; - tourType: 'admin' | 'tools' | 'whatsnew'; + tourType: "admin" | "tools" | "whatsnew"; isRTL: boolean; t: TFunction; isOpen: boolean; @@ -57,22 +57,14 @@ interface OnboardingTourProps { onClose: (args: CloseArgs) => void; } -export default function OnboardingTour({ - tourSteps, - tourType, - isRTL, - t, - isOpen, - onAdvance, - onClose, -}: OnboardingTourProps) { +export default function OnboardingTour({ tourSteps, tourType, isRTL, t, isOpen, onAdvance, onClose }: OnboardingTourProps) { if (!isOpen) return null; return ( { @@ -80,10 +72,10 @@ export default function OnboardingTour({ onAdvance(clickProps); }} keyboardHandler={(e, clickProps, status) => { - if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) { + if (e.key === "ArrowRight" && !status?.isRightDisabled && clickProps) { e.preventDefault(); onAdvance(clickProps); - } else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) { + } else if (e.key === "Escape" && !status?.isEscDisabled && clickProps) { e.preventDefault(); onClose(clickProps); } @@ -92,12 +84,12 @@ export default function OnboardingTour({ styles={{ popover: (base) => ({ ...base, - backgroundColor: 'var(--mantine-color-body)', - color: 'var(--mantine-color-text)', - borderRadius: '8px', - padding: '20px', - boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', - maxWidth: '400px', + backgroundColor: "var(--mantine-color-body)", + color: "var(--mantine-color-text)", + borderRadius: "8px", + padding: "20px", + boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)", + maxWidth: "400px", }), maskArea: (base) => ({ ...base, @@ -105,11 +97,11 @@ export default function OnboardingTour({ }), badge: (base) => ({ ...base, - backgroundColor: 'var(--mantine-primary-color-filled)', + backgroundColor: "var(--mantine-primary-color-filled)", }), controls: (base) => ({ ...base, - justifyContent: 'center', + justifyContent: "center", }), }} highlightedMaskClassName="tour-highlight-glow" @@ -127,7 +119,7 @@ export default function OnboardingTour({ onClick={() => onAdvance({ setCurrentStep, currentStep: tourCurrentStep, steps: tourSteps, setIsOpen })} variant="subtle" size="lg" - aria-label={isLast ? t('onboarding.finish', 'Finish') : t('onboarding.next', 'Next')} + aria-label={isLast ? t("onboarding.finish", "Finish") : t("onboarding.next", "Next")} > {isLast ? : } @@ -135,10 +127,10 @@ export default function OnboardingTour({ }} components={{ Close: ({ onClick }) => ( - + ), Content: ({ content }: { content: string }) => ( -
+
), }} > @@ -148,4 +140,3 @@ export default function OnboardingTour({ } export type { AdvanceArgs, CloseArgs }; - diff --git a/frontend/src/core/components/onboarding/adminStepsConfig.ts b/frontend/src/core/components/onboarding/adminStepsConfig.ts index b7a5c0b66c..7946c9fec7 100644 --- a/frontend/src/core/components/onboarding/adminStepsConfig.ts +++ b/frontend/src/core/components/onboarding/adminStepsConfig.ts @@ -1,6 +1,6 @@ -import type { StepType } from '@reactour/tour'; -import type { TFunction } from 'i18next'; -import { addGlowToElements, removeAllGlows } from '@app/components/onboarding/tourGlow'; +import type { StepType } from "@reactour/tour"; +import type { TFunction } from "i18next"; +import { addGlowToElements, removeAllGlows } from "@app/components/onboarding/tourGlow"; export enum AdminTourStep { WELCOME, @@ -14,7 +14,7 @@ export enum AdminTourStep { WRAP_UP, } -interface AdminStepActions { +interface AdminStepActions { saveAdminState: () => void; openConfigModal: () => void; navigateToSection: (section: string) => void; @@ -32,8 +32,11 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg return { [AdminTourStep.WELCOME]: { selector: '[data-tour="config-button"]', - content: t('adminOnboarding.welcome', "Welcome to the Admin Tour! Let's explore the powerful enterprise features and settings available to system administrators."), - position: 'right', + content: t( + "adminOnboarding.welcome", + "Welcome to the Admin Tour! Let's explore the powerful enterprise features and settings available to system administrators.", + ), + position: "right", padding: 10, action: () => { saveAdminState(); @@ -41,17 +44,23 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg }, [AdminTourStep.CONFIG_BUTTON]: { selector: '[data-tour="config-button"]', - content: t('adminOnboarding.configButton', "Click the Config button to access all system settings and administrative controls."), - position: 'right', + content: t( + "adminOnboarding.configButton", + "Click the Config button to access all system settings and administrative controls.", + ), + position: "right", padding: 10, actionAfter: () => { openConfigModal(); }, }, [AdminTourStep.SETTINGS_OVERVIEW]: { - selector: '.modal-nav', - content: t('adminOnboarding.settingsOverview', "This is the Settings Panel. Admin settings are organised by category for easy navigation."), - position: 'right', + selector: ".modal-nav", + content: t( + "adminOnboarding.settingsOverview", + "This is the Settings Panel. Admin settings are organised by category for easy navigation.", + ), + position: "right", padding: 0, action: () => { removeAllGlows(); @@ -59,41 +68,68 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg }, [AdminTourStep.TEAMS_AND_USERS]: { selector: '[data-tour="admin-people-nav"]', - highlightedSelectors: ['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]'], - content: t('adminOnboarding.teamsAndUsers', "Manage Teams and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."), - position: 'right', + highlightedSelectors: [ + '[data-tour="admin-people-nav"]', + '[data-tour="admin-teams-nav"]', + '[data-tour="settings-content-area"]', + ], + content: t( + "adminOnboarding.teamsAndUsers", + "Manage Teams and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself.", + ), + position: "right", padding: 10, action: () => { removeAllGlows(); - navigateToSection('people'); + navigateToSection("people"); setTimeout(() => { - addGlowToElements(['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]']); + addGlowToElements([ + '[data-tour="admin-people-nav"]', + '[data-tour="admin-teams-nav"]', + '[data-tour="settings-content-area"]', + ]); }, 100); }, }, [AdminTourStep.SYSTEM_CUSTOMIZATION]: { selector: '[data-tour="admin-adminGeneral-nav"]', - highlightedSelectors: ['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]'], - content: t('adminOnboarding.systemCustomization', "We have extensive ways to customise the UI: 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."), - position: 'right', + highlightedSelectors: [ + '[data-tour="admin-adminGeneral-nav"]', + '[data-tour="admin-adminFeatures-nav"]', + '[data-tour="admin-adminEndpoints-nav"]', + '[data-tour="settings-content-area"]', + ], + content: t( + "adminOnboarding.systemCustomization", + "We have extensive ways to customise the UI: 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.", + ), + position: "right", padding: 10, action: () => { removeAllGlows(); - navigateToSection('adminGeneral'); + navigateToSection("adminGeneral"); setTimeout(() => { - addGlowToElements(['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]']); + addGlowToElements([ + '[data-tour="admin-adminGeneral-nav"]', + '[data-tour="admin-adminFeatures-nav"]', + '[data-tour="admin-adminEndpoints-nav"]', + '[data-tour="settings-content-area"]', + ]); }, 100); }, }, [AdminTourStep.DATABASE_SECTION]: { selector: '[data-tour="admin-adminDatabase-nav"]', highlightedSelectors: ['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]'], - content: t('adminOnboarding.databaseSection', "For advanced production environments, we have settings to allow external database hookups so you can integrate with your existing infrastructure."), - position: 'right', + content: t( + "adminOnboarding.databaseSection", + "For advanced production environments, we have settings to allow external database hookups so you can integrate with your existing infrastructure.", + ), + position: "right", padding: 10, action: () => { removeAllGlows(); - navigateToSection('adminDatabase'); + navigateToSection("adminDatabase"); setTimeout(() => { addGlowToElements(['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]']); }, 100); @@ -102,38 +138,55 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg [AdminTourStep.CONNECTIONS_SECTION]: { selector: '[data-tour="admin-adminConnections-nav"]', highlightedSelectors: ['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]'], - content: t('adminOnboarding.connectionsSection', "The Connections section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications."), - position: 'right', + content: t( + "adminOnboarding.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.", + ), + position: "right", padding: 10, action: () => { removeAllGlows(); - navigateToSection('adminConnections'); + navigateToSection("adminConnections"); setTimeout(() => { addGlowToElements(['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]']); }, 100); }, actionAfter: async () => { - await scrollNavToSection('adminAudit'); + await scrollNavToSection("adminAudit"); }, }, [AdminTourStep.ADMIN_TOOLS]: { selector: '[data-tour="admin-adminAudit-nav"]', - highlightedSelectors: ['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]'], - content: t('adminOnboarding.adminTools', "Finally, we have advanced administration tools like Auditing to track system activity and Usage Analytics to monitor how your users interact with the platform."), - position: 'right', + highlightedSelectors: [ + '[data-tour="admin-adminAudit-nav"]', + '[data-tour="admin-adminUsage-nav"]', + '[data-tour="settings-content-area"]', + ], + content: t( + "adminOnboarding.adminTools", + "Finally, we have advanced administration tools like Auditing to track system activity and Usage Analytics to monitor how your users interact with the platform.", + ), + position: "right", padding: 10, action: () => { removeAllGlows(); - navigateToSection('adminAudit'); + navigateToSection("adminAudit"); setTimeout(() => { - addGlowToElements(['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]']); + addGlowToElements([ + '[data-tour="admin-adminAudit-nav"]', + '[data-tour="admin-adminUsage-nav"]', + '[data-tour="settings-content-area"]', + ]); }, 100); }, }, [AdminTourStep.WRAP_UP]: { selector: '[data-tour="help-button"]', - content: t('adminOnboarding.wrapUp', "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the Help menu."), - position: 'right', + content: t( + "adminOnboarding.wrapUp", + "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the Help menu.", + ), + position: "right", padding: 10, action: () => { removeAllGlows(); @@ -141,4 +194,3 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg }, }; } - diff --git a/frontend/src/core/components/onboarding/onboardingFlowConfig.ts b/frontend/src/core/components/onboarding/onboardingFlowConfig.ts index 101fb13eef..c569954cdb 100644 --- a/frontend/src/core/components/onboarding/onboardingFlowConfig.ts +++ b/frontend/src/core/components/onboarding/onboardingFlowConfig.ts @@ -1,45 +1,45 @@ -import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide'; -import DesktopInstallSlide from '@app/components/onboarding/slides/DesktopInstallSlide'; -import SecurityCheckSlide from '@app/components/onboarding/slides/SecurityCheckSlide'; -import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide'; -import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide'; -import FirstLoginSlide from '@app/components/onboarding/slides/FirstLoginSlide'; -import TourOverviewSlide from '@app/components/onboarding/slides/TourOverviewSlide'; -import AnalyticsChoiceSlide from '@app/components/onboarding/slides/AnalyticsChoiceSlide'; -import MFASetupSlide from '@app/components/onboarding/slides/MFASetupSlide'; -import { SlideConfig, LicenseNotice } from '@app/types/types'; +import WelcomeSlide from "@app/components/onboarding/slides/WelcomeSlide"; +import DesktopInstallSlide from "@app/components/onboarding/slides/DesktopInstallSlide"; +import SecurityCheckSlide from "@app/components/onboarding/slides/SecurityCheckSlide"; +import PlanOverviewSlide from "@app/components/onboarding/slides/PlanOverviewSlide"; +import ServerLicenseSlide from "@app/components/onboarding/slides/ServerLicenseSlide"; +import FirstLoginSlide from "@app/components/onboarding/slides/FirstLoginSlide"; +import TourOverviewSlide from "@app/components/onboarding/slides/TourOverviewSlide"; +import AnalyticsChoiceSlide from "@app/components/onboarding/slides/AnalyticsChoiceSlide"; +import MFASetupSlide from "@app/components/onboarding/slides/MFASetupSlide"; +import { SlideConfig, LicenseNotice } from "@app/types/types"; export type SlideId = - | 'first-login' - | 'welcome' - | 'desktop-install' - | 'security-check' - | 'admin-overview' - | 'server-license' - | 'tour-overview' - | 'analytics-choice' - | 'mfa-setup'; + | "first-login" + | "welcome" + | "desktop-install" + | "security-check" + | "admin-overview" + | "server-license" + | "tour-overview" + | "analytics-choice" + | "mfa-setup"; -export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo' | 'lock' | 'analytics'; +export type HeroType = "rocket" | "dual-icon" | "shield" | "diamond" | "logo" | "lock" | "analytics"; export type ButtonAction = - | 'next' - | 'prev' - | 'close' - | 'complete-close' - | 'download-selected' - | 'security-next' - | 'launch-admin' - | 'launch-tools' - | 'launch-auto' - | 'see-plans' - | 'skip-to-license' - | 'skip-tour' - | 'enable-analytics' - | 'disable-analytics'; + | "next" + | "prev" + | "close" + | "complete-close" + | "download-selected" + | "security-next" + | "launch-admin" + | "launch-tools" + | "launch-auto" + | "see-plans" + | "skip-to-license" + | "skip-tour" + | "enable-analytics" + | "disable-analytics"; export interface FlowState { - selectedRole: 'admin' | 'user' | null; + selectedRole: "admin" | "user" | null; } export interface OSOption { @@ -53,8 +53,8 @@ export interface SlideFactoryParams { osUrl: string; osOptions?: OSOption[]; onDownloadUrlChange?: (url: string) => void; - selectedRole: 'admin' | 'user' | null; - onRoleSelect: (role: 'admin' | 'user' | null) => void; + selectedRole: "admin" | "user" | null; + onRoleSelect: (role: "admin" | "user" | null) => void; licenseNotice?: LicenseNotice; loginEnabled?: boolean; // First login params @@ -72,11 +72,11 @@ export interface HeroDefinition { export interface ButtonDefinition { key: string; - type: 'button' | 'icon'; + type: "button" | "icon"; label?: string; - icon?: 'chevron-left'; - variant?: 'primary' | 'secondary' | 'default'; - group: 'left' | 'right'; + icon?: "chevron-left"; + variant?: "primary" | "secondary" | "default"; + group: "left" | "right"; action: ButtonAction; disabledWhen?: (state: FlowState) => boolean; } @@ -89,206 +89,204 @@ export interface SlideDefinition { } export const SLIDE_DEFINITIONS: Record = { - 'first-login': { - id: 'first-login', + "first-login": { + id: "first-login", createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) => FirstLoginSlide({ - username: firstLoginUsername || '', + username: firstLoginUsername || "", onPasswordChanged: onPasswordChanged || (() => {}), usingDefaultCredentials: usingDefaultCredentials || false, }), - hero: { type: 'lock' }, + hero: { type: "lock" }, buttons: [], // Form has its own submit button }, - 'welcome': { - id: 'welcome', + welcome: { + id: "welcome", createSlide: () => WelcomeSlide(), - hero: { type: 'rocket' }, + hero: { type: "rocket" }, buttons: [ { - key: 'welcome-next', - type: 'button', - label: 'onboarding.buttons.next', - variant: 'primary', - group: 'right', - action: 'next', + key: "welcome-next", + type: "button", + label: "onboarding.buttons.next", + variant: "primary", + group: "right", + action: "next", }, ], }, - 'desktop-install': { - id: 'desktop-install', - createSlide: ({ osLabel, osUrl, osOptions, onDownloadUrlChange }) => DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }), - hero: { type: 'dual-icon' }, + "desktop-install": { + id: "desktop-install", + createSlide: ({ osLabel, osUrl, osOptions, onDownloadUrlChange }) => + DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }), + hero: { type: "dual-icon" }, buttons: [ { - key: 'desktop-back', - type: 'icon', - icon: 'chevron-left', - group: 'left', - action: 'prev', + key: "desktop-back", + type: "icon", + icon: "chevron-left", + group: "left", + action: "prev", }, { - key: 'desktop-skip', - type: 'button', - label: 'onboarding.buttons.skipForNow', - variant: 'secondary', - group: 'left', - action: 'next', + key: "desktop-skip", + type: "button", + label: "onboarding.buttons.skipForNow", + variant: "secondary", + group: "left", + action: "next", }, { - key: 'desktop-download', - type: 'button', - label: 'onboarding.buttons.download', - variant: 'primary', - group: 'right', - action: 'download-selected', + key: "desktop-download", + type: "button", + label: "onboarding.buttons.download", + variant: "primary", + group: "right", + action: "download-selected", }, ], }, - 'security-check': { - id: 'security-check', - createSlide: ({ selectedRole, onRoleSelect }) => - SecurityCheckSlide({ selectedRole, onRoleSelect }), - hero: { type: 'shield' }, + "security-check": { + id: "security-check", + createSlide: ({ selectedRole, onRoleSelect }) => SecurityCheckSlide({ selectedRole, onRoleSelect }), + hero: { type: "shield" }, buttons: [ { - key: 'security-back', - type: 'button', - label: 'onboarding.buttons.back', - variant: 'secondary', - group: 'left', - action: 'prev', + key: "security-back", + type: "button", + label: "onboarding.buttons.back", + variant: "secondary", + group: "left", + action: "prev", }, { - key: 'security-next', - type: 'button', - label: 'onboarding.buttons.next', - variant: 'primary', - group: 'right', - action: 'security-next', + key: "security-next", + type: "button", + label: "onboarding.buttons.next", + variant: "primary", + group: "right", + action: "security-next", disabledWhen: (state) => !state.selectedRole, }, ], }, - 'admin-overview': { - id: 'admin-overview', - createSlide: ({ licenseNotice, loginEnabled }) => - PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }), - hero: { type: 'diamond' }, + "admin-overview": { + id: "admin-overview", + createSlide: ({ licenseNotice, loginEnabled }) => PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }), + hero: { type: "diamond" }, buttons: [ { - key: 'admin-back', - type: 'icon', - icon: 'chevron-left', - group: 'left', - action: 'prev', + key: "admin-back", + type: "icon", + icon: "chevron-left", + group: "left", + action: "prev", }, { - key: 'admin-show', - type: 'button', - label: 'onboarding.buttons.showMeAround', - variant: 'primary', - group: 'right', - action: 'launch-admin', + key: "admin-show", + type: "button", + label: "onboarding.buttons.showMeAround", + variant: "primary", + group: "right", + action: "launch-admin", }, { - key: 'admin-skip', - type: 'button', - label: 'onboarding.buttons.skipTheTour', - variant: 'secondary', - group: 'left', - action: 'skip-to-license', + key: "admin-skip", + type: "button", + label: "onboarding.buttons.skipTheTour", + variant: "secondary", + group: "left", + action: "skip-to-license", }, ], }, - 'server-license': { - id: 'server-license', + "server-license": { + id: "server-license", createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }), - hero: { type: 'dual-icon' }, + hero: { type: "dual-icon" }, buttons: [ { - key: 'license-back', - type: 'icon', - icon: 'chevron-left', - group: 'left', - action: 'prev', + key: "license-back", + type: "icon", + icon: "chevron-left", + group: "left", + action: "prev", }, { - key: 'license-close', - type: 'button', - label: 'onboarding.buttons.skipForNow', - variant: 'secondary', - group: 'left', - action: 'close', + key: "license-close", + type: "button", + label: "onboarding.buttons.skipForNow", + variant: "secondary", + group: "left", + action: "close", }, { - key: 'license-see-plans', - type: 'button', - label: 'onboarding.serverLicense.seePlans', - variant: 'primary', - group: 'right', - action: 'see-plans', + key: "license-see-plans", + type: "button", + label: "onboarding.serverLicense.seePlans", + variant: "primary", + group: "right", + action: "see-plans", }, ], }, - 'tour-overview': { - id: 'tour-overview', + "tour-overview": { + id: "tour-overview", createSlide: () => TourOverviewSlide(), - hero: { type: 'rocket' }, + hero: { type: "rocket" }, buttons: [ { - key: 'tour-overview-back', - type: 'icon', - icon: 'chevron-left', - group: 'left', - action: 'prev', + key: "tour-overview-back", + type: "icon", + icon: "chevron-left", + group: "left", + action: "prev", }, { - key: 'tour-overview-skip', - type: 'button', - label: 'onboarding.buttons.skipForNow', - variant: 'secondary', - group: 'left', - action: 'skip-tour', + key: "tour-overview-skip", + type: "button", + label: "onboarding.buttons.skipForNow", + variant: "secondary", + group: "left", + action: "skip-tour", }, { - key: 'tour-overview-show', - type: 'button', - label: 'onboarding.buttons.showMeAround', - variant: 'primary', - group: 'right', - action: 'launch-tools', + key: "tour-overview-show", + type: "button", + label: "onboarding.buttons.showMeAround", + variant: "primary", + group: "right", + action: "launch-tools", }, ], }, - 'analytics-choice': { - id: 'analytics-choice', + "analytics-choice": { + id: "analytics-choice", createSlide: ({ analyticsError }) => AnalyticsChoiceSlide({ analyticsError }), - hero: { type: 'analytics' }, + hero: { type: "analytics" }, buttons: [ { - key: 'analytics-disable', - type: 'button', - label: 'no', - variant: 'secondary', - group: 'left', - action: 'disable-analytics', + key: "analytics-disable", + type: "button", + label: "no", + variant: "secondary", + group: "left", + action: "disable-analytics", }, { - key: 'analytics-enable', - type: 'button', - label: 'yes', - variant: 'primary', - group: 'right', - action: 'enable-analytics', + key: "analytics-enable", + type: "button", + label: "yes", + variant: "primary", + group: "right", + action: "enable-analytics", }, ], }, - 'mfa-setup': { - id: 'mfa-setup', + "mfa-setup": { + id: "mfa-setup", createSlide: ({ onMfaSetupComplete = () => {} }: SlideFactoryParams) => MFASetupSlide({ onMfaSetupComplete }), - hero: { type: 'lock' }, + hero: { type: "lock" }, buttons: [], // Form has its own submit button }, }; - diff --git a/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts b/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts index 9528694c3e..a4f06e4c72 100644 --- a/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts +++ b/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts @@ -1,23 +1,21 @@ export type OnboardingStepId = - | 'first-login' - | 'welcome' - | 'desktop-install' - | 'security-check' - | 'admin-overview' - | 'tool-layout' - | 'tour-overview' - | 'server-license' - | 'analytics-choice' - | 'mfa-setup'; + | "first-login" + | "welcome" + | "desktop-install" + | "security-check" + | "admin-overview" + | "tool-layout" + | "tour-overview" + | "server-license" + | "analytics-choice" + | "mfa-setup"; -export type OnboardingStepType = - | 'modal-slide' - | 'tool-prompt'; +export type OnboardingStepType = "modal-slide" | "tool-prompt"; export interface OnboardingRuntimeState { - selectedRole: 'admin' | 'user' | null; + selectedRole: "admin" | "user" | null; tourRequested: boolean; - tourType: 'admin' | 'tools' | 'whatsnew'; + tourType: "admin" | "tools" | "whatsnew"; isDesktopApp: boolean; desktopSlideEnabled: boolean; analyticsNotConfigured: boolean; @@ -43,14 +41,23 @@ export interface OnboardingStep { id: OnboardingStepId; type: OnboardingStepType; condition: (ctx: OnboardingConditionContext) => boolean; - slideId?: 'first-login' | 'welcome' | 'desktop-install' | 'security-check' | 'admin-overview' | 'server-license' | 'tour-overview' | 'analytics-choice' | 'mfa-setup'; + slideId?: + | "first-login" + | "welcome" + | "desktop-install" + | "security-check" + | "admin-overview" + | "server-license" + | "tour-overview" + | "analytics-choice" + | "mfa-setup"; allowDismiss?: boolean; } export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = { selectedRole: null, tourRequested: false, - tourType: 'whatsnew', + tourType: "whatsnew", isDesktopApp: false, analyticsNotConfigured: false, analyticsEnabled: false, @@ -61,7 +68,7 @@ export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = { requiresLicense: false, }, requiresPasswordChange: false, - firstLoginUsername: '', + firstLoginUsername: "", usingDefaultCredentials: false, desktopSlideEnabled: true, requiresMfaSetup: false, @@ -69,59 +76,59 @@ export const DEFAULT_RUNTIME_STATE: OnboardingRuntimeState = { export const ONBOARDING_STEPS: OnboardingStep[] = [ { - id: 'first-login', - type: 'modal-slide', - slideId: 'first-login', + id: "first-login", + type: "modal-slide", + slideId: "first-login", condition: (ctx) => ctx.requiresPasswordChange, }, { - id: 'welcome', - type: 'modal-slide', - slideId: 'welcome', + id: "welcome", + type: "modal-slide", + slideId: "welcome", // Desktop has its own onboarding modal (DesktopOnboardingModal) condition: (ctx) => !ctx.isDesktopApp, }, { - id: 'admin-overview', - type: 'modal-slide', - slideId: 'admin-overview', + id: "admin-overview", + type: "modal-slide", + slideId: "admin-overview", condition: (ctx) => ctx.effectiveIsAdmin, }, { - id: 'desktop-install', - type: 'modal-slide', - slideId: 'desktop-install', + id: "desktop-install", + type: "modal-slide", + slideId: "desktop-install", condition: (ctx) => !ctx.isDesktopApp && ctx.desktopSlideEnabled, }, { - id: 'security-check', - type: 'modal-slide', - slideId: 'security-check', + id: "security-check", + type: "modal-slide", + slideId: "security-check", condition: () => false, }, { - id: 'tool-layout', - type: 'tool-prompt', + id: "tool-layout", + type: "tool-prompt", condition: () => false, }, { - id: 'tour-overview', - type: 'modal-slide', - slideId: 'tour-overview', - condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== 'admin' && !ctx.isDesktopApp, + id: "tour-overview", + type: "modal-slide", + slideId: "tour-overview", + condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== "admin" && !ctx.isDesktopApp, }, { - id: 'server-license', - type: 'modal-slide', - slideId: 'server-license', + id: "server-license", + type: "modal-slide", + slideId: "server-license", condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense, }, { - id: 'mfa-setup', - type: 'modal-slide', - slideId: 'mfa-setup', + id: "mfa-setup", + type: "modal-slide", + slideId: "mfa-setup", condition: (ctx) => ctx.requiresMfaSetup, - } + }, ]; export function getStepById(id: OnboardingStepId): OnboardingStep | undefined { @@ -131,4 +138,3 @@ export function getStepById(id: OnboardingStepId): OnboardingStep | undefined { export function getStepIndex(id: OnboardingStepId): number { return ONBOARDING_STEPS.findIndex((step) => step.id === id); } - diff --git a/frontend/src/core/components/onboarding/orchestrator/onboardingStorage.ts b/frontend/src/core/components/onboarding/orchestrator/onboardingStorage.ts index 9e3065614a..08a8049a1b 100644 --- a/frontend/src/core/components/onboarding/orchestrator/onboardingStorage.ts +++ b/frontend/src/core/components/onboarding/orchestrator/onboardingStorage.ts @@ -1,62 +1,62 @@ -const STORAGE_PREFIX = 'onboarding'; +const STORAGE_PREFIX = "onboarding"; const TOURS_TOOLTIP_KEY = `${STORAGE_PREFIX}::tours-tooltip-shown`; const ONBOARDING_COMPLETED_KEY = `${STORAGE_PREFIX}::completed`; export function isOnboardingCompleted(): boolean { - if (typeof window === 'undefined') return false; + if (typeof window === "undefined") return false; try { - return localStorage.getItem(ONBOARDING_COMPLETED_KEY) === 'true'; + return localStorage.getItem(ONBOARDING_COMPLETED_KEY) === "true"; } catch { return false; } } export function markOnboardingCompleted(): void { - if (typeof window === 'undefined') return; + if (typeof window === "undefined") return; try { - localStorage.setItem(ONBOARDING_COMPLETED_KEY, 'true'); + localStorage.setItem(ONBOARDING_COMPLETED_KEY, "true"); } catch (error) { - console.error('[onboardingStorage] Error marking onboarding as completed:', error); + console.error("[onboardingStorage] Error marking onboarding as completed:", error); } } export function resetOnboardingProgress(): void { - if (typeof window === 'undefined') return; + if (typeof window === "undefined") return; try { localStorage.removeItem(ONBOARDING_COMPLETED_KEY); } catch (error) { - console.error('[onboardingStorage] Error resetting onboarding progress:', error); + console.error("[onboardingStorage] Error resetting onboarding progress:", error); } } export function hasShownToursTooltip(): boolean { - if (typeof window === 'undefined') return false; + if (typeof window === "undefined") return false; try { - return localStorage.getItem(TOURS_TOOLTIP_KEY) === 'true'; + return localStorage.getItem(TOURS_TOOLTIP_KEY) === "true"; } catch { return false; } } export function markToursTooltipShown(): void { - if (typeof window === 'undefined') return; + if (typeof window === "undefined") return; try { - localStorage.setItem(TOURS_TOOLTIP_KEY, 'true'); + localStorage.setItem(TOURS_TOOLTIP_KEY, "true"); } catch (error) { - console.error('[onboardingStorage] Error marking tours tooltip as shown:', error); + console.error("[onboardingStorage] Error marking tours tooltip as shown:", error); } } export function migrateFromLegacyPreferences(): void { - if (typeof window === 'undefined') return; + if (typeof window === "undefined") return; const migrationKey = `${STORAGE_PREFIX}::migrated`; try { // Skip if already migrated - if (localStorage.getItem(migrationKey) === 'true') return; + if (localStorage.getItem(migrationKey) === "true") return; - const prefsRaw = localStorage.getItem('stirlingpdf_preferences'); + const prefsRaw = localStorage.getItem("stirlingpdf_preferences"); if (prefsRaw) { const prefs = JSON.parse(prefsRaw) as Record; @@ -67,7 +67,7 @@ export function migrateFromLegacyPreferences(): void { } // Mark migration complete - localStorage.setItem(migrationKey, 'true'); + localStorage.setItem(migrationKey, "true"); } catch { // If migration fails, onboarding will show again - safer than hiding it } diff --git a/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts b/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts index 54d1d2acf4..4fade3a3fc 100644 --- a/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts +++ b/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts @@ -1,7 +1,7 @@ -import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; -import { useLocation } from 'react-router-dom'; -import { useServerExperience } from '@app/hooks/useServerExperience'; -import { useAppConfig } from '@app/contexts/AppConfigContext'; +import { useState, useCallback, useMemo, useEffect, useRef } from "react"; +import { useLocation } from "react-router-dom"; +import { useServerExperience } from "@app/hooks/useServerExperience"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; import { ONBOARDING_STEPS, @@ -10,39 +10,40 @@ import { type OnboardingRuntimeState, type OnboardingConditionContext, DEFAULT_RUNTIME_STATE, -} from '@app/components/onboarding/orchestrator/onboardingConfig'; +} from "@app/components/onboarding/orchestrator/onboardingConfig"; import { isOnboardingCompleted, markOnboardingCompleted, migrateFromLegacyPreferences, -} from '@app/components/onboarding/orchestrator/onboardingStorage'; -import { accountService } from '@app/services/accountService'; -import { useBypassOnboarding } from '@app/components/onboarding/useBypassOnboarding'; +} from "@app/components/onboarding/orchestrator/onboardingStorage"; +import { accountService } from "@app/services/accountService"; +import { useBypassOnboarding } from "@app/components/onboarding/useBypassOnboarding"; -const AUTH_ROUTES = ['/login', '/signup', '/auth', '/invite']; -const SESSION_TOUR_REQUESTED = 'onboarding::session::tour-requested'; -const SESSION_TOUR_TYPE = 'onboarding::session::tour-type'; -const SESSION_SELECTED_ROLE = 'onboarding::session::selected-role'; +const AUTH_ROUTES = ["/login", "/signup", "/auth", "/invite"]; +const SESSION_TOUR_REQUESTED = "onboarding::session::tour-requested"; +const SESSION_TOUR_TYPE = "onboarding::session::tour-type"; +const SESSION_SELECTED_ROLE = "onboarding::session::selected-role"; // Check if user has an auth token (to avoid flash before redirect) function hasAuthToken(): boolean { - if (typeof window === 'undefined') return false; - return !!localStorage.getItem('stirling_jwt'); + if (typeof window === "undefined") return false; + return !!localStorage.getItem("stirling_jwt"); } // Get initial runtime state from session storage (survives remounts) function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRuntimeState { - if (typeof window === 'undefined') { + if (typeof window === "undefined") { return baseState; } try { - const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === 'true'; + const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === "true"; const sessionTourType = sessionStorage.getItem(SESSION_TOUR_TYPE); - const tourType = (sessionTourType === 'admin' || sessionTourType === 'tools' || sessionTourType === 'whatsnew') - ? sessionTourType - : 'whatsnew'; - const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as 'admin' | 'user' | null; + const tourType = + sessionTourType === "admin" || sessionTourType === "tools" || sessionTourType === "whatsnew" + ? sessionTourType + : "whatsnew"; + const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as "admin" | "user" | null; return { ...baseState, @@ -56,11 +57,11 @@ function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRu } function persistRuntimeState(state: Partial): void { - if (typeof window === 'undefined') return; + if (typeof window === "undefined") return; try { if (state.tourRequested !== undefined) { - sessionStorage.setItem(SESSION_TOUR_REQUESTED, state.tourRequested ? 'true' : 'false'); + sessionStorage.setItem(SESSION_TOUR_REQUESTED, state.tourRequested ? "true" : "false"); } if (state.tourType !== undefined) { sessionStorage.setItem(SESSION_TOUR_TYPE, state.tourType); @@ -73,12 +74,12 @@ function persistRuntimeState(state: Partial): void { } } } catch (error) { - console.error('[useOnboardingOrchestrator] Error persisting runtime state:', error); + console.error("[useOnboardingOrchestrator] Error persisting runtime state:", error); } } function clearRuntimeStateSession(): void { - if (typeof window === 'undefined') return; + if (typeof window === "undefined") return; try { sessionStorage.removeItem(SESSION_TOUR_REQUESTED); @@ -94,9 +95,9 @@ function parseMfaRequired(settings: string | null | undefined): boolean { try { const parsed = JSON.parse(settings) as { mfaRequired?: string }; - return parsed.mfaRequired?.toLowerCase() === 'true'; + return parsed.mfaRequired?.toLowerCase() === "true"; } catch (error) { - console.warn('[useOnboardingOrchestrator] Failed to parse account settings JSON:', error); + console.warn("[useOnboardingOrchestrator] Failed to parse account settings JSON:", error); return false; } } @@ -151,18 +152,14 @@ export interface UseOnboardingOrchestratorOptions { defaultRuntimeState?: OnboardingRuntimeState; } -export function useOnboardingOrchestrator( - options?: UseOnboardingOrchestratorOptions -): UseOnboardingOrchestratorResult { +export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOptions): UseOnboardingOrchestratorResult { const defaultState = options?.defaultRuntimeState ?? DEFAULT_RUNTIME_STATE; const serverExperience = useServerExperience(); const { config, loading: configLoading } = useAppConfig(); const location = useLocation(); const bypassOnboarding = useBypassOnboarding(); - const [runtimeState, setRuntimeState] = useState(() => - getInitialRuntimeState(defaultState) - ); + const [runtimeState, setRuntimeState] = useState(() => getInitialRuntimeState(defaultState)); const [isPaused, setIsPaused] = useState(false); const [isInitialized, setIsInitialized] = useState(false); const [currentStepIndex, setCurrentStepIndex] = useState(-1); @@ -186,10 +183,10 @@ export function useOnboardingOrchestrator( totalUsers: serverExperience.totalUsers, freeTierLimit: serverExperience.freeTierLimit, isOverLimit: serverExperience.overFreeTierLimit ?? false, - requiresLicense: !serverExperience.hasPaidLicense && ( - serverExperience.overFreeTierLimit === true || - (serverExperience.effectiveIsAdmin && serverExperience.userCountResolved) - ), + requiresLicense: + !serverExperience.hasPaidLicense && + (serverExperience.overFreeTierLimit === true || + (serverExperience.effectiveIsAdmin && serverExperience.userCountResolved)), }, })); }, [ @@ -220,7 +217,7 @@ export function useOnboardingOrchestrator( requiresMfaSetup: parseMfaRequired(accountData.settings), })); } catch (error) { - console.log('[OnboardingOrchestrator] Failed to fetch account data for onboarding runtime state:', error); + console.log("[OnboardingOrchestrator] Failed to fetch account data for onboarding runtime state:", error); // Account endpoint failed - user not logged in or security disabled } }; @@ -233,26 +230,25 @@ export function useOnboardingOrchestrator( const isOnAuthRoute = AUTH_ROUTES.some((route) => location.pathname.startsWith(route)); const loginEnabled = config?.enableLogin === true; const isUnauthenticatedWithLoginEnabled = loginEnabled && !hasAuthToken(); - const shouldBlockOnboarding = - bypassOnboarding || isOnAuthRoute || configLoading || isUnauthenticatedWithLoginEnabled; + const shouldBlockOnboarding = bypassOnboarding || isOnAuthRoute || configLoading || isUnauthenticatedWithLoginEnabled; - const conditionContext = useMemo(() => ({ - ...serverExperience, - ...runtimeState, - effectiveIsAdmin: serverExperience.effectiveIsAdmin || - (!serverExperience.loginEnabled && runtimeState.selectedRole === 'admin'), - }), [serverExperience, runtimeState]); + const conditionContext = useMemo( + () => ({ + ...serverExperience, + ...runtimeState, + effectiveIsAdmin: + serverExperience.effectiveIsAdmin || (!serverExperience.loginEnabled && runtimeState.selectedRole === "admin"), + }), + [serverExperience, runtimeState], + ); const activeFlow = useMemo(() => { return ONBOARDING_STEPS.filter((step) => step.condition(conditionContext)); }, [conditionContext]); // Wait for config AND admin status before calculating initial step - const adminStatusResolved = !configLoading && ( - config?.enableLogin === false || - config?.enableLogin === undefined || - config?.isAdmin !== undefined - ); + const adminStatusResolved = + !configLoading && (config?.enableLogin === false || config?.enableLogin === undefined || config?.isAdmin !== undefined); useEffect(() => { if (configLoading || !adminStatusResolved) return; @@ -280,14 +276,15 @@ export function useOnboardingOrchestrator( const totalSteps = activeFlow.length; - const isComplete = isInitialized && - (totalSteps === 0 || currentStepIndex >= totalSteps || isOnboardingCompleted()); - const currentStep = (currentStepIndex >= 0 && currentStepIndex < totalSteps) - ? activeFlow[currentStepIndex] - : null; + const isComplete = isInitialized && (totalSteps === 0 || currentStepIndex >= totalSteps || isOnboardingCompleted()); + const currentStep = currentStepIndex >= 0 && currentStepIndex < totalSteps ? activeFlow[currentStepIndex] : null; const isActive = !shouldBlockOnboarding && !isPaused && !isComplete && isInitialized && currentStep !== null; - const isLoading = configLoading || !adminStatusResolved || !isInitialized || - !initialIndexSet.current || (currentStepIndex === -1 && activeFlow.length > 0); + const isLoading = + configLoading || + !adminStatusResolved || + !isInitialized || + !initialIndexSet.current || + (currentStepIndex === -1 && activeFlow.length > 0); useEffect(() => { if (!configLoading && !isInitialized) setIsInitialized(true); @@ -325,7 +322,6 @@ export function useOnboardingOrchestrator( setCurrentStepIndex(nextIndex); }, [currentStepIndex, totalSteps]); - const updateRuntimeState = useCallback((updates: Partial) => { persistRuntimeState(updates); setRuntimeState((prev) => ({ ...prev, ...updates })); @@ -336,13 +332,16 @@ export function useOnboardingOrchestrator( setCurrentStepIndex(-1); }, []); - const startStep = useCallback((stepId: OnboardingStepId) => { - const index = activeFlow.findIndex((step) => step.id === stepId); - if (index !== -1) { - setCurrentStepIndex(index); - setIsPaused(false); - } - }, [activeFlow]); + const startStep = useCallback( + (stepId: OnboardingStepId) => { + const index = activeFlow.findIndex((step) => step.id === stepId); + if (index !== -1) { + setCurrentStepIndex(index); + setIsPaused(false); + } + }, + [activeFlow], + ); const pause = useCallback(() => setIsPaused(true), []); const resume = useCallback(() => setIsPaused(false), []); diff --git a/frontend/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx b/frontend/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx index c04009ca4c..9521933cb3 100644 --- a/frontend/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx +++ b/frontend/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx @@ -1,11 +1,11 @@ -import React from 'react'; -import { Trans } from 'react-i18next'; -import { Button } from '@mantine/core'; -import OpenInNewIcon from '@mui/icons-material/OpenInNew'; -import i18n from '@app/i18n'; -import { SlideConfig } from '@app/types/types'; -import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig'; -import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css'; +import React from "react"; +import { Trans } from "react-i18next"; +import { Button } from "@mantine/core"; +import OpenInNewIcon from "@mui/icons-material/OpenInNew"; +import i18n from "@app/i18n"; +import { SlideConfig } from "@app/types/types"; +import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig"; +import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css"; interface AnalyticsChoiceSlideProps { analyticsError?: string | null; @@ -13,8 +13,8 @@ interface AnalyticsChoiceSlideProps { export default function AnalyticsChoiceSlide({ analyticsError }: AnalyticsChoiceSlideProps): SlideConfig { return { - key: 'analytics-choice', - title: i18n.t('analytics.title', 'Do you want to help make Stirling PDF better?'), + key: "analytics-choice", + title: i18n.t("analytics.title", "Do you want to help make Stirling PDF better?"), body: (
}} />
-
+
- {analyticsError && ( -
- {analyticsError} -
- )} + {analyticsError &&
{analyticsError}
}
), background: { - gradientStops: ['#0EA5E9', '#6366F1'], + gradientStops: ["#0EA5E9", "#6366F1"], circles: UNIFIED_CIRCLE_CONFIG, }, }; } - diff --git a/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.tsx b/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.tsx index 289b07b6c4..11bd671c58 100644 --- a/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.tsx +++ b/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.tsx @@ -1,12 +1,12 @@ -import React from 'react'; -import styles from '@app/components/onboarding/slides/AnimatedSlideBackground.module.css'; -import { AnimatedSlideBackgroundProps } from '@app/types/types'; +import React from "react"; +import styles from "@app/components/onboarding/slides/AnimatedSlideBackground.module.css"; +import { AnimatedSlideBackgroundProps } from "@app/types/types"; type CircleStyles = React.CSSProperties & { - '--circle-move-x'?: string; - '--circle-move-y'?: string; - '--circle-duration'?: string; - '--circle-delay'?: string; + "--circle-move-x"?: string; + "--circle-move-y"?: string; + "--circle-duration"?: string; + "--circle-delay"?: string; }; interface AnimatedSlideBackgroundComponentProps extends AnimatedSlideBackgroundProps { @@ -14,11 +14,7 @@ interface AnimatedSlideBackgroundComponentProps extends AnimatedSlideBackgroundP slideKey: string; } -export default function AnimatedSlideBackground({ - gradientStops, - circles, - isActive, -}: AnimatedSlideBackgroundComponentProps) { +export default function AnimatedSlideBackground({ gradientStops, circles, isActive }: AnimatedSlideBackgroundComponentProps) { const [prevGradient, setPrevGradient] = React.useState<[string, string] | null>(null); const [currentGradient, setCurrentGradient] = React.useState<[string, string]>(gradientStops); const [isTransitioning, setIsTransitioning] = React.useState(false); @@ -31,13 +27,13 @@ export default function AnimatedSlideBackground({ setCurrentGradient(gradientStops); return; } - + // Only transition if gradient actually changed if (currentGradient[0] !== gradientStops[0] || currentGradient[1] !== gradientStops[1]) { // Store previous gradient and start transition setPrevGradient(currentGradient); setIsTransitioning(true); - + // Update to new gradient (will fade in) setCurrentGradient(gradientStops); } @@ -59,8 +55,8 @@ export default function AnimatedSlideBackground({ return (
{prevGradientStyle && isTransitioning && ( -
{ setPrevGradient(null); @@ -69,14 +65,14 @@ export default function AnimatedSlideBackground({ /> )}
{circles.map((circle, index) => { const { position, size, color, opacity, blur, amplitude = 48, duration = 15, delay = 0 } = circle; - const moveX = position === 'bottom-left' ? amplitude : -amplitude; - const moveY = position === 'bottom-left' ? -amplitude * 0.6 : amplitude * 0.6; + const moveX = position === "bottom-left" ? amplitude : -amplitude; + const moveY = position === "bottom-left" ? -amplitude * 0.6 : amplitude * 0.6; const circleStyle: CircleStyles = { width: size, @@ -84,17 +80,17 @@ export default function AnimatedSlideBackground({ background: color, opacity: opacity ?? 0.9, filter: blur ? `blur(${blur}px)` : undefined, - '--circle-move-x': `${moveX}px`, - '--circle-move-y': `${moveY}px`, - '--circle-duration': `${duration}s`, - '--circle-delay': `${delay}s`, + "--circle-move-x": `${moveX}px`, + "--circle-move-y": `${moveY}px`, + "--circle-duration": `${duration}s`, + "--circle-delay": `${delay}s`, }; const defaultOffset = -size / 2; const offsetX = circle.offsetX ?? 0; const offsetY = circle.offsetY ?? 0; - if (position === 'bottom-left') { + if (position === "bottom-left") { circleStyle.left = `${defaultOffset + offsetX}px`; circleStyle.bottom = `${defaultOffset + offsetY}px`; } else { @@ -102,13 +98,7 @@ export default function AnimatedSlideBackground({ circleStyle.top = `${defaultOffset + offsetY}px`; } - return ( -
- ); + return
; })}
); diff --git a/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx b/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx index 9cebfbb9cd..0ad0eebc29 100644 --- a/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx +++ b/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx @@ -1,8 +1,8 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { SlideConfig } from '@app/types/types'; -import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig'; -import { DesktopInstallTitle, type OSOption } from '@app/components/onboarding/slides/DesktopInstallTitle'; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { SlideConfig } from "@app/types/types"; +import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig"; +import { DesktopInstallTitle, type OSOption } from "@app/components/onboarding/slides/DesktopInstallTitle"; export type { OSOption }; @@ -19,8 +19,8 @@ const DesktopInstallBody = () => { return ( {t( - 'onboarding.desktopInstall.body', - 'Stirling works best as a desktop app. You can use it offline, access documents faster, and make edits locally on your computer.', + "onboarding.desktopInstall.body", + "Stirling works best as a desktop app. You can use it offline, access documents faster, and make edits locally on your computer.", )} ); @@ -32,11 +32,10 @@ export default function DesktopInstallSlide({ osOptions = [], onDownloadUrlChange, }: DesktopInstallSlideProps): SlideConfig { - return { - key: 'desktop-install', + key: "desktop-install", title: ( - , downloadUrl: osUrl, background: { - gradientStops: ['#2563EB', '#0EA5E9'], + gradientStops: ["#2563EB", "#0EA5E9"], circles: UNIFIED_CIRCLE_CONFIG, }, }; } - diff --git a/frontend/src/core/components/onboarding/slides/DesktopInstallTitle.tsx b/frontend/src/core/components/onboarding/slides/DesktopInstallTitle.tsx index ac42b518b3..b69b1b6b84 100644 --- a/frontend/src/core/components/onboarding/slides/DesktopInstallTitle.tsx +++ b/frontend/src/core/components/onboarding/slides/DesktopInstallTitle.tsx @@ -1,7 +1,7 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { Menu, ActionIcon } from '@mantine/core'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Menu, ActionIcon } from "@mantine/core"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; export interface OSOption { label: string; @@ -16,11 +16,11 @@ interface DesktopInstallTitleProps { onDownloadUrlChange?: (url: string) => void; } -export const DesktopInstallTitle: React.FC = ({ - osLabel, - osUrl, - osOptions, - onDownloadUrlChange +export const DesktopInstallTitle: React.FC = ({ + osLabel, + osUrl, + osOptions, + onDownloadUrlChange, }) => { const { t } = useTranslation(); const [selectedOsUrl, setSelectedOsUrl] = React.useState(osUrl); @@ -29,37 +29,41 @@ export const DesktopInstallTitle: React.FC = ({ setSelectedOsUrl(osUrl); }, [osUrl]); - const handleOsSelect = React.useCallback((option: OSOption) => { - setSelectedOsUrl(option.url); - onDownloadUrlChange?.(option.url); - }, [onDownloadUrlChange]); + const handleOsSelect = React.useCallback( + (option: OSOption) => { + setSelectedOsUrl(option.url); + onDownloadUrlChange?.(option.url); + }, + [onDownloadUrlChange], + ); - const currentOsOption = osOptions.find(opt => opt.url === selectedOsUrl) || + const currentOsOption = + osOptions.find((opt) => opt.url === selectedOsUrl) || (osOptions.length > 0 ? osOptions[0] : { label: osLabel, url: osUrl }); - + const displayLabel = currentOsOption.label || osLabel; - const title = displayLabel - ? t('onboarding.desktopInstall.titleWithOs', 'Download for {{osLabel}}', { osLabel: displayLabel }) - : t('onboarding.desktopInstall.title', 'Download'); + const title = displayLabel + ? t("onboarding.desktopInstall.titleWithOs", "Download for {{osLabel}}", { osLabel: displayLabel }) + : t("onboarding.desktopInstall.title", "Download"); // If only one option or no options, don't show dropdown if (osOptions.length <= 1) { - return
{title}
; + return
{title}
; } return ( -
- {title} +
+ {title} @@ -74,11 +78,9 @@ export const DesktopInstallTitle: React.FC = ({ onClick={() => handleOsSelect(option)} style={{ backgroundColor: isSelected - ? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))' - : 'transparent', - color: isSelected - ? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))' - : 'inherit', + ? "light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))" + : "transparent", + color: isSelected ? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))" : "inherit", }} > {option.label} @@ -90,4 +92,3 @@ export const DesktopInstallTitle: React.FC = ({
); }; - diff --git a/frontend/src/core/components/onboarding/slides/FirstLoginSlide.tsx b/frontend/src/core/components/onboarding/slides/FirstLoginSlide.tsx index 46ec6a0c89..df7d5ff544 100644 --- a/frontend/src/core/components/onboarding/slides/FirstLoginSlide.tsx +++ b/frontend/src/core/components/onboarding/slides/FirstLoginSlide.tsx @@ -1,12 +1,12 @@ -import React, { useState } from 'react'; -import { Stack, PasswordInput, Button, Alert, Text } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import { SlideConfig } from '@app/types/types'; -import LocalIcon from '@app/components/shared/LocalIcon'; -import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig'; -import { accountService } from '@app/services/accountService'; -import { alert as showToast } from '@app/components/toast'; -import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css'; +import React, { useState } from "react"; +import { Stack, PasswordInput, Button, Alert, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { SlideConfig } from "@app/types/types"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig"; +import { accountService } from "@app/services/accountService"; +import { alert as showToast } from "@app/components/toast"; +import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css"; interface FirstLoginSlideProps { username: string; @@ -14,66 +14,66 @@ interface FirstLoginSlideProps { usingDefaultCredentials?: boolean; } -const DEFAULT_PASSWORD = 'stirling'; +const DEFAULT_PASSWORD = "stirling"; function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = false }: FirstLoginSlideProps) { const { t } = useTranslation(); // If using default credentials, pre-fill with "stirling" - user won't see this field - const [currentPassword, setCurrentPassword] = useState(usingDefaultCredentials ? DEFAULT_PASSWORD : ''); - const [newPassword, setNewPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); + const [currentPassword, setCurrentPassword] = useState(usingDefaultCredentials ? DEFAULT_PASSWORD : ""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); const [loading, setLoading] = useState(false); - const [error, setError] = useState(''); + const [error, setError] = useState(""); const handleSubmit = async () => { // Validation if ((!usingDefaultCredentials && !currentPassword) || !newPassword || !confirmPassword) { - setError(t('firstLogin.allFieldsRequired', 'All fields are required')); + setError(t("firstLogin.allFieldsRequired", "All fields are required")); return; } if (newPassword !== confirmPassword) { - setError(t('firstLogin.passwordsDoNotMatch', 'New passwords do not match')); + setError(t("firstLogin.passwordsDoNotMatch", "New passwords do not match")); return; } if (newPassword.length < 8) { - setError(t('firstLogin.passwordTooShort', 'Password must be at least 8 characters')); + setError(t("firstLogin.passwordTooShort", "Password must be at least 8 characters")); return; } if (newPassword === currentPassword) { - setError(t('firstLogin.passwordMustBeDifferent', 'New password must be different from current password')); + setError(t("firstLogin.passwordMustBeDifferent", "New password must be different from current password")); return; } try { setLoading(true); - setError(''); + setError(""); await accountService.changePasswordOnLogin(currentPassword, newPassword, confirmPassword); showToast({ - alertType: 'success', - title: t('firstLogin.passwordChangedSuccess', 'Password changed successfully! Please log in again.') + alertType: "success", + title: t("firstLogin.passwordChangedSuccess", "Password changed successfully! Please log in again."), }); // Clear form - setCurrentPassword(''); - setNewPassword(''); - setConfirmPassword(''); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); // Wait a moment for the user to see the success message setTimeout(() => { onPasswordChanged(); }, 1500); } catch (err) { - console.error('Failed to change password:', err); + console.error("Failed to change password:", err); // Extract error message from axios response if available const axiosError = err as { response?: { data?: { message?: string } } }; setError( axiosError.response?.data?.message || - t('firstLogin.passwordChangeFailed', 'Failed to change password. Please check your current password.') + t("firstLogin.passwordChangeFailed", "Failed to change password. Please check your current password."), ); } finally { setLoading(false); @@ -85,25 +85,18 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
- + - {t( - 'firstLogin.welcomeMessage', - 'For security reasons, you must change your password on your first login.' - )} + {t("firstLogin.welcomeMessage", "For security reasons, you must change your password on your first login.")}
- {t('firstLogin.loggedInAs', 'Logged in as')}: {username} + {t("firstLogin.loggedInAs", "Logged in as")}: {username} {error && ( - } - color="red" - variant="light" - > + } color="red" variant="light"> {error} )} @@ -111,8 +104,8 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = {/* Only show current password field if not using default credentials */} {!usingDefaultCredentials && ( setCurrentPassword(e.currentTarget.value)} required @@ -123,8 +116,8 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = )} setNewPassword(e.currentTarget.value)} minLength={8} @@ -135,8 +128,8 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = /> setConfirmPassword(e.currentTarget.value)} required @@ -154,7 +147,7 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = size="md" mt="xs" > - {t('firstLogin.changePassword', 'Change Password')} + {t("firstLogin.changePassword", "Change Password")}
@@ -168,8 +161,8 @@ export default function FirstLoginSlide({ usingDefaultCredentials = false, }: FirstLoginSlideProps): SlideConfig { return { - key: 'first-login', - title: 'Set Your Password', + key: "first-login", + title: "Set Your Password", body: ( ), background: { - gradientStops: ['#059669', '#0891B2'], // Green to teal - security/trust colors + gradientStops: ["#059669", "#0891B2"], // Green to teal - security/trust colors circles: UNIFIED_CIRCLE_CONFIG, }, }; } - diff --git a/frontend/src/core/components/onboarding/slides/MFASetupSlide.tsx b/frontend/src/core/components/onboarding/slides/MFASetupSlide.tsx index d8def0528a..b565af0f94 100644 --- a/frontend/src/core/components/onboarding/slides/MFASetupSlide.tsx +++ b/frontend/src/core/components/onboarding/slides/MFASetupSlide.tsx @@ -4,7 +4,7 @@ import { QRCodeSVG } from "qrcode.react"; import { SlideConfig } from "@app/types/types"; import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig"; import { accountService } from "@app/services/accountService"; -import { useAccountLogout } from '@app/extensions/accountLogout'; +import { useAccountLogout } from "@app/extensions/accountLogout"; import { useAuth } from "@app/auth/UseSession"; import LocalIcon from "@app/components/shared/LocalIcon"; import { BASE_PATH } from "@app/constants/app"; @@ -59,10 +59,10 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) { }, [fetchMfaSetup]); const redirectToLogin = useCallback(() => { - window.location.assign('/login'); + window.location.assign("/login"); }, []); - const onLogout = useCallback(async() => { + const onLogout = useCallback(async () => { await accountLogout({ signOut, redirectToLogin }); }, [accountLogout, redirectToLogin, signOut]); @@ -84,13 +84,13 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) { } catch (err) { const axiosError = err as { response?: { data?: { error?: string } } }; setMfaError( - axiosError.response?.data?.error || "Unable to enable two-factor authentication. Check the code and try again." + axiosError.response?.data?.error || "Unable to enable two-factor authentication. Check the code and try again.", ); } finally { setSubmitting(false); } }, - [mfaSetupCode, onMfaSetupComplete] + [mfaSetupCode, onMfaSetupComplete], ); const isReady = Boolean(mfaSetupData); @@ -179,18 +179,10 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) { > Regenerate QR code - - diff --git a/frontend/src/core/components/onboarding/slides/PlanOverviewSlide.tsx b/frontend/src/core/components/onboarding/slides/PlanOverviewSlide.tsx index 3b8d4bfb0c..cb86b2062c 100644 --- a/frontend/src/core/components/onboarding/slides/PlanOverviewSlide.tsx +++ b/frontend/src/core/components/onboarding/slides/PlanOverviewSlide.tsx @@ -1,7 +1,7 @@ -import React from 'react'; -import { Trans, useTranslation } from 'react-i18next'; -import { SlideConfig, LicenseNotice } from '@app/types/types'; -import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig'; +import React from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { SlideConfig, LicenseNotice } from "@app/types/types"; +import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig"; interface PlanOverviewSlideProps { isAdmin: boolean; @@ -16,31 +16,23 @@ const PlanOverviewTitle: React.FC<{ isAdmin: boolean }> = ({ isAdmin }) => { return ( <> {isAdmin - ? t('onboarding.planOverview.adminTitle', 'Admin Overview') - : t('onboarding.planOverview.userTitle', 'Plan Overview')} + ? t("onboarding.planOverview.adminTitle", "Admin Overview") + : t("onboarding.planOverview.userTitle", "Plan Overview")} ); }; -const AdminOverviewBody: React.FC<{ freeTierLimit: number; loginEnabled: boolean }> = ({ - freeTierLimit, - loginEnabled, -}) => { +const AdminOverviewBody: React.FC<{ freeTierLimit: number; loginEnabled: boolean }> = ({ freeTierLimit, loginEnabled }) => { const adminBodyKey = loginEnabled - ? 'onboarding.planOverview.adminBodyLoginEnabled' - : 'onboarding.planOverview.adminBodyLoginDisabled'; + ? "onboarding.planOverview.adminBodyLoginEnabled" + : "onboarding.planOverview.adminBodyLoginDisabled"; const defaultValue = loginEnabled - ? 'As an admin, you can manage users, configure settings, and monitor server health. The first {{freeTierLimit}} people on your server get to use Stirling free of charge.' - : 'Once you enable login mode, you can manage users, configure settings, and monitor server health. The first {{freeTierLimit}} people on your server get to use Stirling free of charge.'; + ? "As an admin, you can manage users, configure settings, and monitor server health. The first {{freeTierLimit}} people on your server get to use Stirling free of charge." + : "Once you enable login mode, you can manage users, configure settings, and monitor server health. The first {{freeTierLimit}} people on your server get to use Stirling free of charge."; return ( - }} - defaults={defaultValue} - /> + }} defaults={defaultValue} /> ); }; @@ -49,7 +41,7 @@ const UserOverviewBody: React.FC = () => { return ( {t( - 'onboarding.planOverview.userBody', + "onboarding.planOverview.userBody", "Invite teammates, assign roles, and keep your documents organized in one secure workspace. Enable login mode whenever you're ready to grow beyond solo use.", )} @@ -60,8 +52,7 @@ const PlanOverviewBody: React.FC<{ isAdmin: boolean; freeTierLimit: number; logi isAdmin, freeTierLimit, loginEnabled, -}) => - isAdmin ? : ; +}) => (isAdmin ? : ); export default function PlanOverviewSlide({ isAdmin, @@ -71,13 +62,12 @@ export default function PlanOverviewSlide({ const freeTierLimit = licenseNotice?.freeTierLimit ?? DEFAULT_FREE_TIER_LIMIT; return { - key: isAdmin ? 'admin-overview' : 'plan-overview', + key: isAdmin ? "admin-overview" : "plan-overview", title: , body: , background: { - gradientStops: isAdmin ? ['#4F46E5', '#0EA5E9'] : ['#F97316', '#EF4444'], + gradientStops: isAdmin ? ["#4F46E5", "#0EA5E9"] : ["#F97316", "#EF4444"], circles: UNIFIED_CIRCLE_CONFIG, }, }; } - diff --git a/frontend/src/core/components/onboarding/slides/SecurityCheckSlide.tsx b/frontend/src/core/components/onboarding/slides/SecurityCheckSlide.tsx index 0efb2f591d..f0245e6cd9 100644 --- a/frontend/src/core/components/onboarding/slides/SecurityCheckSlide.tsx +++ b/frontend/src/core/components/onboarding/slides/SecurityCheckSlide.tsx @@ -1,39 +1,41 @@ -import React from 'react'; -import { Select } from '@mantine/core'; -import { SlideConfig } from '@app/types/types'; -import LocalIcon from '@app/components/shared/LocalIcon'; -import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig'; -import i18n from '@app/i18n'; -import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css'; +import React from "react"; +import { Select } from "@mantine/core"; +import { SlideConfig } from "@app/types/types"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig"; +import i18n from "@app/i18n"; +import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css"; interface SecurityCheckSlideProps { - selectedRole: 'admin' | 'user' | null; - onRoleSelect: (role: 'admin' | 'user' | null) => void; + selectedRole: "admin" | "user" | null; + onRoleSelect: (role: "admin" | "user" | null) => void; } -export default function SecurityCheckSlide({ - selectedRole, - onRoleSelect, -}: SecurityCheckSlideProps): SlideConfig { +export default function SecurityCheckSlide({ selectedRole, onRoleSelect }: SecurityCheckSlideProps): SlideConfig { return { - key: 'security-check', - title: 'Security Check', + key: "security-check", + title: "Security Check", body: (
- - {i18n.t('onboarding.securityCheck.message', 'The application has undergone significant changes recently. Your server admin\'s attention may be required. Please confirm your role to continue.')} + + + {i18n.t( + "onboarding.securityCheck.message", + "The application has undergone significant changes recently. Your server admin's attention may be required. Please confirm your role to continue.", + )} +
setShareRole((value as typeof shareRole) || 'editor')} + onChange={(value) => setShareRole((value as typeof shareRole) || "editor")} comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_FILE_MANAGER_MODAL + 10 }} data={[ - { value: 'editor', label: t('storageShare.roleEditor', 'Editor') }, - { value: 'commenter', label: t('storageShare.roleCommenter', 'Commenter') }, - { value: 'viewer', label: t('storageShare.roleViewer', 'Viewer') }, + { value: "editor", label: t("storageShare.roleEditor", "Editor") }, + { value: "commenter", label: t("storageShare.roleCommenter", "Commenter") }, + { value: "viewer", label: t("storageShare.roleViewer", "Viewer") }, ]} /> - {shareRole === 'commenter' && ( + {shareRole === "commenter" && ( - {t('storageShare.commenterHint', 'Commenting is coming soon.')} + {t("storageShare.commenterHint", "Commenting is coming soon.")} )} @@ -245,7 +228,7 @@ const BulkShareModal: React.FC = ({ diff --git a/frontend/src/core/components/shared/BulkUploadToServerModal.tsx b/frontend/src/core/components/shared/BulkUploadToServerModal.tsx index 4e97fe74c7..e9fb4650c1 100644 --- a/frontend/src/core/components/shared/BulkUploadToServerModal.tsx +++ b/frontend/src/core/components/shared/BulkUploadToServerModal.tsx @@ -1,15 +1,15 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Modal, Stack, Text, Button, Group, Alert } from '@mantine/core'; -import CloudUploadIcon from '@mui/icons-material/CloudUpload'; -import { useTranslation } from 'react-i18next'; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Modal, Stack, Text, Button, Group, Alert } from "@mantine/core"; +import CloudUploadIcon from "@mui/icons-material/CloudUpload"; +import { useTranslation } from "react-i18next"; -import { alert } from '@app/components/toast'; -import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex'; -import type { StirlingFileStub } from '@app/types/fileContext'; -import { uploadHistoryChains } from '@app/services/serverStorageUpload'; -import { fileStorage } from '@app/services/fileStorage'; -import { useFileActions } from '@app/contexts/FileContext'; -import type { FileId } from '@app/types/file'; +import { alert } from "@app/components/toast"; +import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import { uploadHistoryChains } from "@app/services/serverStorageUpload"; +import { fileStorage } from "@app/services/fileStorage"; +import { useFileActions } from "@app/contexts/FileContext"; +import type { FileId } from "@app/types/file"; interface BulkUploadToServerModalProps { opened: boolean; @@ -18,12 +18,7 @@ interface BulkUploadToServerModalProps { onUploaded?: () => Promise | void; } -const BulkUploadToServerModal: React.FC = ({ - opened, - onClose, - files, - onUploaded, -}) => { +const BulkUploadToServerModal: React.FC = ({ opened, onClose, files, onUploaded }) => { const { t } = useTranslation(); const { actions } = useFileActions(); const [isUploading, setIsUploading] = useState(false); @@ -44,18 +39,11 @@ const BulkUploadToServerModal: React.FC = ({ setErrorMessage(null); try { - const rootIds = Array.from( - new Set(files.map((file) => (file.originalFileId || file.id) as FileId)) - ); - const remoteIds = Array.from( - new Set(files.map((file) => file.remoteStorageId).filter(Boolean) as number[]) - ); + const rootIds = Array.from(new Set(files.map((file) => (file.originalFileId || file.id) as FileId))); + const remoteIds = Array.from(new Set(files.map((file) => file.remoteStorageId).filter(Boolean) as number[])); const existingRemoteId = remoteIds.length === 1 ? remoteIds[0] : undefined; - const { remoteId, updatedAt, chain } = await uploadHistoryChains( - rootIds, - existingRemoteId - ); + const { remoteId, updatedAt, chain } = await uploadHistoryChains(rootIds, existingRemoteId); for (const stub of chain) { actions.updateStirlingFileStub(stub.id, { @@ -73,8 +61,8 @@ const BulkUploadToServerModal: React.FC = ({ } alert({ - alertType: 'success', - title: t('storageUpload.success', 'Uploaded to server'), + alertType: "success", + title: t("storageUpload.success", "Uploaded to server"), expandable: false, durationMs: 3000, }); @@ -83,10 +71,8 @@ const BulkUploadToServerModal: React.FC = ({ } onClose(); } catch (error) { - console.error('Failed to upload files to server:', error); - setErrorMessage( - t('storageUpload.failure', 'Upload failed. Please check your login and storage settings.') - ); + console.error("Failed to upload files to server:", error); + setErrorMessage(t("storageUpload.failure", "Upload failed. Please check your login and storage settings.")); } finally { setIsUploading(false); } @@ -97,48 +83,39 @@ const BulkUploadToServerModal: React.FC = ({ opened={opened} onClose={onClose} centered - title={t('storageUpload.bulkTitle', 'Upload selected files')} + title={t("storageUpload.bulkTitle", "Upload selected files")} zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL} > - - {t( - 'storageUpload.bulkDescription', - 'This uploads the selected files to your server storage.' - )} - + {t("storageUpload.bulkDescription", "This uploads the selected files to your server storage.")} - {t('storageUpload.fileCount', '{{count}} files selected', { + {t("storageUpload.fileCount", "{{count}} files selected", { count: files.length, })} {displayNames.length > 0 && ( - {displayNames.join(', ')} + {displayNames.join(", ")} {fileNames.length > displayNames.length - ? t('storageUpload.more', ' +{{count}} more', { + ? t("storageUpload.more", " +{{count}} more", { count: fileNames.length - displayNames.length, }) - : ''} + : ""} )} {errorMessage && ( - + {errorMessage} )} - diff --git a/frontend/src/core/components/shared/ButtonSelector.test.tsx b/frontend/src/core/components/shared/ButtonSelector.test.tsx index 12a509abd6..d0715a3090 100644 --- a/frontend/src/core/components/shared/ButtonSelector.test.tsx +++ b/frontend/src/core/components/shared/ButtonSelector.test.tsx @@ -1,214 +1,175 @@ -import { describe, expect, test, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; -import { MantineProvider } from '@mantine/core'; -import ButtonSelector from '@app/components/shared/ButtonSelector'; +import { describe, expect, test, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import ButtonSelector from "@app/components/shared/ButtonSelector"; // Wrapper component to provide Mantine context -const TestWrapper = ({ children }: { children: React.ReactNode }) => ( - {children} -); +const TestWrapper = ({ children }: { children: React.ReactNode }) => {children}; -describe('ButtonSelector', () => { +describe("ButtonSelector", () => { const mockOnChange = vi.fn(); beforeEach(() => { vi.clearAllMocks(); }); - test('should render all options as buttons', () => { + test("should render all options as buttons", () => { const options = [ - { value: 'option1', label: 'Option 1' }, - { value: 'option2', label: 'Option 2' }, + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, ]; render( - - + + , ); - expect(screen.getByText('Test Label')).toBeInTheDocument(); - expect(screen.getByText('Option 1')).toBeInTheDocument(); - expect(screen.getByText('Option 2')).toBeInTheDocument(); + expect(screen.getByText("Test Label")).toBeInTheDocument(); + expect(screen.getByText("Option 1")).toBeInTheDocument(); + expect(screen.getByText("Option 2")).toBeInTheDocument(); }); - test('should highlight selected button with filled variant', () => { + test("should highlight selected button with filled variant", () => { const options = [ - { value: 'option1', label: 'Option 1' }, - { value: 'option2', label: 'Option 2' }, + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, ]; render( - - + + , ); - const selectedButton = screen.getByRole('button', { name: 'Option 1' }); - const unselectedButton = screen.getByRole('button', { name: 'Option 2' }); + const selectedButton = screen.getByRole("button", { name: "Option 1" }); + const unselectedButton = screen.getByRole("button", { name: "Option 2" }); // Check data-variant attribute for filled/outline - expect(selectedButton).toHaveAttribute('data-variant', 'filled'); - expect(unselectedButton).toHaveAttribute('data-variant', 'outline'); - expect(screen.getByText('Selection Label')).toBeInTheDocument(); + expect(selectedButton).toHaveAttribute("data-variant", "filled"); + expect(unselectedButton).toHaveAttribute("data-variant", "outline"); + expect(screen.getByText("Selection Label")).toBeInTheDocument(); }); - test('should call onChange when button is clicked', () => { + test("should call onChange when button is clicked", () => { const options = [ - { value: 'option1', label: 'Option 1' }, - { value: 'option2', label: 'Option 2' }, + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, ]; render( - - + + , ); - fireEvent.click(screen.getByRole('button', { name: 'Option 2' })); + fireEvent.click(screen.getByRole("button", { name: "Option 2" })); - expect(mockOnChange).toHaveBeenCalledWith('option2'); + expect(mockOnChange).toHaveBeenCalledWith("option2"); }); - test('should handle undefined value (no selection)', () => { + test("should handle undefined value (no selection)", () => { const options = [ - { value: 'option1', label: 'Option 1' }, - { value: 'option2', label: 'Option 2' }, + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, ]; render( - - + + , ); // Both buttons should be outlined when no value is selected - const button1 = screen.getByRole('button', { name: 'Option 1' }); - const button2 = screen.getByRole('button', { name: 'Option 2' }); + const button1 = screen.getByRole("button", { name: "Option 1" }); + const button2 = screen.getByRole("button", { name: "Option 2" }); - expect(button1).toHaveAttribute('data-variant', 'outline'); - expect(button2).toHaveAttribute('data-variant', 'outline'); + expect(button1).toHaveAttribute("data-variant", "outline"); + expect(button2).toHaveAttribute("data-variant", "outline"); }); test.each([ { - description: 'disable buttons when disabled prop is true', + description: "disable buttons when disabled prop is true", options: [ - { value: 'option1', label: 'Option 1' }, - { value: 'option2', label: 'Option 2' }, + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, ], globalDisabled: true, expectedStates: [true, true], }, { - description: 'disable individual options when option.disabled is true', + description: "disable individual options when option.disabled is true", options: [ - { value: 'option1', label: 'Option 1' }, - { value: 'option2', label: 'Option 2', disabled: true }, + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2", disabled: true }, ], globalDisabled: false, expectedStates: [false, true], }, - ])('should $description', ({ options, globalDisabled, expectedStates }) => { + ])("should $description", ({ options, globalDisabled, expectedStates }) => { render( - - + + , ); options.forEach((option, index) => { - const button = screen.getByRole('button', { name: option.label }); - expect(button).toHaveProperty('disabled', expectedStates[index]); + const button = screen.getByRole("button", { name: option.label }); + expect(button).toHaveProperty("disabled", expectedStates[index]); }); }); - test('should not call onChange when disabled button is clicked', () => { + test("should not call onChange when disabled button is clicked", () => { const options = [ - { value: 'option1', label: 'Option 1' }, - { value: 'option2', label: 'Option 2', disabled: true }, + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2", disabled: true }, ]; render( - - + + , ); - fireEvent.click(screen.getByRole('button', { name: 'Option 2' })); + fireEvent.click(screen.getByRole("button", { name: "Option 2" })); expect(mockOnChange).not.toHaveBeenCalled(); }); - test('should not apply fullWidth styling when fullWidth is false', () => { + test("should not apply fullWidth styling when fullWidth is false", () => { const options = [ - { value: 'option1', label: 'Option 1' }, - { value: 'option2', label: 'Option 2' }, + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, ]; render( - - + + , ); - const button = screen.getByRole('button', { name: 'Option 1' }); - expect(button).not.toHaveStyle({ flex: '1' }); - expect(screen.getByText('Layout Label')).toBeInTheDocument(); + const button = screen.getByRole("button", { name: "Option 1" }); + expect(button).not.toHaveStyle({ flex: "1" }); + expect(screen.getByText("Layout Label")).toBeInTheDocument(); }); - test('should not render label element when not provided', () => { + test("should not render label element when not provided", () => { const options = [ - { value: 'option1', label: 'Option 1' }, - { value: 'option2', label: 'Option 2' }, + { value: "option1", label: "Option 1" }, + { value: "option2", label: "Option 2" }, ]; const { container } = render( - - + + , ); // Should render buttons - expect(screen.getByText('Option 1')).toBeInTheDocument(); - expect(screen.getByText('Option 2')).toBeInTheDocument(); - + expect(screen.getByText("Option 1")).toBeInTheDocument(); + expect(screen.getByText("Option 2")).toBeInTheDocument(); + // Stack should only contain the Group (buttons), no Text element for label const stackElement = container.querySelector('[class*="mantine-Stack-root"]'); expect(stackElement?.children).toHaveLength(1); // Only the Group, no label Text diff --git a/frontend/src/core/components/shared/ButtonSelector.tsx b/frontend/src/core/components/shared/ButtonSelector.tsx index 94bd10c6e7..548d75fd81 100644 --- a/frontend/src/core/components/shared/ButtonSelector.tsx +++ b/frontend/src/core/components/shared/ButtonSelector.tsx @@ -5,7 +5,7 @@ export interface ButtonOption { value: T; label: string; disabled?: boolean; - tooltip?: string; // Tooltip shown on hover (useful for explaining why option is disabled) + tooltip?: string; // Tooltip shown on hover (useful for explaining why option is disabled) } interface ButtonSelectorProps { @@ -30,42 +30,42 @@ const ButtonSelector = ({ textClassName, }: ButtonSelectorProps) => { return ( - + {/* Label (if it exists) */} - {label && {label}} + {label && ( + + {label} + + )} {/* Buttons */} - + {options.map((option) => { const isDisabled = disabled || option.disabled; const button = ( ); @@ -73,12 +73,16 @@ const ButtonSelector = ({ if (option.tooltip && isDisabled) { return ( - {button} + {button} ); } - return {button}; + return ( + + {button} + + ); })} diff --git a/frontend/src/core/components/shared/ButtonToggle.tsx b/frontend/src/core/components/shared/ButtonToggle.tsx index f695c8a8bc..2f434e0086 100644 --- a/frontend/src/core/components/shared/ButtonToggle.tsx +++ b/frontend/src/core/components/shared/ButtonToggle.tsx @@ -1,5 +1,5 @@ -import { Button, Stack } from '@mantine/core'; -import React from 'react'; +import { Button, Stack } from "@mantine/core"; +import React from "react"; export interface ButtonToggleOption { value: string; @@ -13,8 +13,8 @@ export interface ButtonToggleProps { value: string; onChange: (value: string) => void; disabled?: boolean; - orientation?: 'vertical' | 'horizontal'; - size?: 'xs' | 'sm' | 'md' | 'lg'; + orientation?: "vertical" | "horizontal"; + size?: "xs" | "sm" | "md" | "lg"; fullWidth?: boolean; } @@ -23,18 +23,18 @@ export const ButtonToggle: React.FC = ({ value, onChange, disabled = false, - orientation = 'vertical', - size = 'md', + orientation = "vertical", + size = "md", fullWidth = true, }) => { - const isVertical = orientation === 'vertical'; + const isVertical = orientation === "vertical"; const buttonStyle: React.CSSProperties = { - justifyContent: 'flex-start', - height: isVertical ? 'auto' : undefined, - minHeight: isVertical ? '50px' : undefined, - padding: isVertical ? '12px 16px' : undefined, - textAlign: 'left', + justifyContent: "flex-start", + height: isVertical ? "auto" : undefined, + minHeight: isVertical ? "50px" : undefined, + padding: isVertical ? "12px 16px" : undefined, + textAlign: "left", }; const renderButton = (option: ButtonToggleOption) => { @@ -44,21 +44,21 @@ export const ButtonToggle: React.FC = ({ return ( ); diff --git a/frontend/src/core/components/shared/DropdownListWithFooter.tsx b/frontend/src/core/components/shared/DropdownListWithFooter.tsx index b5e5a9f5d7..11d457ef99 100644 --- a/frontend/src/core/components/shared/DropdownListWithFooter.tsx +++ b/frontend/src/core/components/shared/DropdownListWithFooter.tsx @@ -1,8 +1,8 @@ -import React, { ReactNode, useState, useMemo } from 'react'; -import { Stack, Text, Popover, Box, Checkbox, Group, TextInput } from '@mantine/core'; -import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore'; -import SearchIcon from '@mui/icons-material/Search'; -import { Z_INDEX_AUTOMATE_DROPDOWN } from '@app/styles/zIndex'; +import React, { ReactNode, useState, useMemo } from "react"; +import { Stack, Text, Popover, Box, Checkbox, Group, TextInput } from "@mantine/core"; +import UnfoldMoreIcon from "@mui/icons-material/UnfoldMore"; +import SearchIcon from "@mui/icons-material/Search"; +import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex"; export interface DropdownItem { value: string; @@ -15,30 +15,30 @@ export interface DropdownListWithFooterProps { // Value and onChange - support both single and multi-select value: string | string[]; onChange: (value: string | string[]) => void; - + // Items and display items: DropdownItem[]; placeholder?: string; disabled?: boolean; - + // Labels and headers label?: string; header?: ReactNode; footer?: ReactNode; - + // Behavior multiSelect?: boolean; searchable?: boolean; maxHeight?: number; - + // Styling className?: string; dropdownClassName?: string; - + // Popover props - position?: 'top' | 'bottom' | 'left' | 'right'; + position?: "top" | "bottom" | "left" | "right"; withArrow?: boolean; - width?: 'target' | number; + width?: "target" | number; withinPortal?: boolean; zIndex?: number; } @@ -47,7 +47,7 @@ const DropdownListWithFooter: React.FC = ({ value, onChange, items, - placeholder = 'Select option', + placeholder = "Select option", disabled = false, label, header, @@ -55,34 +55,31 @@ const DropdownListWithFooter: React.FC = ({ multiSelect = false, searchable = false, maxHeight = 300, - className = '', - dropdownClassName = '', - position = 'bottom', + className = "", + dropdownClassName = "", + position = "bottom", withArrow = false, - width = 'target', + width = "target", withinPortal = true, - zIndex = Z_INDEX_AUTOMATE_DROPDOWN + zIndex = Z_INDEX_AUTOMATE_DROPDOWN, }) => { - - const [searchTerm, setSearchTerm] = useState(''); - + const [searchTerm, setSearchTerm] = useState(""); + const isMultiValue = Array.isArray(value); - const selectedValues = isMultiValue ? value : (value ? [value] : []); + const selectedValues = isMultiValue ? value : value ? [value] : []; // Filter items based on search term const filteredItems = useMemo(() => { if (!searchable || !searchTerm.trim()) { return items; } - return items.filter(item => - item.name.toLowerCase().includes(searchTerm.toLowerCase()) - ); + return items.filter((item) => item.name.toLowerCase().includes(searchTerm.toLowerCase())); }, [items, searchTerm, searchable]); const handleItemClick = (itemValue: string) => { if (multiSelect) { const newSelection = selectedValues.includes(itemValue) - ? selectedValues.filter(v => v !== itemValue) + ? selectedValues.filter((v) => v !== itemValue) : [...selectedValues, itemValue]; onChange(newSelection); } else { @@ -94,7 +91,7 @@ const DropdownListWithFooter: React.FC = ({ if (selectedValues.length === 0) { return placeholder; } else if (selectedValues.length === 1) { - const selectedItem = items.find(item => item.value === selectedValues[0]); + const selectedItem = items.find((item) => item.value === selectedValues[0]); return selectedItem?.name || selectedValues[0]; } else { return `${selectedValues.length} selected`; @@ -112,125 +109,130 @@ const DropdownListWithFooter: React.FC = ({ {label} )} - - searchable && setSearchTerm('')} + onClose={() => searchable && setSearchTerm("")} withinPortal={withinPortal} zIndex={zIndex} > {getDisplayText()} - + - + {header && ( - + {header} )} - + {searchable && ( - + } + leftSection={} size="sm" - style={{ width: '100%' }} + style={{ width: "100%" }} /> )} - - + + {filteredItems.length === 0 ? ( - + - {searchable && searchTerm ? 'No results found' : 'No items available'} + {searchable && searchTerm ? "No results found" : "No items available"} ) : ( filteredItems.map((item) => ( - !item.disabled && handleItemClick(item.value)} - style={{ - padding: '8px 12px', - cursor: item.disabled ? 'not-allowed' : 'pointer', - borderRadius: 'var(--mantine-radius-sm)', - opacity: item.disabled ? 0.5 : 1, - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between' - }} - onMouseEnter={(e) => { - if (!item.disabled) { - e.currentTarget.style.backgroundColor = 'light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5))'; - } - }} - onMouseLeave={(e) => { - e.currentTarget.style.backgroundColor = 'transparent'; - }} - > - - {item.leftIcon && ( - - {item.leftIcon} - + !item.disabled && handleItemClick(item.value)} + style={{ + padding: "8px 12px", + cursor: item.disabled ? "not-allowed" : "pointer", + borderRadius: "var(--mantine-radius-sm)", + opacity: item.disabled ? 0.5 : 1, + display: "flex", + alignItems: "center", + justifyContent: "space-between", + }} + onMouseEnter={(e) => { + if (!item.disabled) { + e.currentTarget.style.backgroundColor = + "light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5))"; + } + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = "transparent"; + }} + > + + {item.leftIcon && {item.leftIcon}} + {item.name} + + + {multiSelect && ( + {}} // Handled by parent onClick + size="sm" + disabled={item.disabled} + /> )} - {item.name} - - - {multiSelect && ( - {}} // Handled by parent onClick - size="sm" - disabled={item.disabled} - /> - )} - + )) )} - + {footer && ( - + {footer} )} @@ -241,4 +243,4 @@ const DropdownListWithFooter: React.FC = ({ ); }; -export default DropdownListWithFooter; \ No newline at end of file +export default DropdownListWithFooter; diff --git a/frontend/src/core/components/shared/EditableSecretField.tsx b/frontend/src/core/components/shared/EditableSecretField.tsx index dfd3da6458..927e46a73c 100644 --- a/frontend/src/core/components/shared/EditableSecretField.tsx +++ b/frontend/src/core/components/shared/EditableSecretField.tsx @@ -1,7 +1,7 @@ -import { useState, useRef, useEffect } from 'react'; -import { PasswordInput, Group, ActionIcon, Tooltip, TextInput } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import LocalIcon from '@app/components/shared/LocalIcon'; +import { useState, useRef, useEffect } from "react"; +import { PasswordInput, Group, ActionIcon, Tooltip, TextInput } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import LocalIcon from "@app/components/shared/LocalIcon"; interface EditableSecretFieldProps { label?: string; @@ -26,16 +26,16 @@ export default function EditableSecretField({ description, value, onChange, - placeholder = 'Enter value', + placeholder = "Enter value", disabled = false, error, }: EditableSecretFieldProps) { const { t } = useTranslation(); const [isEditing, setIsEditing] = useState(false); - const [tempValue, setTempValue] = useState(''); + const [tempValue, setTempValue] = useState(""); const inputRef = useRef(null); - const isMasked = value === '********'; + const isMasked = value === "********"; useEffect(() => { if (isEditing && inputRef.current) { @@ -44,45 +44,34 @@ export default function EditableSecretField({ }, [isEditing]); const handleEdit = () => { - setTempValue(''); + setTempValue(""); setIsEditing(true); }; const handleCancel = () => { - setTempValue(''); + setTempValue(""); setIsEditing(false); }; const handleSave = () => { - if (tempValue.trim() !== '') { + if (tempValue.trim() !== "") { onChange(tempValue); } - setTempValue(''); + setTempValue(""); setIsEditing(false); }; return (
- {label && } - {description &&

{description}

} + {label && } + {description &&

{description}

} {isMasked && !isEditing ? ( // Masked value from backend: show display + Edit button - - - + + + @@ -99,7 +88,7 @@ export default function EditableSecretField({ autoComplete="new-password" onBlur={handleSave} onKeyDown={(e) => { - if (e.key === 'Escape') handleCancel(); + if (e.key === "Escape") handleCancel(); }} /> ) : ( diff --git a/frontend/src/core/components/shared/EncryptedPdfUnlockModal.tsx b/frontend/src/core/components/shared/EncryptedPdfUnlockModal.tsx index 5ba2dbf053..765f90704b 100644 --- a/frontend/src/core/components/shared/EncryptedPdfUnlockModal.tsx +++ b/frontend/src/core/components/shared/EncryptedPdfUnlockModal.tsx @@ -1,7 +1,7 @@ -import { Modal, Stack, Text, Button, PasswordInput, Group } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import { type KeyboardEventHandler } from 'react'; -import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex'; +import { Modal, Stack, Text, Button, PasswordInput, Group } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { type KeyboardEventHandler } from "react"; +import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; interface EncryptedPdfUnlockModalProps { opened: boolean; @@ -27,7 +27,7 @@ const EncryptedPdfUnlockModal = ({ const { t } = useTranslation(); const handleKeyDown: KeyboardEventHandler = (event) => { - if (event.key === 'Enter' && !isProcessing && password.trim().length > 0) { + if (event.key === "Enter" && !isProcessing && password.trim().length > 0) { onUnlock(); } }; @@ -36,7 +36,7 @@ const EncryptedPdfUnlockModal = ({ - {fileName} + + {fileName} + {t( - 'encryptedPdfUnlock.description', - 'This PDF is password protected. Enter the password so you can continue working with it.' + "encryptedPdfUnlock.description", + "This PDF is password protected. Enter the password so you can continue working with it.", )} onPasswordChange(event.currentTarget.value)} onKeyDown={handleKeyDown} @@ -71,10 +73,10 @@ const EncryptedPdfUnlockModal = ({ diff --git a/frontend/src/core/components/shared/ErrorBoundary.tsx b/frontend/src/core/components/shared/ErrorBoundary.tsx index 0bab94f0a2..5b075c6a93 100644 --- a/frontend/src/core/components/shared/ErrorBoundary.tsx +++ b/frontend/src/core/components/shared/ErrorBoundary.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { Text, Button, Stack } from '@mantine/core'; +import React from "react"; +import { Text, Button, Stack } from "@mantine/core"; interface ErrorBoundaryState { hasError: boolean; @@ -8,7 +8,7 @@ interface ErrorBoundaryState { interface ErrorBoundaryProps { children: React.ReactNode; - fallback?: React.ComponentType<{error?: Error; retry: () => void}>; + fallback?: React.ComponentType<{ error?: Error; retry: () => void }>; } export default class ErrorBoundary extends React.Component { @@ -23,22 +23,22 @@ export default class ErrorBoundary extends React.Component { @@ -72,26 +72,36 @@ export default class ErrorBoundary extends React.Component - Something went wrong - {process.env.NODE_ENV === 'development' && this.state.error && ( + + + Something went wrong + + {process.env.NODE_ENV === "development" && this.state.error && ( <> - + {this.state.error.message} {this.state.error.stack && ( -
- - Show stack trace +
+ + + Show stack trace + -
+                  
                     {this.state.error.stack}
                   
diff --git a/frontend/src/core/components/shared/FileCard.tsx b/frontend/src/core/components/shared/FileCard.tsx index dda4791753..569345f9d5 100644 --- a/frontend/src/core/components/shared/FileCard.tsx +++ b/frontend/src/core/components/shared/FileCard.tsx @@ -22,7 +22,17 @@ interface FileCardProps { isSupported?: boolean; // Whether the file format is supported by the current tool } -const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => { +const FileCard = ({ + file, + fileStub, + onRemove, + onDoubleClick, + onView, + onEdit, + isSelected, + onSelect, + isSupported = true, +}: FileCardProps) => { const { t } = useTranslation(); // Use record thumbnail if available, otherwise fall back to IndexedDB lookup const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileStub); @@ -30,7 +40,7 @@ const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isS const [isHovered, setIsHovered] = useState(false); // Show loading state during hydration: PDF file without thumbnail yet - const isPdf = file.type === 'application/pdf'; + const isPdf = file.type === "application/pdf"; const isHydrating = isPdf && !thumb && !isGenerating; return ( @@ -44,11 +54,11 @@ const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isS minWidth: 180, maxWidth: 260, cursor: onDoubleClick && isSupported ? "pointer" : undefined, - position: 'relative', - border: isSelected ? '2px solid var(--mantine-color-blue-6)' : undefined, - backgroundColor: isSelected ? 'var(--mantine-color-blue-0)' : undefined, + position: "relative", + border: isSelected ? "2px solid var(--mantine-color-blue-6)" : undefined, + backgroundColor: isSelected ? "var(--mantine-color-blue-0)" : undefined, opacity: isSupported ? 1 : 0.5, - filter: isSupported ? 'none' : 'grayscale(50%)' + filter: isSupported ? "none" : "grayscale(50%)", }} onDoubleClick={onDoubleClick} onMouseEnter={() => setIsHovered(true)} @@ -69,22 +79,22 @@ const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isS justifyContent: "center", margin: "0 auto", background: "#fafbfc", - position: 'relative' + position: "relative", }} > {/* Hover action buttons */} {isHovered && (onView || onEdit) && (
e.stopPropagation()} > @@ -121,26 +131,23 @@ const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isS
)} {thumb ? ( - PDF thumbnail - ) : (isGenerating || isHydrating) ? ( + PDF thumbnail + ) : isGenerating || isHydrating ? ( - Loading... + + Loading... + ) : ( -
+
100 * 1024 * 1024 ? "orange" : "red"} @@ -151,7 +158,9 @@ const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isS {file.size > 100 * 1024 * 1024 && ( - Large File + + Large File + )}
)} @@ -169,12 +178,7 @@ const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isS {getFileDate(file)} {fileStub?.id && ( - } - > + }> DB )} diff --git a/frontend/src/core/components/shared/FileDropdownMenu.tsx b/frontend/src/core/components/shared/FileDropdownMenu.tsx index fcb1a6a266..cd35d43aa1 100644 --- a/frontend/src/core/components/shared/FileDropdownMenu.tsx +++ b/frontend/src/core/components/shared/FileDropdownMenu.tsx @@ -1,12 +1,12 @@ -import React from 'react'; -import { Menu, Loader, Group, Text, ActionIcon, Tooltip } from '@mantine/core'; -import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'; -import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; -import CloseIcon from '@mui/icons-material/Close'; -import FitText from '@app/components/shared/FitText'; -import { PrivateContent } from '@app/components/shared/PrivateContent'; -import { FileId } from '@app/types/file'; -import { truncateCenter } from '@app/utils/textUtils'; +import React from "react"; +import { Menu, Loader, Group, Text, ActionIcon, Tooltip } from "@mantine/core"; +import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import CloseIcon from "@mui/icons-material/Close"; +import FitText from "@app/components/shared/FitText"; +import { PrivateContent } from "@app/components/shared/PrivateContent"; +import { FileId } from "@app/types/file"; +import { truncateCenter } from "@app/utils/textUtils"; interface FileDropdownMenuProps { displayName: string; @@ -31,7 +31,7 @@ export const FileDropdownMenu: React.FC = ({ return ( -
+
{switchingTo === "viewer" ? ( ) : ( @@ -41,22 +41,24 @@ export const FileDropdownMenu: React.FC = ({
- + {activeFiles.map((file, index) => { - const itemName = file?.name || 'Untitled'; + const itemName = file?.name || "Untitled"; const isActive = index === currentFileIndex; return ( = ({ onFileSelect?.(index); }} className="viewer-file-tab" - {...(isActive && { 'data-active': true })} + {...(isActive && { "data-active": true })} style={{ - justifyContent: 'flex-start', + justifyContent: "flex-start", }} > - -
+ +
diff --git a/frontend/src/core/components/shared/FileGrid.tsx b/frontend/src/core/components/shared/FileGrid.tsx index 5bd31008db..c9ec2fe1b5 100644 --- a/frontend/src/core/components/shared/FileGrid.tsx +++ b/frontend/src/core/components/shared/FileGrid.tsx @@ -24,7 +24,7 @@ interface FileGridProps { isFileSupported?: (fileName: string) => boolean; // Function to check if file is supported } -type SortOption = 'date' | 'name' | 'size'; +type SortOption = "date" | "name" | "size"; const FileGrid = ({ files, @@ -40,25 +40,23 @@ const FileGrid = ({ onShowAll, showingAll = false, onDeleteAll, - isFileSupported + isFileSupported, }: FileGridProps) => { const { t } = useTranslation(); const [searchTerm, setSearchTerm] = useState(""); - const [sortBy, setSortBy] = useState('date'); + const [sortBy, setSortBy] = useState("date"); // Filter files based on search term - const filteredFiles = files.filter(item => - item.file.name.toLowerCase().includes(searchTerm.toLowerCase()) - ); + const filteredFiles = files.filter((item) => item.file.name.toLowerCase().includes(searchTerm.toLowerCase())); // Sort files const sortedFiles = [...filteredFiles].sort((a, b) => { switch (sortBy) { - case 'date': + case "date": return (b.file.lastModified || 0) - (a.file.lastModified || 0); - case 'name': + case "name": return a.file.name.localeCompare(b.file.name); - case 'size': + case "size": return (b.file.size || 0) - (a.file.size || 0); default: return 0; @@ -66,14 +64,12 @@ const FileGrid = ({ }); // Apply max display limit if specified - const displayFiles = maxDisplay && !showingAll - ? sortedFiles.slice(0, maxDisplay) - : sortedFiles; + const displayFiles = maxDisplay && !showingAll ? sortedFiles.slice(0, maxDisplay) : sortedFiles; const hasMoreFiles = maxDisplay && !showingAll && sortedFiles.length > maxDisplay; return ( - + {/* Search and Sort Controls */} {(showSearch || showSort || onDeleteAll) && ( @@ -91,9 +87,9 @@ const FileGrid = ({ {showSort && ( + ); } diff --git a/frontend/src/core/components/shared/LandingDocumentStack.tsx b/frontend/src/core/components/shared/LandingDocumentStack.tsx index 3fffd48602..b0a37a0f67 100644 --- a/frontend/src/core/components/shared/LandingDocumentStack.tsx +++ b/frontend/src/core/components/shared/LandingDocumentStack.tsx @@ -19,9 +19,9 @@ export function LandingDocumentStack() {
-
-
-
+
+
+
diff --git a/frontend/src/core/components/shared/LandingPage.tsx b/frontend/src/core/components/shared/LandingPage.tsx index 1d9b878f46..46675255f3 100644 --- a/frontend/src/core/components/shared/LandingPage.tsx +++ b/frontend/src/core/components/shared/LandingPage.tsx @@ -1,14 +1,14 @@ -import React, { useState } from 'react'; -import { Container } from '@mantine/core'; -import { Dropzone } from '@mantine/dropzone'; -import { useTranslation } from 'react-i18next'; -import { useFileHandler } from '@app/hooks/useFileHandler'; -import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology'; -import MobileUploadModal from '@app/components/shared/MobileUploadModal'; -import { openFilesFromDisk } from '@app/services/openFilesFromDisk'; -import { LandingDocumentStack } from '@app/components/shared/LandingDocumentStack'; -import { LandingActions } from '@app/components/shared/LandingActions'; -import '@app/components/shared/LandingPage.css'; +import React, { useState } from "react"; +import { Container } from "@mantine/core"; +import { Dropzone } from "@mantine/dropzone"; +import { useTranslation } from "react-i18next"; +import { useFileHandler } from "@app/hooks/useFileHandler"; +import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology"; +import MobileUploadModal from "@app/components/shared/MobileUploadModal"; +import { openFilesFromDisk } from "@app/services/openFilesFromDisk"; +import { LandingDocumentStack } from "@app/components/shared/LandingDocumentStack"; +import { LandingActions } from "@app/components/shared/LandingActions"; +import "@app/components/shared/LandingPage.css"; const LandingPage = () => { const { t } = useTranslation(); @@ -36,7 +36,7 @@ const LandingPage = () => { if (files.length > 0) { await addFiles(files); } - event.target.value = ''; + event.target.value = ""; }; const handleFilesReceivedFromMobile = async (files: File[]) => { @@ -46,7 +46,7 @@ const LandingPage = () => { }; return ( - + { className="flex min-h-0 flex-1 cursor-default flex-col items-center justify-center border-none bg-transparent px-4 py-8 shadow-none outline-none" styles={{ root: { - border: 'none !important', - backgroundColor: 'transparent', - overflow: 'visible', - '&[data-accept]': { outline: '2px dashed var(--accent-interactive)', outlineOffset: 4 }, - '&[data-reject]': { outline: '2px dashed var(--mantine-color-red-6)', outlineOffset: 4 }, + border: "none !important", + backgroundColor: "transparent", + overflow: "visible", + "&[data-accept]": { outline: "2px dashed var(--accent-interactive)", outlineOffset: 4 }, + "&[data-reject]": { outline: "2px dashed var(--mantine-color-red-6)", outlineOffset: 4 }, }, - inner: { overflow: 'visible', display: 'flex', flexDirection: 'column', alignItems: 'center', width: '100%' }, + inner: { overflow: "visible", display: "flex", flexDirection: "column", alignItems: "center", width: "100%" }, }} > -

{t('landing.heroTitle', 'Stirling PDF')}

-

{t('landing.heroSubtitle', 'Drop in or add an existing PDF to get started.')}

+

{t("landing.heroTitle", "Stirling PDF")}

+

{t("landing.heroSubtitle", "Drop in or add an existing PDF to get started.")}

['position']; + position?: React.ComponentProps["position"]; offset?: number; compact?: boolean; // icon-only trigger tooltip?: string; // tooltip text for compact mode @@ -48,12 +48,12 @@ const LanguageItem: React.FC = ({ rippleEffect, pendingLanguage, compact, - disabled = false + disabled = false, }) => { const { t } = useTranslation(); const labelText = option.label; - const comingSoonText = t('comingSoon', 'Coming soon'); + const comingSoonText = t("comingSoon", "Coming soon"); const label = disabled ? ( @@ -68,7 +68,7 @@ const LanguageItem: React.FC = ({ className={styles.languageItem} style={{ opacity: animationTriggered ? 1 : 0, - transform: animationTriggered ? 'translateY(0px)' : 'translateY(8px)', + transform: animationTriggered ? "translateY(0px)" : "translateY(8px)", transition: `opacity 0.15s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.01}s, transform 0.15s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.01}s`, }} > @@ -81,40 +81,42 @@ const LanguageItem: React.FC = ({ disabled={disabled} styles={{ root: { - borderRadius: '4px', - minHeight: '32px', - padding: '4px 8px', - justifyContent: 'flex-start', - position: 'relative', - overflow: 'hidden', + borderRadius: "4px", + minHeight: "32px", + padding: "4px 8px", + justifyContent: "flex-start", + position: "relative", + overflow: "hidden", backgroundColor: isSelected - ? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))' - : 'transparent', + ? "light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))" + : "transparent", color: disabled - ? 'light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3))' + ? "light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3))" : isSelected - ? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))' - : 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))', - transition: 'all 0.12s cubic-bezier(0.25, 0.46, 0.45, 0.94)', - cursor: disabled ? 'not-allowed' : 'pointer', - '&:hover': !disabled ? { - backgroundColor: isSelected - ? 'light-dark(var(--mantine-color-blue-2), var(--mantine-color-blue-7))' - : 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))', - transform: 'translateY(-1px)', - boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)', - } : {} + ? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))" + : "light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))", + transition: "all 0.12s cubic-bezier(0.25, 0.46, 0.45, 0.94)", + cursor: disabled ? "not-allowed" : "pointer", + "&:hover": !disabled + ? { + backgroundColor: isSelected + ? "light-dark(var(--mantine-color-blue-2), var(--mantine-color-blue-7))" + : "light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))", + transform: "translateY(-1px)", + boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)", + } + : {}, }, label: { - fontSize: '13px', + fontSize: "13px", fontWeight: isSelected ? 600 : 400, - textAlign: 'left', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - position: 'relative', + textAlign: "left", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + position: "relative", zIndex: 2, - } + }, }} > {label} @@ -122,16 +124,16 @@ const LanguageItem: React.FC = ({
@@ -155,10 +157,10 @@ const RippleStyles: React.FC = () => ( // Main component const LanguageSelector: React.FC = ({ - position = 'bottom-start', + position = "bottom-start", offset = 8, compact = false, - tooltip + tooltip, }) => { const { i18n, ready } = useTranslation(); const [opened, setOpened] = useState(false); @@ -183,8 +185,7 @@ const LanguageSelector: React.FC = ({ // Get the filtered list of supported languages from i18n // This respects server config (ui.languages) applied by AppConfigLoader - const allowedLanguages = (i18n.options.supportedLngs as string[] || []) - .filter(lang => lang !== 'cimode'); // Exclude i18next debug language + const allowedLanguages = ((i18n.options.supportedLngs as string[]) || []).filter((lang) => lang !== "cimode"); // Exclude i18next debug language const languageOptions: LanguageOption[] = Object.entries(supportedLanguages) .filter(([code]) => allowedLanguages.length === 0 || allowedLanguages.includes(code)) @@ -196,13 +197,9 @@ const LanguageSelector: React.FC = ({ // Calculate dropdown width and grid columns based on number of languages // 2-4: 300px/2 cols, 5-9: 400px/3 cols, 10+: 600px/4 cols - const dropdownWidth = languageOptions.length <= 4 ? 300 - : languageOptions.length <= 9 ? 400 - : 600; + const dropdownWidth = languageOptions.length <= 4 ? 300 : languageOptions.length <= 9 ? 400 : 600; - const gridColumns = languageOptions.length <= 4 ? 2 - : languageOptions.length <= 9 ? 3 - : 4; + const gridColumns = languageOptions.length <= 4 ? 2 : languageOptions.length <= 9 ? 3 : 4; const handleLanguageChange = (value: string, event: React.MouseEvent) => { // Create ripple effect at click position (only for button mode) @@ -229,16 +226,15 @@ const LanguageSelector: React.FC = ({ setTimeout(() => setRippleEffect(null), 50); // Force a full reload so RTL/LTR layout and tooltips re-evaluate correctly - if (typeof window !== 'undefined') { + if (typeof window !== "undefined") { window.location.reload(); } }, 150); }, 100); }; - const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] || - supportedLanguages['en-GB'] || - 'English'; // Fallback if supportedLanguages lookup fails + const currentLanguage = + supportedLanguages[i18n.language as keyof typeof supportedLanguages] || supportedLanguages["en-GB"] || "English"; // Fallback if supportedLanguages lookup fails // Hide the language selector if there's only one language option // (no point showing a selector when there's nothing to select) @@ -258,9 +254,9 @@ const LanguageSelector: React.FC = ({ zIndex={Z_INDEX_CONFIG_MODAL} withinPortal transitionProps={{ - transition: 'scale-y', + transition: "scale-y", duration: 120, - timingFunction: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)' + timingFunction: "cubic-bezier(0.25, 0.46, 0.45, 0.94)", }} > @@ -272,11 +268,11 @@ const LanguageSelector: React.FC = ({ title={!opened && tooltip ? tooltip : undefined} styles={{ root: { - color: 'var(--right-rail-icon)', - '&:hover': { - backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))', - } - } + color: "var(--right-rail-icon)", + "&:hover": { + backgroundColor: "light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))", + }, + }, }} > @@ -288,50 +284,45 @@ const LanguageSelector: React.FC = ({ leftSection={} styles={{ root: { - border: 'none', - color: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))', - transition: 'background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)', - '&:hover': { - backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))', - } + border: "none", + color: "light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))", + transition: "background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)", + "&:hover": { + backgroundColor: "light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))", + }, }, - label: { fontSize: '12px', fontWeight: 500 } + label: { fontSize: "12px", fontWeight: 500 }, }} > - - {currentLanguage} - + {currentLanguage} )} -
- {languageOptions.map((option, index) => ( - handleLanguageChange(option.value, event)} - rippleEffect={rippleEffect} - pendingLanguage={pendingLanguage} - compact={compact} - disabled={false} - /> - ))} +
+ {languageOptions.map((option, index) => ( + handleLanguageChange(option.value, event)} + rippleEffect={rippleEffect} + pendingLanguage={pendingLanguage} + compact={compact} + disabled={false} + /> + ))}
diff --git a/frontend/src/core/components/shared/LocalIcon.tsx b/frontend/src/core/components/shared/LocalIcon.tsx index ff7ca493af..5b12dc1fd9 100644 --- a/frontend/src/core/components/shared/LocalIcon.tsx +++ b/frontend/src/core/components/shared/LocalIcon.tsx @@ -1,6 +1,6 @@ -import React from 'react'; -import { addCollection, Icon } from '@iconify/react'; -import iconSet from '../../../assets/material-symbols-icons.json'; // eslint-disable-line no-restricted-imports -- Outside app paths +import React from "react"; +import { addCollection, Icon } from "@iconify/react"; +import iconSet from "../../../assets/material-symbols-icons.json"; // eslint-disable-line no-restricted-imports -- Outside app paths // Load icons synchronously at import time - guaranteed to be ready on first render let iconsLoaded = false; @@ -13,7 +13,7 @@ try { console.info(`✅ Local icons loaded: ${localIconCount} icons (${Math.round(JSON.stringify(iconSet).length / 1024)}KB)`); } } catch { - console.info('ℹ️ Local icons not available - using CDN fallback'); + console.info("ℹ️ Local icons not available - using CDN fallback"); } interface LocalIconProps { @@ -30,17 +30,15 @@ interface LocalIconProps { */ export const LocalIcon: React.FC = ({ icon, width, height, style, ...props }) => { // Convert our icon naming convention to the local collection format - const iconName = icon.startsWith('material-symbols:') - ? icon - : `material-symbols:${icon}`; + const iconName = icon.startsWith("material-symbols:") ? icon : `material-symbols:${icon}`; // Development logging (only in dev mode) - if (process.env.NODE_ENV === 'development') { + if (process.env.NODE_ENV === "development") { const logKey = `icon-${iconName}`; if (!sessionStorage.getItem(logKey)) { - const source = iconsLoaded ? 'local' : 'CDN'; + const source = iconsLoaded ? "local" : "CDN"; console.debug(`🎯 Icon: ${iconName} (${source})`); - sessionStorage.setItem(logKey, 'logged'); + sessionStorage.setItem(logKey, "logged"); } } @@ -48,10 +46,10 @@ export const LocalIcon: React.FC = ({ icon, width, height, style // Use width if provided, otherwise fall back to height const size = width || height; - if (size && typeof size === 'string') { + if (size && typeof size === "string") { // If it's a CSS unit string (like '1.5rem'), use it as fontSize iconStyle.fontSize = size; - } else if (typeof size === 'number') { + } else if (typeof size === "number") { // If it's a number, treat it as pixels iconStyle.fontSize = `${size}px`; } diff --git a/frontend/src/core/components/shared/MobileUploadModal.tsx b/frontend/src/core/components/shared/MobileUploadModal.tsx index 275b2cb0d9..4358003cac 100644 --- a/frontend/src/core/components/shared/MobileUploadModal.tsx +++ b/frontend/src/core/components/shared/MobileUploadModal.tsx @@ -1,16 +1,16 @@ -import { useEffect, useCallback, useState, useRef } from 'react'; -import { Modal, Stack, Text, Badge, Box, Alert } from '@mantine/core'; -import { QRCodeSVG } from 'qrcode.react'; -import { useTranslation } from 'react-i18next'; -import { useAppConfig } from '@app/contexts/AppConfigContext'; -import InfoRoundedIcon from '@mui/icons-material/InfoRounded'; -import ErrorRoundedIcon from '@mui/icons-material/ErrorRounded'; -import CheckRoundedIcon from '@mui/icons-material/CheckRounded'; -import WarningRoundedIcon from '@mui/icons-material/WarningRounded'; -import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex'; -import { withBasePath } from '@app/constants/app'; -import { convertImageToPdf, isImageFile } from '@app/utils/imageToPdfUtils'; -import apiClient from '@app/services/apiClient'; +import { useEffect, useCallback, useState, useRef } from "react"; +import { Modal, Stack, Text, Badge, Box, Alert } from "@mantine/core"; +import { QRCodeSVG } from "qrcode.react"; +import { useTranslation } from "react-i18next"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import InfoRoundedIcon from "@mui/icons-material/InfoRounded"; +import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded"; +import CheckRoundedIcon from "@mui/icons-material/CheckRounded"; +import WarningRoundedIcon from "@mui/icons-material/WarningRounded"; +import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex"; +import { withBasePath } from "@app/constants/app"; +import { convertImageToPdf, isImageFile } from "@app/utils/imageToPdfUtils"; +import apiClient from "@app/services/apiClient"; interface MobileUploadModalProps { opened: boolean; @@ -21,9 +21,9 @@ interface MobileUploadModalProps { // Generate a cryptographically secure UUID v4-like session ID function generateSessionId(): string { // Use Web Crypto API for cryptographically secure random values - const cryptoObj = typeof crypto !== 'undefined' ? crypto : (window as any).crypto; + const cryptoObj = typeof crypto !== "undefined" ? crypto : (window as any).crypto; - if (cryptoObj && typeof cryptoObj.getRandomValues === 'function') { + if (cryptoObj && typeof cryptoObj.getRandomValues === "function") { const bytes = new Uint8Array(16); cryptoObj.getRandomValues(bytes); @@ -32,19 +32,19 @@ function generateSessionId(): string { bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10 // Convert bytes to hex string in UUID format - const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')); + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")); return [ - hex.slice(0, 4).join(''), - hex.slice(4, 6).join(''), - hex.slice(6, 8).join(''), - hex.slice(8, 10).join(''), - hex.slice(10, 16).join(''), - ].join('-'); + hex.slice(0, 4).join(""), + hex.slice(4, 6).join(""), + hex.slice(6, 8).join(""), + hex.slice(8, 10).join(""), + hex.slice(10, 16).join(""), + ].join("-"); } // If Web Crypto is not available, fail fast rather than using insecure randomness - console.error('Web Crypto API not available. Cannot generate secure session ID.'); - throw new Error('Web Crypto API not available. Cannot generate secure session ID.'); + console.error("Web Crypto API not available. Cannot generate secure session ID."); + throw new Error("Web Crypto API not available. Cannot generate secure session ID."); } interface SessionInfo { @@ -76,30 +76,37 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }: // Use configured frontendUrl if set, otherwise use current origin // Combine with base path and mobile-scanner route - const baseUrl = localStorage.getItem('server_url') || ''; + const baseUrl = localStorage.getItem("server_url") || ""; const frontendUrl = baseUrl || config?.frontendUrl || window.location.origin; - const mobileUrl = `${frontendUrl}${withBasePath('/mobile-scanner')}?session=${sessionId}`; + const mobileUrl = `${frontendUrl}${withBasePath("/mobile-scanner")}?session=${sessionId}`; // Create session on backend - const createSession = useCallback(async (newSessionId: string) => { - try { - const response = await apiClient.post(`/api/v1/mobile-scanner/create-session/${newSessionId}`, undefined, { - responseType: 'json', - }); + const createSession = useCallback( + async (newSessionId: string) => { + try { + const response = await apiClient.post( + `/api/v1/mobile-scanner/create-session/${newSessionId}`, + undefined, + { + responseType: "json", + }, + ); - if (!response.status || response.status !== 200) { - throw new Error('Failed to create session'); + if (!response.status || response.status !== 200) { + throw new Error("Failed to create session"); + } + + const data = response.data; + setSessionInfo(data); + setError(null); + console.log("[MobileUploadModal] Session created:", data); + } catch (err) { + console.error("[MobileUploadModal] Failed to create session:", err); + setError(t("mobileUpload.sessionCreateError", "Failed to create session")); } - - const data = response.data; - setSessionInfo(data); - setError(null); - console.log('[MobileUploadModal] Session created:', data); - } catch (err) { - console.error('[MobileUploadModal] Failed to create session:', err); - setError(t('mobileUpload.sessionCreateError', 'Failed to create session')); - } - }, [t]); + }, + [t], + ); // Regenerate session (when expired or warned) const regenerateSession = useCallback(() => { @@ -117,7 +124,7 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }: try { const response = await apiClient.get(`/api/v1/mobile-scanner/files/${sessionId}`); if (!response.status || response.status !== 200) { - throw new Error('Failed to check for files'); + throw new Error("Failed to check for files"); } const data = response.data; @@ -130,28 +137,29 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }: for (const fileMetadata of newFiles) { try { const downloadResponse = await apiClient.get( - `/api/v1/mobile-scanner/download/${sessionId}/${fileMetadata.filename}`, { - responseType: 'blob', - } + `/api/v1/mobile-scanner/download/${sessionId}/${fileMetadata.filename}`, + { + responseType: "blob", + }, ); if (downloadResponse.status === 200) { const blob = downloadResponse.data; let file = new File([blob], fileMetadata.filename, { - type: fileMetadata.contentType || 'image/jpeg' + type: fileMetadata.contentType || "image/jpeg", }); // Convert images to PDF if enabled if (isImageFile(file) && config?.mobileScannerConvertToPdf !== false) { try { file = await convertImageToPdf(file, { - imageResolution: config?.mobileScannerImageResolution as 'full' | 'reduced' | undefined, - pageFormat: config?.mobileScannerPageFormat as 'keep' | 'A4' | 'letter' | undefined, + imageResolution: config?.mobileScannerImageResolution as "full" | "reduced" | undefined, + pageFormat: config?.mobileScannerPageFormat as "keep" | "A4" | "letter" | undefined, stretchToFit: config?.mobileScannerStretchToFit, }); - console.log('[MobileUploadModal] Converted image to PDF:', file.name); + console.log("[MobileUploadModal] Converted image to PDF:", file.name); } catch (convertError) { - console.warn('[MobileUploadModal] Failed to convert image to PDF, using original file:', convertError); + console.warn("[MobileUploadModal] Failed to convert image to PDF, using original file:", convertError); // Continue with original image file if conversion fails } } @@ -161,7 +169,7 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }: onFilesReceived([file]); } } catch (err) { - console.error('[MobileUploadModal] Failed to download file:', fileMetadata.filename, err); + console.error("[MobileUploadModal] Failed to download file:", fileMetadata.filename, err); } } @@ -169,14 +177,14 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }: // This ensures files are only on server for ~1 second try { await apiClient.delete(`/api/v1/mobile-scanner/session/${sessionId}`); - console.log('[MobileUploadModal] Session cleaned up after file download'); + console.log("[MobileUploadModal] Session cleaned up after file download"); } catch (cleanupErr) { - console.warn('[MobileUploadModal] Failed to cleanup session after download:', cleanupErr); + console.warn("[MobileUploadModal] Failed to cleanup session after download:", cleanupErr); } } } catch (err) { - console.error('[MobileUploadModal] Error polling for files:', err); - setError(t('mobileUpload.pollingError', 'Error checking for files')); + console.error("[MobileUploadModal] Error polling for files:", err); + setError(t("mobileUpload.pollingError", "Error checking for files")); } }, [opened, sessionId, onFilesReceived, t]); @@ -201,9 +209,10 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }: processedFiles.current.clear(); return () => { - console.log('Cleaning up session on unmount/close:', sessionId); - apiClient.delete(`/api/v1/mobile-scanner/session/${sessionId}`) - .catch(err => console.warn('[MobileUploadModal] Cleanup failed:', err)); + console.log("Cleaning up session on unmount/close:", sessionId); + apiClient + .delete(`/api/v1/mobile-scanner/session/${sessionId}`) + .catch((err) => console.warn("[MobileUploadModal] Cleanup failed:", err)); }; }, [opened, sessionId, createSession]); @@ -267,7 +276,7 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }: - } - color="blue" - variant="light" - > + } color="blue" variant="light"> {config?.mobileScannerConvertToPdf !== false ? t( - 'mobileUpload.description', - 'Scan this QR code with your mobile device to upload photos. Images will be automatically converted to PDF.' + "mobileUpload.description", + "Scan this QR code with your mobile device to upload photos. Images will be automatically converted to PDF.", ) - : t( - 'mobileUpload.descriptionNoConvert', - 'Scan this QR code with your mobile device to upload photos.' - )} + : t("mobileUpload.descriptionNoConvert", "Scan this QR code with your mobile device to upload photos.")} {showExpiryWarning && timeRemaining !== null && ( } - title={t('mobileUpload.expiryWarning', 'Session Expiring Soon')} + icon={} + title={t("mobileUpload.expiryWarning", "Session Expiring Soon")} color="orange" > {t( - 'mobileUpload.expiryWarningMessage', - 'This QR code will expire in {{seconds}} seconds. A new code will be generated automatically.', - { seconds: Math.ceil(timeRemaining / 1000) } + "mobileUpload.expiryWarningMessage", + "This QR code will expire in {{seconds}} seconds. A new code will be generated automatically.", + { seconds: Math.ceil(timeRemaining / 1000) }, )} @@ -316,41 +318,41 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }: {error && ( } - title={t('mobileUpload.error', 'Connection Error')} + icon={} + title={t("mobileUpload.error", "Connection Error")} color="red" > {error} )} - + {filesReceived > 0 && ( - }> - {t('mobileUpload.filesReceived', '{{count}} file(s) received', { count: filesReceived })} + }> + {t("mobileUpload.filesReceived", "{{count}} file(s) received", { count: filesReceived })} )} - + {config?.mobileScannerConvertToPdf !== false ? t( - 'mobileUpload.instructions', - 'Open the camera app on your phone and scan this code. Images will be automatically converted to PDF.' + "mobileUpload.instructions", + "Open the camera app on your phone and scan this code. Images will be automatically converted to PDF.", ) : t( - 'mobileUpload.instructionsNoConvert', - 'Open the camera app on your phone and scan this code. Files will be uploaded through the server.' + "mobileUpload.instructionsNoConvert", + "Open the camera app on your phone and scan this code. Files will be uploaded through the server.", )} @@ -358,9 +360,9 @@ export default function MobileUploadModal({ opened, onClose, onFilesReceived }: size="xs" c="dimmed" style={{ - wordBreak: 'break-all', - textAlign: 'center', - fontFamily: 'monospace', + wordBreak: "break-all", + textAlign: "center", + fontFamily: "monospace", }} > {mobileUrl} diff --git a/frontend/src/core/components/shared/MultiSelectControls.tsx b/frontend/src/core/components/shared/MultiSelectControls.tsx index b6e0b24b9f..856c971130 100644 --- a/frontend/src/core/components/shared/MultiSelectControls.tsx +++ b/frontend/src/core/components/shared/MultiSelectControls.tsx @@ -16,65 +16,43 @@ const MultiSelectControls = ({ onOpenInFileEditor, onOpenInPageEditor, onAddToUpload, - onDeleteAll + onDeleteAll, }: MultiSelectControlsProps) => { const { t } = useTranslation(); if (selectedCount === 0) return null; return ( - + {selectedCount} {t("fileManager.filesSelected", "files selected")} - {onAddToUpload && ( - )} {onOpenInFileEditor && ( - )} {onOpenInPageEditor && ( - )} {onDeleteAll && ( - )} diff --git a/frontend/src/core/components/shared/NavigationWarningModal.tsx b/frontend/src/core/components/shared/NavigationWarningModal.tsx index 8e80b5d771..d35f2a470c 100644 --- a/frontend/src/core/components/shared/NavigationWarningModal.tsx +++ b/frontend/src/core/components/shared/NavigationWarningModal.tsx @@ -86,33 +86,55 @@ const NavigationWarningModal = () => { zIndex={Z_INDEX_TOAST} > - - - {t("unsavedChanges", "You have unsaved changes to your PDF.")} - - - {t("areYouSure", "Are you sure you want to leave?")} - + + + {t("unsavedChanges", "You have unsaved changes to your PDF.")} + + + {t("areYouSure", "Are you sure you want to leave?")} + {/* Desktop layout: 2 groups side by side */} - - {hasApply && ( - )} {hasExport && ( - )} @@ -121,19 +143,41 @@ const NavigationWarningModal = () => { {/* Mobile layout: centered stack of 4 buttons */} - - {hasApply && ( - )} {hasExport && ( - )} diff --git a/frontend/src/core/components/shared/ObscuredOverlay.tsx b/frontend/src/core/components/shared/ObscuredOverlay.tsx index 2329d624dc..592a79befe 100644 --- a/frontend/src/core/components/shared/ObscuredOverlay.tsx +++ b/frontend/src/core/components/shared/ObscuredOverlay.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import styles from '@app/components/shared/ObscuredOverlay/ObscuredOverlay.module.css'; +import React from "react"; +import styles from "@app/components/shared/ObscuredOverlay/ObscuredOverlay.module.css"; type ObscuredOverlayProps = { obscured: boolean; @@ -30,11 +30,7 @@ export default function ObscuredOverlay({ }} >
- {overlayMessage && ( -
- {overlayMessage} -
- )} + {overlayMessage &&
{overlayMessage}
} {buttonText && onButtonClick && (
); } - - diff --git a/frontend/src/core/components/shared/PageEditorFileDropdown.tsx b/frontend/src/core/components/shared/PageEditorFileDropdown.tsx index 11f9742c2d..2d850d7177 100644 --- a/frontend/src/core/components/shared/PageEditorFileDropdown.tsx +++ b/frontend/src/core/components/shared/PageEditorFileDropdown.tsx @@ -1,16 +1,16 @@ -import React from 'react'; -import { Menu, Loader, Group, Text, Checkbox } from '@mantine/core'; -import { LocalIcon } from '@app/components/shared/LocalIcon'; -import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; -import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; -import AddIcon from '@mui/icons-material/Add'; -import FitText from '@app/components/shared/FitText'; -import { getFileColorWithOpacity } from '@app/components/pageEditor/fileColors'; -import { useFilesModalContext } from '@app/contexts/FilesModalContext'; -import { PrivateContent } from '@app/components/shared/PrivateContent'; -import { useFileItemDragDrop } from '@app/components/shared/pageEditor/useFileItemDragDrop'; +import React from "react"; +import { Menu, Loader, Group, Text, Checkbox } from "@mantine/core"; +import { LocalIcon } from "@app/components/shared/LocalIcon"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; +import AddIcon from "@mui/icons-material/Add"; +import FitText from "@app/components/shared/FitText"; +import { getFileColorWithOpacity } from "@app/components/pageEditor/fileColors"; +import { useFilesModalContext } from "@app/contexts/FilesModalContext"; +import { PrivateContent } from "@app/components/shared/PrivateContent"; +import { useFileItemDragDrop } from "@app/components/shared/pageEditor/useFileItemDragDrop"; -import { FileId } from '@app/types/file'; +import { FileId } from "@app/types/file"; // Local interface for PageEditor file display interface PageEditorFile { @@ -28,50 +28,36 @@ interface FileMenuItemProps { onReorder: (fromIndex: number, toIndex: number) => void; } -const FileMenuItem: React.FC = ({ - file, - index, - colorIndex, - onToggleSelection, - onReorder, -}) => { - const { - itemRef, - isDragging, - isDragOver, - dropPosition, - movedRef, - onPointerDown, - onPointerMove, - onPointerUp, - } = useFileItemDragDrop({ - fileId: file.fileId, - index, - onReorder, - }); +const FileMenuItem: React.FC = ({ file, index, colorIndex, onToggleSelection, onReorder }) => { + const { itemRef, isDragging, isDragOver, dropPosition, movedRef, onPointerDown, onPointerMove, onPointerUp } = + useFileItemDragDrop({ + fileId: file.fileId, + index, + onReorder, + }); - const itemName = file?.name || 'Untitled'; + const itemName = file?.name || "Untitled"; const fileColorBorder = getFileColorWithOpacity(colorIndex, 1); const fileColorBorderHover = getFileColorWithOpacity(colorIndex, 1.0); return (
{/* Drop indicator line */} {isDragOver && (
@@ -87,34 +73,36 @@ const FileMenuItem: React.FC = ({ onToggleSelection(file.fileId); }} style={{ - padding: '0.75rem 0.75rem', - cursor: isDragging ? 'grabbing' : 'grab', - backgroundColor: file.isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent', + padding: "0.75rem 0.75rem", + cursor: isDragging ? "grabbing" : "grab", + backgroundColor: file.isSelected ? "rgba(0, 0, 0, 0.05)" : "transparent", borderLeft: `6px solid ${fileColorBorder}`, opacity: isDragging ? 0.5 : 1, - transition: 'opacity 0.2s ease-in-out, background-color 0.15s ease', - userSelect: 'none', + transition: "opacity 0.2s ease-in-out, background-color 0.15s ease", + userSelect: "none", }} onMouseEnter={(e) => { if (!isDragging) { - (e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(0, 0, 0, 0.05)'; + (e.currentTarget as HTMLDivElement).style.backgroundColor = "rgba(0, 0, 0, 0.05)"; (e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorderHover; } }} onMouseLeave={(e) => { if (!isDragging) { - (e.currentTarget as HTMLDivElement).style.backgroundColor = file.isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent'; + (e.currentTarget as HTMLDivElement).style.backgroundColor = file.isSelected + ? "rgba(0, 0, 0, 0.05)" + : "transparent"; (e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorder; } }} > - +
@@ -125,7 +113,7 @@ const FileMenuItem: React.FC = ({ onClick={(e) => e.stopPropagation()} size="sm" /> -
+
@@ -167,24 +155,29 @@ export const PageEditorFileDropdown: React.FC = ({ return ( -
+
{switchingTo === "pageEditor" ? ( ) : ( )} - {selectedCount}/{totalCount} files selected + + {selectedCount}/{totalCount} files selected +
- + {files.map((file, index) => { const colorIndex = fileColorMap.get(file.fileId as string) ?? 0; @@ -207,23 +200,23 @@ export const PageEditorFileDropdown: React.FC = ({ openFilesModal(); }} style={{ - padding: '0.75rem 0.75rem', - marginTop: '0.5rem', - cursor: 'pointer', - backgroundColor: 'transparent', - borderTop: '1px solid var(--border-subtle)', - transition: 'background-color 0.15s ease', + padding: "0.75rem 0.75rem", + marginTop: "0.5rem", + cursor: "pointer", + backgroundColor: "transparent", + borderTop: "1px solid var(--border-subtle)", + transition: "background-color 0.15s ease", }} onMouseEnter={(e) => { - (e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(59, 130, 246, 0.25)'; + (e.currentTarget as HTMLDivElement).style.backgroundColor = "rgba(59, 130, 246, 0.25)"; }} onMouseLeave={(e) => { - (e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent'; + (e.currentTarget as HTMLDivElement).style.backgroundColor = "transparent"; }} > - - - + + + Add File diff --git a/frontend/src/core/components/shared/PageSelectionSyntaxHint.tsx b/frontend/src/core/components/shared/PageSelectionSyntaxHint.tsx index bf7e642066..c44025bf37 100644 --- a/frontend/src/core/components/shared/PageSelectionSyntaxHint.tsx +++ b/frontend/src/core/components/shared/PageSelectionSyntaxHint.tsx @@ -1,25 +1,25 @@ -import { useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Text } from '@mantine/core'; -import classes from '@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css'; -import { parseSelectionWithDiagnostics } from '@app/utils/bulkselection/parseSelection'; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Text } from "@mantine/core"; +import classes from "@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css"; +import { parseSelectionWithDiagnostics } from "@app/utils/bulkselection/parseSelection"; interface PageSelectionSyntaxHintProps { input: string; /** Optional known page count; if not provided, a large max is used for syntax-only checks */ maxPages?: number; /** panel = full bulk panel style, compact = inline tool style */ - variant?: 'panel' | 'compact'; + variant?: "panel" | "compact"; } const FALLBACK_MAX_PAGES = 100000; // large upper bound for syntax validation without a document -const PageSelectionSyntaxHint = ({ input, maxPages, variant = 'panel' }: PageSelectionSyntaxHintProps) => { +const PageSelectionSyntaxHint = ({ input, maxPages, variant = "panel" }: PageSelectionSyntaxHintProps) => { const [syntaxError, setSyntaxError] = useState(null); const { t } = useTranslation(); useEffect(() => { - const text = (input || '').trim(); + const text = (input || "").trim(); if (!text) { setSyntaxError(null); return; @@ -27,21 +27,23 @@ const PageSelectionSyntaxHint = ({ input, maxPages, variant = 'panel' }: PageSel try { const { warning } = parseSelectionWithDiagnostics(text, maxPages && maxPages > 0 ? maxPages : FALLBACK_MAX_PAGES); - setSyntaxError(warning ? t('bulkSelection.syntaxError', 'There is a syntax issue. See Page Selection tips for help.') : null); + setSyntaxError( + warning ? t("bulkSelection.syntaxError", "There is a syntax issue. See Page Selection tips for help.") : null, + ); } catch { - setSyntaxError(t('bulkSelection.syntaxError', 'There is a syntax issue. See Page Selection tips for help.')); + setSyntaxError(t("bulkSelection.syntaxError", "There is a syntax issue. See Page Selection tips for help.")); } }, [input, maxPages]); if (!syntaxError) return null; return ( -
- {syntaxError} +
+ + {syntaxError} +
); }; export default PageSelectionSyntaxHint; - - diff --git a/frontend/src/core/components/shared/PrivateContent.tsx b/frontend/src/core/components/shared/PrivateContent.tsx index 3ed11bfc6d..a2b048e95c 100644 --- a/frontend/src/core/components/shared/PrivateContent.tsx +++ b/frontend/src/core/components/shared/PrivateContent.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React from "react"; interface PrivateContentProps extends React.HTMLAttributes { children: React.ReactNode; @@ -23,14 +23,9 @@ interface PrivateContentProps extends React.HTMLAttributes { * preview * */ -export const PrivateContent: React.FC = ({ - children, - className = '', - style, - ...props -}) => { - const combinedClassName = `ph-no-capture${className ? ` ${className}` : ''}`; - const combinedStyle = { display: 'contents' as const, ...style }; +export const PrivateContent: React.FC = ({ children, className = "", style, ...props }) => { + const combinedClassName = `ph-no-capture${className ? ` ${className}` : ""}`; + const combinedStyle = { display: "contents" as const, ...style }; return ( diff --git a/frontend/src/core/components/shared/QuickAccessBar.tsx b/frontend/src/core/components/shared/QuickAccessBar.tsx index 726ed19c43..db57bed92e 100644 --- a/frontend/src/core/components/shared/QuickAccessBar.tsx +++ b/frontend/src/core/components/shared/QuickAccessBar.tsx @@ -1,49 +1,52 @@ import React, { useState, useRef, forwardRef, useEffect, useMemo, useCallback } from "react"; -import { createPortal } from 'react-dom'; +import { createPortal } from "react-dom"; import { Stack, Divider, Menu, Indicator } from "@mantine/core"; -import { useTranslation } from 'react-i18next'; -import { useNavigate, useLocation } from 'react-router-dom'; -import LocalIcon from '@app/components/shared/LocalIcon'; -import SignPopout, { SIGN_REQUEST_WORKBENCH_TYPE, SESSION_DETAIL_WORKBENCH_TYPE } from '@app/components/shared/signing/SignPopout'; +import { useTranslation } from "react-i18next"; +import { useNavigate, useLocation } from "react-router-dom"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import SignPopout, { + SIGN_REQUEST_WORKBENCH_TYPE, + SESSION_DETAIL_WORKBENCH_TYPE, +} from "@app/components/shared/signing/SignPopout"; import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider"; -import { useFilesModalContext } from '@app/contexts/FilesModalContext'; -import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; -import { useFileSelection, useFileState } from '@app/contexts/file/fileHooks'; -import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext'; -import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation'; -import { handleUnlessSpecialClick } from '@app/utils/clickHandlers'; -import { ButtonConfig } from '@app/types/sidebar'; -import '@app/components/shared/quickAccessBar/QuickAccessBar.css'; -import { Tooltip } from '@app/components/shared/Tooltip'; -import AllToolsNavButton from '@app/components/shared/AllToolsNavButton'; +import { useFilesModalContext } from "@app/contexts/FilesModalContext"; +import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; +import { useFileSelection, useFileState } from "@app/contexts/file/fileHooks"; +import { useNavigationState, useNavigationActions } from "@app/contexts/NavigationContext"; +import { useSidebarNavigation } from "@app/hooks/useSidebarNavigation"; +import { handleUnlessSpecialClick } from "@app/utils/clickHandlers"; +import { ButtonConfig } from "@app/types/sidebar"; +import "@app/components/shared/quickAccessBar/QuickAccessBar.css"; +import { Tooltip } from "@app/components/shared/Tooltip"; +import AllToolsNavButton from "@app/components/shared/AllToolsNavButton"; import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolButton"; -import AppConfigModal from '@app/components/shared/AppConfigModal'; -import { useAppConfig } from '@app/contexts/AppConfigContext'; -import { useGroupSigningEnabled } from '@app/hooks/useGroupSigningEnabled'; -import { useSharingEnabled } from '@app/hooks/useSharingEnabled'; +import AppConfigModal from "@app/components/shared/AppConfigModal"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useGroupSigningEnabled } from "@app/hooks/useGroupSigningEnabled"; +import { useSharingEnabled } from "@app/hooks/useSharingEnabled"; import { useLicenseAlert } from "@app/hooks/useLicenseAlert"; -import { requestStartTour } from '@app/constants/events'; -import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccessButton'; -import { useToursTooltip } from '@app/components/shared/quickAccessBar/useToursTooltip'; -import ShareManagementModal from '@app/components/shared/ShareManagementModal'; -import apiClient from '@app/services/apiClient'; -import { absoluteWithBasePath } from '@app/constants/app'; -import { alert } from '@app/components/toast'; -import { uploadHistoryChain } from '@app/services/serverStorageUpload'; -import { fileStorage } from '@app/services/fileStorage'; -import { useFileActions } from '@app/contexts/FileContext'; -import type { FileId } from '@app/types/file'; -import type { StirlingFileStub } from '@app/types/fileContext'; -import type { SignRequestSummary } from '@app/types/signingSession'; +import { requestStartTour } from "@app/constants/events"; +import QuickAccessButton from "@app/components/shared/quickAccessBar/QuickAccessButton"; +import { useToursTooltip } from "@app/components/shared/quickAccessBar/useToursTooltip"; +import ShareManagementModal from "@app/components/shared/ShareManagementModal"; +import apiClient from "@app/services/apiClient"; +import { absoluteWithBasePath } from "@app/constants/app"; +import { alert } from "@app/components/toast"; +import { uploadHistoryChain } from "@app/services/serverStorageUpload"; +import { fileStorage } from "@app/services/fileStorage"; +import { useFileActions } from "@app/contexts/FileContext"; +import type { FileId } from "@app/types/file"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { SignRequestSummary } from "@app/types/signingSession"; import { isNavButtonActive, getNavButtonStyle, getActiveNavButton, -} from '@app/components/shared/quickAccessBar/QuickAccessBar'; -import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex'; -import { QuickAccessBarFooterExtensions } from '@app/components/quickAccessBar/QuickAccessBarFooterExtensions'; -import { useConfigButtonIcon } from '@app/hooks/useConfigButtonIcon'; +} from "@app/components/shared/quickAccessBar/QuickAccessBar"; +import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; +import { QuickAccessBarFooterExtensions } from "@app/components/quickAccessBar/QuickAccessBarFooterExtensions"; +import { useConfigButtonIcon } from "@app/hooks/useConfigButtonIcon"; const QuickAccessBar = forwardRef((_, ref) => { const { t } = useTranslation(); @@ -59,7 +62,7 @@ const QuickAccessBar = forwardRef((_, ref) => { toolRegistry, readerMode, resetTool, - toolAvailability + toolAvailability, } = useToolWorkflow(); const { selectedFiles, selectedFileIds } = useFileSelection(); const { state, selectors } = useFileState(); @@ -70,7 +73,7 @@ const QuickAccessBar = forwardRef((_, ref) => { const { config } = useAppConfig(); const licenseAlert = useLicenseAlert(); const [configModalOpen, setConfigModalOpen] = useState(false); - const [activeButton, setActiveButton] = useState('tools'); + const [activeButton, setActiveButton] = useState("tools"); const [accessMenuOpen, setAccessMenuOpen] = useState(false); const [accessInviteOpen, setAccessInviteOpen] = useState(false); const [selectedAccessFileId, setSelectedAccessFileId] = useState(null); @@ -82,11 +85,10 @@ const QuickAccessBar = forwardRef((_, ref) => { const { sharingEnabled, shareLinksEnabled } = useSharingEnabled(); const groupSigningEnabled = useGroupSigningEnabled(); const isSignWorkbenchActive = - currentWorkbench === SIGN_REQUEST_WORKBENCH_TYPE || - currentWorkbench === SESSION_DETAIL_WORKBENCH_TYPE; - const [inviteRows, setInviteRows] = useState>([ - { id: Date.now(), email: '', role: 'editor' }, - ]); + currentWorkbench === SIGN_REQUEST_WORKBENCH_TYPE || currentWorkbench === SESSION_DETAIL_WORKBENCH_TYPE; + const [inviteRows, setInviteRows] = useState< + Array<{ id: number; email: string; role: "editor" | "commenter" | "viewer"; error?: string }> + >([{ id: Date.now(), email: "", role: "editor" }]); const [isInviting, setIsInviting] = useState(false); // Sign button state @@ -99,12 +101,12 @@ const QuickAccessBar = forwardRef((_, ref) => { if (!groupSigningEnabled) return; const fetchCount = async () => { try { - const response = await apiClient.get('/api/v1/security/cert-sign/sign-requests'); - const pending = response.data.filter( - r => r.myStatus !== 'SIGNED' && r.myStatus !== 'DECLINED' - ).length; + const response = await apiClient.get("/api/v1/security/cert-sign/sign-requests"); + const pending = response.data.filter((r) => r.myStatus !== "SIGNED" && r.myStatus !== "DECLINED").length; setPendingSignCount(pending); - } catch { /* silent — avoid noisy background error toasts */ } + } catch { + /* silent — avoid noisy background error toasts */ + } }; fetchCount(); const interval = setInterval(fetchCount, 60000); @@ -116,12 +118,12 @@ const QuickAccessBar = forwardRef((_, ref) => { if (!signMenuOpen && groupSigningEnabled) { const timeout = setTimeout(async () => { try { - const response = await apiClient.get('/api/v1/security/cert-sign/sign-requests'); - const pending = response.data.filter( - r => r.myStatus !== 'SIGNED' && r.myStatus !== 'DECLINED' - ).length; + const response = await apiClient.get("/api/v1/security/cert-sign/sign-requests"); + const pending = response.data.filter((r) => r.myStatus !== "SIGNED" && r.myStatus !== "DECLINED").length; setPendingSignCount(pending); - } catch { /* silent */ } + } catch { + /* silent */ + } }, 500); return () => clearTimeout(timeout); } @@ -129,23 +131,16 @@ const QuickAccessBar = forwardRef((_, ref) => { const configButtonIcon = useConfigButtonIcon(); - const { - tooltipOpen, - manualCloseOnly, - showCloseButton, - toursMenuOpen, - setToursMenuOpen, - handleTooltipOpenChange, - } = useToursTooltip(); + const { tooltipOpen, manualCloseOnly, showCloseButton, toursMenuOpen, setToursMenuOpen, handleTooltipOpenChange } = + useToursTooltip(); - const isRTL = typeof document !== 'undefined' && document.documentElement.dir === 'rtl'; + const isRTL = typeof document !== "undefined" && document.documentElement.dir === "rtl"; const hasSelectedFiles = selectedFiles.length > 0; const selectedFileStubs = useMemo( () => selectedFileIds.map((id) => selectors.getStirlingFileStub(id)).filter((x): x is StirlingFileStub => Boolean(x)), - [selectedFileIds, selectors, state.files.byId] + [selectedFileIds, selectors, state.files.byId], ); - const selectedAccessFileStub = - selectedFileStubs.find((file) => file.id === selectedAccessFileId) || selectedFileStubs[0]; + const selectedAccessFileStub = selectedFileStubs.find((file) => file.id === selectedAccessFileId) || selectedFileStubs[0]; useEffect(() => { if (!hasSelectedFiles) { setAccessMenuOpen(false); @@ -159,7 +154,7 @@ const QuickAccessBar = forwardRef((_, ref) => { }, [hasSelectedFiles, selectedAccessFileId, selectedFiles]); const resetInviteRows = useCallback(() => { - setInviteRows([{ id: Date.now(), email: '', role: 'editor' }]); + setInviteRows([{ id: Date.now(), email: "", role: "editor" }]); }, []); useEffect(() => { @@ -176,11 +171,11 @@ const QuickAccessBar = forwardRef((_, ref) => { setAccessPopoverPosition({ top, left }); }; updatePosition(); - window.addEventListener('resize', updatePosition); - window.addEventListener('scroll', updatePosition, true); + window.addEventListener("resize", updatePosition); + window.addEventListener("scroll", updatePosition, true); return () => { - window.removeEventListener('resize', updatePosition); - window.removeEventListener('scroll', updatePosition, true); + window.removeEventListener("resize", updatePosition); + window.removeEventListener("scroll", updatePosition, true); }; }, [accessMenuOpen, isRTL, resetInviteRows]); @@ -192,77 +187,77 @@ const QuickAccessBar = forwardRef((_, ref) => { if (accessButtonRef.current?.contains(target)) return; // Check if click is inside a Mantine dropdown - const mantineDropdown = (target as Element).closest?.('.mantine-Combobox-dropdown, .mantine-Popover-dropdown'); + const mantineDropdown = (target as Element).closest?.(".mantine-Combobox-dropdown, .mantine-Popover-dropdown"); if (mantineDropdown) return; setAccessMenuOpen(false); }; const handleEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') { + if (event.key === "Escape") { setAccessMenuOpen(false); } }; - document.addEventListener('mousedown', handleOutside); - document.addEventListener('keydown', handleEscape); + document.addEventListener("mousedown", handleOutside); + document.addEventListener("keydown", handleEscape); return () => { - document.removeEventListener('mousedown', handleOutside); - document.removeEventListener('keydown', handleEscape); + document.removeEventListener("mousedown", handleOutside); + document.removeEventListener("keydown", handleEscape); }; }, [accessMenuOpen]); const shareBaseUrl = useMemo(() => { - const frontendUrl = (config?.frontendUrl || '').trim(); + const frontendUrl = (config?.frontendUrl || "").trim(); if (frontendUrl) { try { const parsed = new URL(frontendUrl); - if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { - const normalized = frontendUrl.endsWith('/') ? frontendUrl.slice(0, -1) : frontendUrl; + if (parsed.protocol === "http:" || parsed.protocol === "https:") { + const normalized = frontendUrl.endsWith("/") ? frontendUrl.slice(0, -1) : frontendUrl; return `${normalized}/share/`; } } catch { // invalid URL — fall through to default } } - return absoluteWithBasePath('/share/'); + return absoluteWithBasePath("/share/"); }, [config?.frontendUrl]); - const ensureStoredFile = useCallback(async (fileStub: StirlingFileStub): Promise => { - const localUpdatedAt = fileStub.createdAt ?? fileStub.lastModified ?? 0; - const isUpToDate = - Boolean(fileStub.remoteStorageId) && - Boolean(fileStub.remoteStorageUpdatedAt) && - (fileStub.remoteStorageUpdatedAt as number) >= localUpdatedAt; - if (isUpToDate && fileStub.remoteStorageId) { - return fileStub.remoteStorageId as number; - } - const originalFileId = (fileStub.originalFileId || fileStub.id) as FileId; - const remoteId = fileStub.remoteStorageId as number | undefined; - const { remoteId: storedId, updatedAt, chain } = await uploadHistoryChain( - originalFileId, - remoteId - ); - for (const stub of chain) { - actions.updateStirlingFileStub(stub.id, { - remoteStorageId: storedId, - remoteStorageUpdatedAt: updatedAt, - remoteOwnedByCurrentUser: true, - remoteSharedViaLink: false, - }); - await fileStorage.updateFileMetadata(stub.id, { - remoteStorageId: storedId, - remoteStorageUpdatedAt: updatedAt, - remoteOwnedByCurrentUser: true, - remoteSharedViaLink: false, - }); - } - return storedId; - }, [actions]); + const ensureStoredFile = useCallback( + async (fileStub: StirlingFileStub): Promise => { + const localUpdatedAt = fileStub.createdAt ?? fileStub.lastModified ?? 0; + const isUpToDate = + Boolean(fileStub.remoteStorageId) && + Boolean(fileStub.remoteStorageUpdatedAt) && + (fileStub.remoteStorageUpdatedAt as number) >= localUpdatedAt; + if (isUpToDate && fileStub.remoteStorageId) { + return fileStub.remoteStorageId as number; + } + const originalFileId = (fileStub.originalFileId || fileStub.id) as FileId; + const remoteId = fileStub.remoteStorageId as number | undefined; + const { remoteId: storedId, updatedAt, chain } = await uploadHistoryChain(originalFileId, remoteId); + for (const stub of chain) { + actions.updateStirlingFileStub(stub.id, { + remoteStorageId: storedId, + remoteStorageUpdatedAt: updatedAt, + remoteOwnedByCurrentUser: true, + remoteSharedViaLink: false, + }); + await fileStorage.updateFileMetadata(stub.id, { + remoteStorageId: storedId, + remoteStorageUpdatedAt: updatedAt, + remoteOwnedByCurrentUser: true, + remoteSharedViaLink: false, + }); + } + return storedId; + }, + [actions], + ); const openShareManage = useCallback(async () => { if (!sharingEnabled) { alert({ - alertType: 'warning', - title: t('storageShare.sharingDisabled', 'Sharing is disabled.'), + alertType: "warning", + title: t("storageShare.sharingDisabled", "Sharing is disabled."), expandable: false, durationMs: 2500, }); @@ -270,8 +265,8 @@ const QuickAccessBar = forwardRef((_, ref) => { } if (selectedFileStubs.length > 1) { alert({ - alertType: 'warning', - title: t('storageShare.selectSingleFile', 'Select a single file to manage sharing.'), + alertType: "warning", + title: t("storageShare.selectSingleFile", "Select a single file to manage sharing."), expandable: false, durationMs: 2500, }); @@ -279,8 +274,8 @@ const QuickAccessBar = forwardRef((_, ref) => { } if (selectedAccessFileStub?.remoteOwnedByCurrentUser === false) { alert({ - alertType: 'warning', - title: t('storageShare.ownerOnly', 'Only the owner can manage sharing.'), + alertType: "warning", + title: t("storageShare.ownerOnly", "Only the owner can manage sharing."), expandable: false, durationMs: 2500, }); @@ -293,10 +288,10 @@ const QuickAccessBar = forwardRef((_, ref) => { setAccessMenuOpen(false); setShareManageOpen(true); } catch (error) { - console.error('Failed to upload file for sharing:', error); + console.error("Failed to upload file for sharing:", error); alert({ - alertType: 'warning', - title: t('storageUpload.failure', 'Upload failed. Please check your login and storage settings.'), + alertType: "warning", + title: t("storageUpload.failure", "Upload failed. Please check your login and storage settings."), expandable: false, durationMs: 3000, }); @@ -304,22 +299,20 @@ const QuickAccessBar = forwardRef((_, ref) => { }, [ensureStoredFile, selectedAccessFileStub, selectedFileStubs.length, sharingEnabled, t]); const handleInviteRowChange = useCallback( - (id: number, updates: Partial<{ email: string; role: 'editor' | 'commenter' | 'viewer'; error?: string }>) => { + (id: number, updates: Partial<{ email: string; role: "editor" | "commenter" | "viewer"; error?: string }>) => { setInviteRows((prev) => prev.map((row) => { if (row.id !== id) return row; - const nextError = Object.prototype.hasOwnProperty.call(updates, 'error') - ? updates.error - : row.error; + const nextError = Object.prototype.hasOwnProperty.call(updates, "error") ? updates.error : row.error; return { ...row, ...updates, error: nextError }; - }) + }), ); }, - [] + [], ); const handleAddInviteRow = useCallback(() => { - setInviteRows((prev) => [...prev, { id: Date.now(), email: '', role: 'editor' }]); + setInviteRows((prev) => [...prev, { id: Date.now(), email: "", role: "editor" }]); }, []); const handleRemoveInviteRow = useCallback((id: number) => { @@ -330,8 +323,8 @@ const QuickAccessBar = forwardRef((_, ref) => { if (!selectedAccessFileStub) return; if (selectedAccessFileStub.remoteOwnedByCurrentUser === false) { alert({ - alertType: 'warning', - title: t('storageShare.ownerOnly', 'Only the owner can manage sharing.'), + alertType: "warning", + title: t("storageShare.ownerOnly", "Only the owner can manage sharing."), expandable: false, durationMs: 2500, }); @@ -341,7 +334,7 @@ const QuickAccessBar = forwardRef((_, ref) => { const trimmed = row.email.trim(); let error: string | undefined; if (!trimmed) { - error = t('storageShare.invalidUsername', 'Enter a valid username or email address.'); + error = t("storageShare.invalidUsername", "Enter a valid username or email address."); } return { ...row, email: trimmed, error }; }); @@ -359,18 +352,18 @@ const QuickAccessBar = forwardRef((_, ref) => { }); } alert({ - alertType: 'success', - title: t('storageShare.userAdded', 'User added to shared list.'), + alertType: "success", + title: t("storageShare.userAdded", "User added to shared list."), expandable: false, durationMs: 2500, }); setAccessInviteOpen(false); resetInviteRows(); } catch (error) { - console.error('Failed to send invite:', error); + console.error("Failed to send invite:", error); alert({ - alertType: 'warning', - title: t('storageShare.userAddFailed', 'Unable to share with that user.'), + alertType: "warning", + title: t("storageShare.userAddFailed", "Unable to share with that user."), expandable: false, durationMs: 3000, }); @@ -383,8 +376,8 @@ const QuickAccessBar = forwardRef((_, ref) => { if (!selectedAccessFileStub) return; if (!shareLinksEnabled) { alert({ - alertType: 'warning', - title: t('storageShare.linksDisabled', 'Share links are disabled.'), + alertType: "warning", + title: t("storageShare.linksDisabled", "Share links are disabled."), expandable: false, durationMs: 2500, }); @@ -392,8 +385,8 @@ const QuickAccessBar = forwardRef((_, ref) => { } if (selectedFileStubs.length > 1) { alert({ - alertType: 'warning', - title: t('storageShare.selectSingleFile', 'Select a single file to copy a link.'), + alertType: "warning", + title: t("storageShare.selectSingleFile", "Select a single file to copy a link."), expandable: false, durationMs: 2500, }); @@ -401,8 +394,8 @@ const QuickAccessBar = forwardRef((_, ref) => { } if (selectedAccessFileStub?.remoteOwnedByCurrentUser === false) { alert({ - alertType: 'warning', - title: t('storageShare.ownerOnly', 'Only the owner can manage sharing.'), + alertType: "warning", + title: t("storageShare.ownerOnly", "Only the owner can manage sharing."), expandable: false, durationMs: 2500, }); @@ -412,10 +405,10 @@ const QuickAccessBar = forwardRef((_, ref) => { try { await ensureStoredFile(selectedAccessFileStub); } catch (error) { - console.error('Failed to upload file for sharing:', error); + console.error("Failed to upload file for sharing:", error); alert({ - alertType: 'warning', - title: t('storageUpload.failure', 'Upload failed. Please check your login and storage settings.'), + alertType: "warning", + title: t("storageUpload.failure", "Upload failed. Please check your login and storage settings."), expandable: false, durationMs: 3000, }); @@ -424,15 +417,14 @@ const QuickAccessBar = forwardRef((_, ref) => { } try { const storedId = await ensureStoredFile(selectedAccessFileStub); - const response = await apiClient.get<{ shareLinks?: Array<{ token?: string }> }>( - `/api/v1/storage/files/${storedId}`, - { suppressErrorToast: true } - ); + const response = await apiClient.get<{ shareLinks?: Array<{ token?: string }> }>(`/api/v1/storage/files/${storedId}`, { + suppressErrorToast: true, + }); const links = response.data?.shareLinks ?? []; let token = links[links.length - 1]?.token; if (!token) { const shareResponse = await apiClient.post(`/api/v1/storage/files/${storedId}/shares/links`, { - accessRole: 'editor', + accessRole: "editor", }); token = shareResponse.data?.token; if (token) { @@ -442,8 +434,8 @@ const QuickAccessBar = forwardRef((_, ref) => { } if (!token) { alert({ - alertType: 'warning', - title: t('storageShare.failure', 'Unable to generate a share link. Please try again.'), + alertType: "warning", + title: t("storageShare.failure", "Unable to generate a share link. Please try again."), expandable: false, durationMs: 2500, }); @@ -451,26 +443,25 @@ const QuickAccessBar = forwardRef((_, ref) => { } await navigator.clipboard.writeText(`${shareBaseUrl}${token}`); alert({ - alertType: 'success', - title: t('storageShare.copied', 'Link copied to clipboard'), + alertType: "success", + title: t("storageShare.copied", "Link copied to clipboard"), expandable: false, durationMs: 2000, }); } catch (error) { - console.error('Failed to copy share link:', error); + console.error("Failed to copy share link:", error); alert({ - alertType: 'warning', - title: t('storageShare.copyFailed', 'Copy failed'), + alertType: "warning", + title: t("storageShare.copyFailed", "Copy failed"), expandable: false, durationMs: 2500, }); } }; - // Open modal if URL is at /settings/* useEffect(() => { - const isSettings = location.pathname.startsWith('/settings'); + const isSettings = location.pathname.startsWith("/settings"); setConfigModalOpen(isSettings); }, [location.pathname]); @@ -485,12 +476,13 @@ const QuickAccessBar = forwardRef((_, ref) => { // Helper function to render navigation buttons with URL support const renderNavButton = (config: ButtonConfig, index: number, shouldGuardNavigation = false) => { - const isActive = !isSignWorkbenchActive && isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView); + const isActive = + !isSignWorkbenchActive && + isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView); // Check if this button has URL navigation support - const navProps = config.type === 'navigation' && (config.id === 'read' || config.id === 'automate') - ? getToolNavigation(config.id) - : null; + const navProps = + config.type === "navigation" && (config.id === "read" || config.id === "automate") ? getToolNavigation(config.id) : null; const handleClick = (e?: React.MouseEvent) => { // If there are unsaved changes and this button should guard navigation, show warning modal @@ -509,15 +501,17 @@ const QuickAccessBar = forwardRef((_, ref) => { }; const buttonStyle = isSignWorkbenchActive - ? { backgroundColor: 'var(--icon-inactive-bg)', color: 'var(--icon-inactive-color)', border: 'none', borderRadius: '0.5rem' } + ? { + backgroundColor: "var(--icon-inactive-bg)", + color: "var(--icon-inactive-color)", + border: "none", + borderRadius: "0.5rem", + } : getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView); // Render navigation button with conditional URL support return ( -
+
((_, ref) => { ariaLabel={config.name} backgroundColor={buttonStyle.backgroundColor} color={buttonStyle.color} - component={navProps ? 'a' : 'button'} + component={navProps ? "a" : "button"} dataTestId={`${config.id}-button`} dataTour={`${config.id}-button`} /> @@ -535,54 +529,58 @@ const QuickAccessBar = forwardRef((_, ref) => { ); }; - const mainButtons: ButtonConfig[] = useMemo(() => [ - { - id: 'read', - name: t("quickAccess.reader", "Reader"), - icon: , - size: 'md' as const, - isRound: false, - type: 'navigation' as const, - onClick: () => { - setActiveButton('read'); - handleReaderToggle(); - } - }, - { - id: 'automate', - name: t("quickAccess.automate", "Automate"), - icon: , - size: 'md' as const, - isRound: false, - type: 'navigation' as const, - onClick: () => { - setActiveButton('automate'); - // If already on automate tool, reset it directly - if (selectedToolKey === 'automate') { - resetTool('automate'); - } else { - handleToolSelect('automate'); - } - } - }, - ].filter(button => { - // Filter out buttons for disabled tools - // 'read' is always available (viewer mode) - if (button.id === 'read') return true; - // Check if tool is actually available (not just present in registry) - const availability = toolAvailability[button.id as keyof typeof toolAvailability]; - return availability?.available !== false; - }), [t, setActiveButton, handleReaderToggle, selectedToolKey, resetTool, handleToolSelect, toolAvailability]); + const mainButtons: ButtonConfig[] = useMemo( + () => + [ + { + id: "read", + name: t("quickAccess.reader", "Reader"), + icon: , + size: "md" as const, + isRound: false, + type: "navigation" as const, + onClick: () => { + setActiveButton("read"); + handleReaderToggle(); + }, + }, + { + id: "automate", + name: t("quickAccess.automate", "Automate"), + icon: , + size: "md" as const, + isRound: false, + type: "navigation" as const, + onClick: () => { + setActiveButton("automate"); + // If already on automate tool, reset it directly + if (selectedToolKey === "automate") { + resetTool("automate"); + } else { + handleToolSelect("automate"); + } + }, + }, + ].filter((button) => { + // Filter out buttons for disabled tools + // 'read' is always available (viewer mode) + if (button.id === "read") return true; + // Check if tool is actually available (not just present in registry) + const availability = toolAvailability[button.id as keyof typeof toolAvailability]; + return availability?.available !== false; + }), + [t, setActiveButton, handleReaderToggle, selectedToolKey, resetTool, handleToolSelect, toolAvailability], + ); const middleButtons: ButtonConfig[] = [ { - id: 'files', + id: "files", name: t("quickAccess.files", "Files"), icon: , isRound: true, - size: 'md', - type: 'modal', - onClick: handleFilesButtonClick + size: "md", + type: "modal", + onClick: handleFilesButtonClick, }, ]; //TODO: Activity @@ -598,51 +596,50 @@ const QuickAccessBar = forwardRef((_, ref) => { // Determine if settings button should be hidden // Hide when login is disabled AND showSettingsWhenNoLogin is false - const shouldHideSettingsButton = - config?.enableLogin === false && - config?.showSettingsWhenNoLogin === false; + const shouldHideSettingsButton = config?.enableLogin === false && config?.showSettingsWhenNoLogin === false; const bottomButtons: ButtonConfig[] = [ { - id: 'help', + id: "help", name: t("quickAccess.tours", "Tours"), icon: , isRound: true, - size: 'md', - type: 'action', + size: "md", + type: "action", onClick: () => { // This will be overridden by the wrapper logic }, }, - ...(shouldHideSettingsButton ? [] : [{ - id: 'config', - name: t("quickAccess.settings", "Settings"), - icon: configButtonIcon ?? , - size: 'md' as const, - type: 'modal' as const, - onClick: () => { - navigate('/settings/overview'); - setConfigModalOpen(true); - } - } as ButtonConfig]) + ...(shouldHideSettingsButton + ? [] + : [ + { + id: "config", + name: t("quickAccess.settings", "Settings"), + icon: configButtonIcon ?? , + size: "md" as const, + type: "modal" as const, + onClick: () => { + navigate("/settings/overview"); + setConfigModalOpen(true); + }, + } as ButtonConfig, + ]), ]; - return (
{/* Fixed header outside scrollable area */}
-
- {/* Scrollable content area */}
((_, ref) => { {mainButtons.map((config, index) => ( - {renderNavButton(config, index, config.id === 'read' || config.id === 'automate')} + {renderNavButton(config, index, config.id === "read" || config.id === "automate")} ))} @@ -665,57 +662,45 @@ const QuickAccessBar = forwardRef((_, ref) => { {/* Middle section */} {middleButtons.length > 0 && ( <> - + {middleButtons.map((config, index) => ( - - {renderNavButton(config, index)} - + {renderNavButton(config, index)} ))} {hasSelectedFiles && sharingEnabled && (
} - label={t('quickAccess.access', 'Access')} + label={t("quickAccess.access", "Access")} isActive={!isSignWorkbenchActive && accessMenuOpen} onClick={() => { setAccessMenuOpen((prev) => !prev); }} - ariaLabel={t('quickAccess.access', 'Access')} + ariaLabel={t("quickAccess.access", "Access")} dataTestId="access-button" />
)} {groupSigningEnabled && ( -
+
{pendingSignCount > 0 ? ( - + } - label={t('quickAccess.sign', 'Sign')} + label={t("quickAccess.sign", "Sign")} isActive={signMenuOpen || isSignWorkbenchActive} onClick={() => setSignMenuOpen((prev) => !prev)} - ariaLabel={t('quickAccess.sign', 'Sign')} + ariaLabel={t("quickAccess.sign", "Sign")} dataTestId="sign-button" /> ) : ( } - label={t('quickAccess.sign', 'Sign')} + label={t("quickAccess.sign", "Sign")} isActive={signMenuOpen || isSignWorkbenchActive} onClick={() => setSignMenuOpen((prev) => !prev)} - ariaLabel={t('quickAccess.sign', 'Sign')} + ariaLabel={t("quickAccess.sign", "Sign")} dataTestId="sign-button" /> )} @@ -734,39 +719,46 @@ const QuickAccessBar = forwardRef((_, ref) => { {bottomButtons.map((buttonConfig, index) => { // Handle help button with menu or direct action - if (buttonConfig.id === 'help') { + if (buttonConfig.id === "help") { const isAdmin = config?.isAdmin === true; const toursTooltipContent = isAdmin - ? t('quickAccess.toursTooltip.admin', 'Watch walkthroughs here: Tools tour, New V2 layout tour, and the Admin tour.') - : t('quickAccess.toursTooltip.user', 'Watch walkthroughs here: Tools tour and the New V2 layout tour.'); + ? t( + "quickAccess.toursTooltip.admin", + "Watch walkthroughs here: Tools tour, New V2 layout tour, and the Admin tour.", + ) + : t("quickAccess.toursTooltip.user", "Watch walkthroughs here: Tools tour and the New V2 layout tour."); const tourItems = [ { - key: 'whatsnew', + key: "whatsnew", icon: , title: t("quickAccess.helpMenu.whatsNewTour", "See what's new in V2"), description: t("quickAccess.helpMenu.whatsNewTourDesc", "Tour the updated layout"), - onClick: () => requestStartTour('whatsnew'), + onClick: () => requestStartTour("whatsnew"), }, { - key: 'tools', + key: "tools", icon: , title: t("quickAccess.helpMenu.toolsTour", "Tools Tour"), description: t("quickAccess.helpMenu.toolsTourDesc", "Learn what the tools can do"), - onClick: () => requestStartTour('tools'), + onClick: () => requestStartTour("tools"), }, - ...(isAdmin ? [{ - key: 'admin', - icon: , - title: t("quickAccess.helpMenu.adminTour", "Admin Tour"), - description: t("quickAccess.helpMenu.adminTourDesc", "Explore admin settings & features"), - onClick: () => requestStartTour('admin'), - }] : []), + ...(isAdmin + ? [ + { + key: "admin", + icon: , + title: t("quickAccess.helpMenu.adminTour", "Admin Tour"), + description: t("quickAccess.helpMenu.adminTourDesc", "Explore admin settings & features"), + onClick: () => requestStartTour("admin"), + }, + ] + : []), ]; const helpButtonNode = (
((_, ref) => { {tourItems.map((item) => ( - +
-
- {item.title} -
-
- {item.description} -
+
{item.title}
+
{item.description}
))} @@ -819,20 +803,12 @@ const QuickAccessBar = forwardRef((_, ref) => { const buttonNode = renderNavButton(buttonConfig, index); const shouldShowSettingsBadge = - buttonConfig.id === 'config' && - licenseAlert.active && - licenseAlert.audience === 'admin'; + buttonConfig.id === "config" && licenseAlert.active && licenseAlert.audience === "admin"; return ( {shouldShowSettingsBadge ? ( - + {buttonNode} ) : ( @@ -845,10 +821,7 @@ const QuickAccessBar = forwardRef((_, ref) => {
- setConfigModalOpen(false)} - /> + setConfigModalOpen(false)} /> {selectedAccessFileStub && ( ((_, ref) => { file={selectedAccessFileStub} /> )} - {hasSelectedFiles && typeof document !== 'undefined' && createPortal( -
-
-
- -
- {accessInviteOpen - ? t('quickAccess.accessInviteTitle', 'Invite People') - : t('quickAccess.accessTitle', 'Document Access')} -
-
- {!accessInviteOpen && ( + {hasSelectedFiles && + typeof document !== "undefined" && + createPortal( +
+
+
+ +
+ {accessInviteOpen + ? t("quickAccess.accessInviteTitle", "Invite People") + : t("quickAccess.accessTitle", "Document Access")} +
+
+ {!accessInviteOpen && ( + + )} - )} - -
-
- -
-
-
-
- {t('quickAccess.accessFileLabel', 'File')} -
- -
- -
- -
-
- {t('quickAccess.accessGeneral', 'General Access')} -
-
-
- -
-
-
- {t('quickAccess.accessRestricted', 'Restricted')} -
-
- {t('quickAccess.accessRestrictedHint', 'Only people with access can open')} -
-
-
-
- -
- -
-
- {t('quickAccess.accessPeople', 'People with access')} -
-
-
- {(selectedAccessFileStub?.remoteOwnerUsername || 'You').slice(0, 2).toUpperCase()} -
-
-
- {selectedAccessFileStub?.remoteOwnerUsername || t('quickAccess.accessYou', 'You')} -
-
- {selectedAccessFileStub?.name ?? t('quickAccess.accessSelectedFile', 'Selected file')} -
-
- - {t('quickAccess.accessOwner', 'Owner')} - -
-
-
-
- {t('quickAccess.accessInviteTitle', 'Invite People')} +
+
+
+
{t("quickAccess.accessFileLabel", "File")}
+ +
+ +
+ +
+
{t("quickAccess.accessGeneral", "General Access")}
+
+
+ +
+
+
{t("quickAccess.accessRestricted", "Restricted")}
+
+ {t("quickAccess.accessRestrictedHint", "Only people with access can open")} +
+
+
+
+ +
+ +
+
{t("quickAccess.accessPeople", "People with access")}
+
+
+ {(selectedAccessFileStub?.remoteOwnerUsername || "You").slice(0, 2).toUpperCase()} +
+
+
+ {selectedAccessFileStub?.remoteOwnerUsername || t("quickAccess.accessYou", "You")} +
+
+ {selectedAccessFileStub?.name ?? t("quickAccess.accessSelectedFile", "Selected file")} +
+
+ {t("quickAccess.accessOwner", "Owner")} +
- {inviteRows.map((row) => ( -
-
- - - handleInviteRowChange(row.id, { email: event.target.value, error: undefined }) - } - /> - {row.error && ( -
{row.error}
- )} -
-
- - handleInviteRowChange(row.id, { email: event.target.value, error: undefined })} + /> + {row.error &&
{row.error}
} +
+
+ + +
+
- -
- ))} - + ))} + +
-
- -
- {accessInviteOpen ? ( - <> - - {shareLinksEnabled && ( - - )} - - ) : ( - <> - {sharingEnabled && ( +
+ {accessInviteOpen ? ( + <> - )} - {shareLinksEnabled && ( - - )} - - )} + {shareLinksEnabled && ( + + )} + + ) : ( + <> + {sharingEnabled && ( + + )} + {shareLinksEnabled && ( + + )} + + )} +
-
-
, - document.body - )} +
, + document.body, + )} {/* Sign Popover */} ((_, ref) => { ); }); -QuickAccessBar.displayName = 'QuickAccessBar'; +QuickAccessBar.displayName = "QuickAccessBar"; export default QuickAccessBar; diff --git a/frontend/src/core/components/shared/RainbowThemeProvider.tsx b/frontend/src/core/components/shared/RainbowThemeProvider.tsx index 992aa79b5c..a3fa5e04af 100644 --- a/frontend/src/core/components/shared/RainbowThemeProvider.tsx +++ b/frontend/src/core/components/shared/RainbowThemeProvider.tsx @@ -1,12 +1,12 @@ -import { createContext, useContext, ReactNode } from 'react'; -import { MantineProvider } from '@mantine/core'; -import { useRainbowTheme } from '@app/hooks/useRainbowTheme'; -import { mantineTheme } from '@app/theme/mantineTheme'; -import rainbowStyles from '@app/styles/rainbow.module.css'; -import { ToastProvider } from '@app/components/toast'; -import ToastRenderer from '@app/components/toast/ToastRenderer'; -import { ToastPortalBinder } from '@app/components/toast'; -import type { ThemeMode } from '@app/constants/theme'; +import { createContext, useContext, ReactNode } from "react"; +import { MantineProvider } from "@mantine/core"; +import { useRainbowTheme } from "@app/hooks/useRainbowTheme"; +import { mantineTheme } from "@app/theme/mantineTheme"; +import rainbowStyles from "@app/styles/rainbow.module.css"; +import { ToastProvider } from "@app/components/toast"; +import ToastRenderer from "@app/components/toast/ToastRenderer"; +import { ToastPortalBinder } from "@app/components/toast"; +import type { ThemeMode } from "@app/constants/theme"; interface RainbowThemeContextType { themeMode: ThemeMode; @@ -22,7 +22,7 @@ const RainbowThemeContext = createContext(null); export function useRainbowThemeContext() { const context = useContext(RainbowThemeContext); if (!context) { - throw new Error('useRainbowThemeContext must be used within RainbowThemeProvider'); + throw new Error("useRainbowThemeContext must be used within RainbowThemeProvider"); } return context; } @@ -35,19 +35,12 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) { const rainbowTheme = useRainbowTheme(); // Determine the Mantine color scheme - const mantineColorScheme = rainbowTheme.themeMode === 'rainbow' ? 'dark' : rainbowTheme.themeMode; + const mantineColorScheme = rainbowTheme.themeMode === "rainbow" ? "dark" : rainbowTheme.themeMode; return ( - -
+ +
{children} diff --git a/frontend/src/core/components/shared/RightRail.tsx b/frontend/src/core/components/shared/RightRail.tsx index 8001b17f73..639a354e73 100644 --- a/frontend/src/core/components/shared/RightRail.tsx +++ b/frontend/src/core/components/shared/RightRail.tsx @@ -1,40 +1,40 @@ -import React, { useCallback, useMemo } from 'react'; -import { ActionIcon, Divider } from '@mantine/core'; -import '@app/components/shared/rightRail/RightRail.css'; -import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; -import { useRightRail } from '@app/contexts/RightRailContext'; -import { useFileState, useFileSelection, useFileActions } from '@app/contexts/FileContext'; -import { isStirlingFile } from '@app/types/fileContext'; -import { useNavigationState } from '@app/contexts/NavigationContext'; -import { useTranslation } from 'react-i18next'; -import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology'; -import { useFileActionIcons } from '@app/hooks/useFileActionIcons'; +import React, { useCallback, useMemo } from "react"; +import { ActionIcon, Divider } from "@mantine/core"; +import "@app/components/shared/rightRail/RightRail.css"; +import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; +import { useRightRail } from "@app/contexts/RightRailContext"; +import { useFileState, useFileSelection, useFileActions } from "@app/contexts/FileContext"; +import { isStirlingFile } from "@app/types/fileContext"; +import { useNavigationState } from "@app/contexts/NavigationContext"; +import { useTranslation } from "react-i18next"; +import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology"; +import { useFileActionIcons } from "@app/hooks/useFileActionIcons"; -import LanguageSelector from '@app/components/shared/LanguageSelector'; -import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider'; -import { Tooltip } from '@app/components/shared/Tooltip'; -import { ViewerContext } from '@app/contexts/ViewerContext'; -import LocalIcon from '@app/components/shared/LocalIcon'; -import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions'; -import DarkModeIcon from '@mui/icons-material/DarkMode'; -import LightModeIcon from '@mui/icons-material/LightMode'; +import LanguageSelector from "@app/components/shared/LanguageSelector"; +import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider"; +import { Tooltip } from "@app/components/shared/Tooltip"; +import { ViewerContext } from "@app/contexts/ViewerContext"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { RightRailFooterExtensions } from "@app/components/rightRail/RightRailFooterExtensions"; +import DarkModeIcon from "@mui/icons-material/DarkMode"; +import LightModeIcon from "@mui/icons-material/LightMode"; -import { useSidebarContext } from '@app/contexts/SidebarContext'; -import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail'; -import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide'; -import { downloadFile } from '@app/services/downloadService'; +import { useSidebarContext } from "@app/contexts/SidebarContext"; +import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from "@app/types/rightRail"; +import { useRightRailTooltipSide } from "@app/hooks/useRightRailTooltipSide"; +import { downloadFile } from "@app/services/downloadService"; -const SECTION_ORDER: RightRailSection[] = ['top', 'middle', 'bottom']; +const SECTION_ORDER: RightRailSection[] = ["top", "middle", "bottom"]; function renderWithTooltip( node: React.ReactNode, tooltip: React.ReactNode | undefined, - position: 'left' | 'right', - offset: number + position: "left" | "right", + offset: number, ) { if (!tooltip) return node; - const portalTarget = typeof document !== 'undefined' ? document.body : undefined; + const portalTarget = typeof document !== "undefined" ? document.body : undefined; return ( @@ -54,7 +54,7 @@ export default function RightRail() { const { buttons, actions, allButtonsDisabled } = useRightRail(); const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow(); - const disableForFullscreen = toolPanelMode === 'fullscreen' && leftPanelView === 'toolPicker'; + const disableForFullscreen = toolPanelMode === "fullscreen" && leftPanelView === "toolPicker"; const { workbench: currentView } = useNavigationState(); @@ -66,24 +66,22 @@ export default function RightRail() { const pageEditorSelectedCount = pageEditorFunctions?.selectedPageIds?.length ?? 0; const totalItems = useMemo(() => { - if (currentView === 'pageEditor') return pageEditorTotalPages; + if (currentView === "pageEditor") return pageEditorTotalPages; return activeFiles.length; }, [currentView, pageEditorTotalPages, activeFiles.length]); const selectedCount = useMemo(() => { - if (currentView === 'pageEditor') { + 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); + 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( @@ -110,20 +108,19 @@ export default function RightRail() { 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 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} @@ -131,12 +128,12 @@ export default function RightRail() { return renderWithTooltip(buttonNode, btn.tooltip, tooltipPosition, tooltipOffset); }, - [actions, allButtonsDisabled, disableForFullscreen, tooltipPosition, tooltipOffset] + [actions, allButtonsDisabled, disableForFullscreen, tooltipPosition, tooltipOffset], ); const handleExportAll = useCallback( async (forceNewFile = false) => { - if (currentView === 'viewer') { + if (currentView === "viewer") { const buffer = await viewerContext?.exportActions?.saveAsCopy?.(); if (!buffer) return; const fileToExport = selectedFiles.length > 0 ? selectedFiles[0] : activeFiles[0]; @@ -144,7 +141,7 @@ export default function RightRail() { const stub = isStirlingFile(fileToExport) ? selectors.getStirlingFileStub(fileToExport.fileId) : undefined; try { const result = await downloadFile({ - data: new Blob([buffer], { type: 'application/pdf' }), + data: new Blob([buffer], { type: "application/pdf" }), filename: fileToExport.name, localPath: forceNewFile ? undefined : stub?.localFilePath, }); @@ -155,12 +152,12 @@ export default function RightRail() { }); } } catch (error) { - console.error('[RightRail] Failed to export viewer file:', error); + console.error("[RightRail] Failed to export viewer file:", error); } return; } - if (currentView === 'pageEditor') { + if (currentView === "pageEditor") { pageEditorFunctions?.onExportAll?.(); return; } @@ -184,27 +181,19 @@ export default function RightRail() { }); } } catch (error) { - console.error('[RightRail] Failed to export file:', file.name, error); + console.error("[RightRail] Failed to export file:", file.name, error); } } } }, - [ - currentView, - selectedFiles, - activeFiles, - pageEditorFunctions, - viewerContext, - selectors, - fileActions, - ] + [currentView, selectedFiles, activeFiles, pageEditorFunctions, viewerContext, selectors, fileActions], ); const downloadTooltip = useMemo(() => { - if (currentView === 'pageEditor') { - return t('rightRail.exportAll', 'Export PDF'); + if (currentView === "pageEditor") { + return t("rightRail.exportAll", "Export PDF"); } - if (currentView === 'viewer') { + if (currentView === "viewer") { return terminology.download; } if (selectedCount > 0) { @@ -223,11 +212,7 @@ export default function RightRail() { const content = renderButton(btn); if (!content) return null; return ( -
+
{content}
); @@ -236,31 +221,24 @@ export default function RightRail() { ))} -
+
{renderWithTooltip( - - {themeMode === 'dark' ? ( - + + {themeMode === "dark" ? ( + ) : ( - + )} , - t('rightRail.toggleTheme', 'Toggle Theme'), + t("rightRail.toggleTheme", "Toggle Theme"), tooltipPosition, - tooltipOffset + tooltipOffset, )} - + {renderWithTooltip( handleExportAll()} - disabled={ - disableForFullscreen || - (currentView !== 'viewer' && (totalItems === 0 || allButtonsDisabled)) - } + disabled={disableForFullscreen || (currentView !== "viewer" && (totalItems === 0 || allButtonsDisabled))} > , downloadTooltip, tooltipPosition, - tooltipOffset + tooltipOffset, )} {icons.saveAsIconName && renderWithTooltip( @@ -286,16 +261,13 @@ export default function RightRail() { radius="md" className="right-rail-icon" onClick={() => handleExportAll(true)} - disabled={ - disableForFullscreen || - (currentView !== 'viewer' && (totalItems === 0 || allButtonsDisabled)) - } + disabled={disableForFullscreen || (currentView !== "viewer" && (totalItems === 0 || allButtonsDisabled))} > , - t('rightRail.saveAs', 'Save As'), + t("rightRail.saveAs", "Save As"), tooltipPosition, - tooltipOffset + tooltipOffset, )}
diff --git a/frontend/src/core/components/shared/ShareFileModal.tsx b/frontend/src/core/components/shared/ShareFileModal.tsx index 3cdcf99245..757aa86bc7 100644 --- a/frontend/src/core/components/shared/ShareFileModal.tsx +++ b/frontend/src/core/components/shared/ShareFileModal.tsx @@ -1,19 +1,19 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Modal, Stack, Text, Button, Group, Alert, TextInput, Paper, Select } from '@mantine/core'; -import LinkIcon from '@mui/icons-material/Link'; -import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded'; -import { useTranslation } from 'react-i18next'; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Modal, Stack, Text, Button, Group, Alert, TextInput, Paper, Select } from "@mantine/core"; +import LinkIcon from "@mui/icons-material/Link"; +import ContentCopyRoundedIcon from "@mui/icons-material/ContentCopyRounded"; +import { useTranslation } from "react-i18next"; -import apiClient from '@app/services/apiClient'; -import { absoluteWithBasePath } from '@app/constants/app'; -import { alert } from '@app/components/toast'; -import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex'; -import { useAppConfig } from '@app/contexts/AppConfigContext'; -import type { StirlingFileStub } from '@app/types/fileContext'; -import { uploadHistoryChain } from '@app/services/serverStorageUpload'; -import { fileStorage } from '@app/services/fileStorage'; -import { useFileActions } from '@app/contexts/FileContext'; -import type { FileId } from '@app/types/file'; +import apiClient from "@app/services/apiClient"; +import { absoluteWithBasePath } from "@app/constants/app"; +import { alert } from "@app/components/toast"; +import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex"; +import { useAppConfig } from "@app/contexts/AppConfigContext"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import { uploadHistoryChain } from "@app/services/serverStorageUpload"; +import { fileStorage } from "@app/services/fileStorage"; +import { useFileActions } from "@app/contexts/FileContext"; +import type { FileId } from "@app/types/file"; interface ShareFileModalProps { opened: boolean; @@ -22,12 +22,7 @@ interface ShareFileModalProps { onUploaded?: () => Promise | void; } -const ShareFileModal: React.FC = ({ - opened, - onClose, - file, - onUploaded, -}) => { +const ShareFileModal: React.FC = ({ opened, onClose, file, onUploaded }) => { const { t } = useTranslation(); const { config } = useAppConfig(); const { actions } = useFileActions(); @@ -35,7 +30,7 @@ const ShareFileModal: React.FC = ({ const [isWorking, setIsWorking] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const [shareToken, setShareToken] = useState(null); - const [shareRole, setShareRole] = useState<'editor' | 'commenter' | 'viewer'>('editor'); + const [shareRole, setShareRole] = useState<"editor" | "commenter" | "viewer">("editor"); useEffect(() => { if (!opened) { @@ -47,18 +42,18 @@ const ShareFileModal: React.FC = ({ useEffect(() => { if (opened) { - setShareRole('editor'); + setShareRole("editor"); } }, [opened]); const shareUrl = useMemo(() => { - if (!shareToken) return ''; - const frontendUrl = (config?.frontendUrl || '').trim(); + if (!shareToken) return ""; + const frontendUrl = (config?.frontendUrl || "").trim(); if (frontendUrl) { try { const parsed = new URL(frontendUrl); - if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { - const normalized = frontendUrl.endsWith('/') ? frontendUrl.slice(0, -1) : frontendUrl; + if (parsed.protocol === "http:" || parsed.protocol === "https:") { + const normalized = frontendUrl.endsWith("/") ? frontendUrl.slice(0, -1) : frontendUrl; return `${normalized}/share/${shareToken}`; } } catch { @@ -68,18 +63,21 @@ const ShareFileModal: React.FC = ({ return absoluteWithBasePath(`/share/${shareToken}`); }, [config?.frontendUrl, shareToken]); - const createShareLink = useCallback(async (storedFileId: number) => { - const response = await apiClient.post(`/api/v1/storage/files/${storedFileId}/shares/links`, { - accessRole: shareRole, - }); - return response.data as { token?: string }; - }, [shareRole]); + const createShareLink = useCallback( + async (storedFileId: number) => { + const response = await apiClient.post(`/api/v1/storage/files/${storedFileId}/shares/links`, { + accessRole: shareRole, + }); + return response.data as { token?: string }; + }, + [shareRole], + ); const handleGenerateLink = useCallback(async () => { if (!shareLinksEnabled) { alert({ - alertType: 'warning', - title: t('storageShare.linksDisabled', 'Share links are disabled.'), + alertType: "warning", + title: t("storageShare.linksDisabled", "Share links are disabled."), expandable: false, durationMs: 2500, }); @@ -101,10 +99,7 @@ const ShareFileModal: React.FC = ({ if (!isUpToDate) { const originalFileId = (file.originalFileId || file.id) as FileId; const remoteId = file.remoteStorageId; - const { remoteId: newStoredId, updatedAt, chain } = await uploadHistoryChain( - originalFileId, - remoteId - ); + const { remoteId: newStoredId, updatedAt, chain } = await uploadHistoryChain(originalFileId, remoteId); storedId = newStoredId; for (const stub of chain) { @@ -124,14 +119,14 @@ const ShareFileModal: React.FC = ({ } if (!storedId) { - throw new Error('Missing stored file ID for sharing.'); + throw new Error("Missing stored file ID for sharing."); } const shareResponse = await createShareLink(storedId); setShareToken(shareResponse.token ?? null); alert({ - alertType: 'success', - title: t('storageShare.generated', 'Share link generated'), + alertType: "success", + title: t("storageShare.generated", "Share link generated"), expandable: false, durationMs: 3000, }); @@ -143,10 +138,8 @@ const ShareFileModal: React.FC = ({ await onUploaded(); } } catch (error: any) { - console.error('Failed to generate share link:', error); - setErrorMessage( - t('storageShare.failure', 'Unable to generate a share link. Please try again.') - ); + console.error("Failed to generate share link:", error); + setErrorMessage(t("storageShare.failure", "Unable to generate a share link. Please try again.")); } finally { setIsWorking(false); } @@ -157,16 +150,16 @@ const ShareFileModal: React.FC = ({ try { await navigator.clipboard.writeText(shareUrl); alert({ - alertType: 'success', - title: t('storageShare.copied', 'Link copied to clipboard'), + alertType: "success", + title: t("storageShare.copied", "Link copied to clipboard"), expandable: false, durationMs: 2000, }); } catch (error) { - console.error('Failed to copy share link:', error); + console.error("Failed to copy share link:", error); alert({ - alertType: 'warning', - title: t('storageShare.copyFailed', 'Copy failed'), + alertType: "warning", + title: t("storageShare.copyFailed", "Copy failed"), expandable: false, durationMs: 2500, }); @@ -178,7 +171,7 @@ const ShareFileModal: React.FC = ({ opened={opened} onClose={onClose} centered - title={t('storageShare.title', 'Share File')} + title={t("storageShare.title", "Share File")} zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL} size="lg" overlayProps={{ blur: 6 }} @@ -188,24 +181,24 @@ const ShareFileModal: React.FC = ({ {t( - 'storageShare.description', - 'Create a share link for this file. Signed-in users with the link can access it.' + "storageShare.description", + "Create a share link for this file. Signed-in users with the link can access it.", )} - {t('storageShare.fileLabel', 'File')}: {file.name} + {t("storageShare.fileLabel", "File")}: {file.name} {errorMessage && ( - + {errorMessage} )} {!shareLinksEnabled && ( - - {t('storageShare.linksDisabledBody', 'Share links are disabled by your server settings.')} + + {t("storageShare.linksDisabledBody", "Share links are disabled by your server settings.")} )} @@ -215,7 +208,7 @@ const ShareFileModal: React.FC = ({ = ({ leftSection={} onClick={handleCopyLink} > - {t('storageShare.copy', 'Copy')} + {t("storageShare.copy", "Copy")} } /> @@ -234,22 +227,22 @@ const ShareFileModal: React.FC = ({ - {t('storageShare.linkAccessTitle', 'Share link access')} + {t("storageShare.linkAccessTitle", "Share link access")} setShareRole((value as typeof shareRole) || 'editor')} + onChange={(value) => setShareRole((value as typeof shareRole) || "editor")} comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_FILE_MANAGER_MODAL + 10 }} data={[ - { value: 'editor', label: t('storageShare.roleEditor', 'Editor') }, - { value: 'commenter', label: t('storageShare.roleCommenter', 'Commenter') }, - { value: 'viewer', label: t('storageShare.roleViewer', 'Viewer') }, + { value: "editor", label: t("storageShare.roleEditor", "Editor") }, + { value: "commenter", label: t("storageShare.roleCommenter", "Commenter") }, + { value: "viewer", label: t("storageShare.roleViewer", "Viewer") }, ]} /> - {shareRole === 'commenter' && ( + {shareRole === "commenter" && ( - {t('storageShare.commenterHint', 'Commenting is coming soon.')} + {t("storageShare.commenterHint", "Commenting is coming soon.")} )} @@ -454,7 +432,7 @@ const ShareManagementModal: React.FC = ({ onClick={() => createShareLink()} loading={isLoading} > - {t('storageShare.generate', 'Generate Link')} + {t("storageShare.generate", "Generate Link")} @@ -464,19 +442,19 @@ const ShareManagementModal: React.FC = ({ - {t('storageShare.sharedUsersTitle', 'Shared users')} + {t("storageShare.sharedUsersTitle", "Shared users")} { setShareUsername(event.currentTarget.value); setShowEmailWarning(false); }} onKeyDown={(event) => { - if (event.key === 'Enter') { + if (event.key === "Enter") { event.preventDefault(); void handleAddUser(); } @@ -488,31 +466,24 @@ const ShareManagementModal: React.FC = ({ onClick={() => handleAddUser()} disabled={!sharingEnabled || isLoading || !normalizedShareUsername || !!shareUsernameError} > - {t('storageShare.addUser', 'Add')} + {t("storageShare.addUser", "Add")} {showEmailWarning && ( - + {t( - 'storageShare.emailWarningBody', - 'This looks like an email address. If this person is not already a Stirling PDF user, they will not be able to access the file.' + "storageShare.emailWarningBody", + "This looks like an email address. If this person is not already a Stirling PDF user, they will not be able to access the file.", )} - - @@ -520,7 +491,7 @@ const ShareManagementModal: React.FC = ({ )} {sharedUsers.length === 0 ? ( - {t('storageShare.noSharedUsers', 'No users have access yet.')} + {t("storageShare.noSharedUsers", "No users have access yet.")} ) : ( @@ -528,24 +499,24 @@ const ShareManagementModal: React.FC = ({ {user.username} - {user.accessRole === 'commenter' && ( + {user.accessRole === "commenter" && ( - {t('storageShare.commenterHint', 'Commenting is coming soon.')} + {t("storageShare.commenterHint", "Commenting is coming soon.")} )} onChange(e.currentTarget.value)} - autoComplete={autoComplete} - className={styles.input} - disabled={disabled} - readOnly={readOnly} - aria-label={ariaLabel} - onFocus={onFocus} - style={{ - backgroundColor: colorScheme === 'dark' ? '#4B525A' : '#FFFFFF', - color: colorScheme === 'dark' ? '#FFFFFF' : '#6B7382', - paddingRight: shouldShowClearButton ? '40px' : '12px', - paddingLeft: icon ? '40px' : '12px', - }} - {...props} - /> - {shouldShowClearButton && ( - - )} -
- ); -}); + return ( +
+ {icon && ( + + {icon} + + )} + onChange(e.currentTarget.value)} + autoComplete={autoComplete} + className={styles.input} + disabled={disabled} + readOnly={readOnly} + aria-label={ariaLabel} + onFocus={onFocus} + style={{ + backgroundColor: colorScheme === "dark" ? "#4B525A" : "#FFFFFF", + color: colorScheme === "dark" ? "#FFFFFF" : "#6B7382", + paddingRight: shouldShowClearButton ? "40px" : "12px", + paddingLeft: icon ? "40px" : "12px", + }} + {...props} + /> + {shouldShowClearButton && ( + + )} +
+ ); + }, +); -TextInput.displayName = 'TextInput'; +TextInput.displayName = "TextInput"; diff --git a/frontend/src/core/components/shared/ToolChain.tsx b/frontend/src/core/components/shared/ToolChain.tsx index c67d894273..7d2d45c6c2 100644 --- a/frontend/src/core/components/shared/ToolChain.tsx +++ b/frontend/src/core/components/shared/ToolChain.tsx @@ -3,63 +3,66 @@ * Used across FileListItem, FileDetails, and FileThumbnail for consistent display */ -import React from 'react'; -import { Text, Tooltip, Badge, Group } from '@mantine/core'; -import { ToolOperation } from '@app/types/file'; -import { useTranslation } from 'react-i18next'; -import { ToolId } from '@app/types/toolId'; +import React from "react"; +import { Text, Tooltip, Badge, Group } from "@mantine/core"; +import { ToolOperation } from "@app/types/file"; +import { useTranslation } from "react-i18next"; +import { ToolId } from "@app/types/toolId"; interface ToolChainProps { toolChain: ToolOperation[]; maxWidth?: string; - displayStyle?: 'text' | 'badges' | 'compact'; - size?: 'xs' | 'sm' | 'md'; + displayStyle?: "text" | "badges" | "compact"; + size?: "xs" | "sm" | "md"; color?: string; } const ToolChain: React.FC = ({ toolChain, - maxWidth = '100%', - displayStyle = 'text', - size = 'xs', - color = 'var(--mantine-color-blue-7)' + maxWidth = "100%", + displayStyle = "text", + size = "xs", + color = "var(--mantine-color-blue-7)", }) => { const { t } = useTranslation(); if (!toolChain || toolChain.length === 0) return null; - const toolIds = toolChain.map(tool => tool.toolId); + const toolIds = toolChain.map((tool) => tool.toolId); const getToolName = (toolId: ToolId) => { return t(`home.${toolId}.title`, toolId); }; // Create full tool chain for tooltip - const fullChainDisplay = displayStyle === 'badges' ? ( - - {toolChain.map((tool, index) => ( - - - {getToolName(tool.toolId)} - - {index < toolChain.length - 1 && ( - - )} - - ))} - - ) : ( - {toolIds.map(getToolName).join(' → ')} - ); + const fullChainDisplay = + displayStyle === "badges" ? ( + + {toolChain.map((tool, index) => ( + + + {getToolName(tool.toolId)} + + {index < toolChain.length - 1 && ( + + → + + )} + + ))} + + ) : ( + {toolIds.map(getToolName).join(" → ")} + ); // Create truncated display based on available space const getTruncatedDisplay = () => { if (toolIds.length <= 2) { // Show all tools if 2 or fewer - return { text: toolIds.map(getToolName).join(' → '), isTruncated: false }; + return { text: toolIds.map(getToolName).join(" → "), isTruncated: false }; } else { // Show first tool ... last tool for longer chains return { - text: `${getToolName(toolIds[0])} → +${toolIds.length-2} → ${getToolName(toolIds[toolIds.length - 1])}`, + text: `${getToolName(toolIds[0])} → +${toolIds.length - 2} → ${getToolName(toolIds[toolIds.length - 1])}`, isTruncated: true, }; } @@ -68,7 +71,7 @@ const ToolChain: React.FC = ({ const { text: truncatedText, isTruncated } = getTruncatedDisplay(); // Compact style for very small spaces - if (displayStyle === 'compact') { + if (displayStyle === "compact") { const compactText = toolIds.length === 1 ? getToolName(toolIds[0]) : `${toolIds.length} tools`; const isCompactTruncated = toolIds.length > 1; @@ -78,11 +81,11 @@ const ToolChain: React.FC = ({ style={{ color, fontWeight: 500, - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", maxWidth: `${maxWidth}`, - cursor: isCompactTruncated ? 'help' : 'default' + cursor: isCompactTruncated ? "help" : "default", }} > {compactText} @@ -93,15 +96,17 @@ const ToolChain: React.FC = ({ {compactElement} - ) : compactElement; + ) : ( + compactElement + ); } // Badge style for file details - if (displayStyle === 'badges') { + if (displayStyle === "badges") { const isBadgesTruncated = toolChain.length > 3; const badgesElement = ( -
+
{toolChain.slice(0, 3).map((tool, index) => ( @@ -109,13 +114,17 @@ const ToolChain: React.FC = ({ {getToolName(tool.toolId)} {index < Math.min(toolChain.length - 1, 2) && ( - + + → + )} ))} {toolChain.length > 3 && ( <> - ... + + ... + {getToolName(toolChain[toolChain.length - 1].toolId)} @@ -126,10 +135,12 @@ const ToolChain: React.FC = ({ ); return isBadgesTruncated ? ( - + {badgesElement} - ) : badgesElement; + ) : ( + badgesElement + ); } // Text style (default) for file list items @@ -139,11 +150,11 @@ const ToolChain: React.FC = ({ style={{ color, fontWeight: 500, - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", maxWidth: `${maxWidth}`, - cursor: isTruncated ? 'help' : 'default' + cursor: isTruncated ? "help" : "default", }} > {truncatedText} @@ -154,7 +165,9 @@ const ToolChain: React.FC = ({ {textElement} - ) : textElement; + ) : ( + textElement + ); }; export default ToolChain; diff --git a/frontend/src/core/components/shared/ToolIcon.tsx b/frontend/src/core/components/shared/ToolIcon.tsx index 75ab249ba7..d0a1c82b9b 100644 --- a/frontend/src/core/components/shared/ToolIcon.tsx +++ b/frontend/src/core/components/shared/ToolIcon.tsx @@ -15,7 +15,7 @@ export const ToolIcon: React.FC = ({ icon, opacity = 1, color = "var(--tools-text-and-icon-color)", - marginRight = "0.5rem" + marginRight = "0.5rem", }) => { return (
= ({ marginRight, transform: "scale(0.8)", transformOrigin: "center", - opacity + opacity, }} > {icon} diff --git a/frontend/src/core/components/shared/Tooltip.tsx b/frontend/src/core/components/shared/Tooltip.tsx index 2580b3530a..8757e56bfa 100644 --- a/frontend/src/core/components/shared/Tooltip.tsx +++ b/frontend/src/core/components/shared/Tooltip.tsx @@ -1,18 +1,18 @@ -import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; -import { createPortal } from 'react-dom'; -import LocalIcon from '@app/components/shared/LocalIcon'; -import { addEventListenerWithCleanup } from '@app/utils/genericUtils'; -import { useTooltipPosition } from '@app/hooks/useTooltipPosition'; -import { TooltipTip } from '@app/types/tips'; -import { TooltipContent } from '@app/components/shared/tooltip/TooltipContent'; -import { useSidebarContext } from '@app/contexts/SidebarContext'; -import { useLogoAssets } from '@app/hooks/useLogoAssets'; -import styles from '@app/components/shared/tooltip/Tooltip.module.css'; -import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex'; +import React, { useState, useRef, useEffect, useMemo, useCallback } from "react"; +import { createPortal } from "react-dom"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { addEventListenerWithCleanup } from "@app/utils/genericUtils"; +import { useTooltipPosition } from "@app/hooks/useTooltipPosition"; +import { TooltipTip } from "@app/types/tips"; +import { TooltipContent } from "@app/components/shared/tooltip/TooltipContent"; +import { useSidebarContext } from "@app/contexts/SidebarContext"; +import { useLogoAssets } from "@app/hooks/useLogoAssets"; +import styles from "@app/components/shared/tooltip/Tooltip.module.css"; +import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; export interface TooltipProps { sidebarTooltip?: boolean; - position?: 'right' | 'left' | 'top' | 'bottom'; + position?: "right" | "left" | "top" | "bottom"; content?: React.ReactNode; tips?: TooltipTip[]; children: React.ReactElement; @@ -73,8 +73,7 @@ export const Tooltip: React.FC = ({ const tooltipIdRef = useRef(`tooltip-${Math.random().toString(36).slice(2)}`); // Runtime guard: some browsers may surface non-Node EventTargets for relatedTarget/target - const isDomNode = (value: unknown): value is Node => - typeof Node !== 'undefined' && value instanceof Node; + const isDomNode = (value: unknown): value is Node => typeof Node !== "undefined" && value instanceof Node; const clearTimers = useCallback(() => { if (openTimeoutRef.current) { @@ -92,14 +91,14 @@ export const Tooltip: React.FC = ({ const open = (isControlled ? !!controlledOpen : internalOpen) && !disabled; const allowAutoClose = !manualCloseOnly; - const resolvedPosition: NonNullable = useMemo(() => { - const htmlDir = typeof document !== 'undefined' ? document.documentElement.dir : 'ltr'; - const isRTL = htmlDir === 'rtl'; - const base = position ?? 'right'; - if (!isRTL) return base as NonNullable; - if (base === 'left') return 'right'; - if (base === 'right') return 'left'; - return base as NonNullable; + const resolvedPosition: NonNullable = useMemo(() => { + const htmlDir = typeof document !== "undefined" ? document.documentElement.dir : "ltr"; + const isRTL = htmlDir === "rtl"; + const base = position ?? "right"; + if (!isRTL) return base as NonNullable; + if (base === "left") return "right"; + if (base === "right") return "left"; + return base as NonNullable; }, [position]); const setOpen = useCallback( @@ -109,7 +108,7 @@ export const Tooltip: React.FC = ({ else setInternalOpen(newOpen); if (!newOpen) setIsPinned(false); }, - [isControlled, onOpenChange, open] + [isControlled, onOpenChange, open], ); const { coords, positionReady } = useTooltipPosition({ @@ -146,13 +145,13 @@ export const Tooltip: React.FC = ({ setOpen(false); } }, - [isPinned, closeOnOutside, setOpen, allowAutoClose] + [isPinned, closeOnOutside, setOpen, allowAutoClose], ); useEffect(() => { // Attach global click when open (so hover tooltips can also close on outside if desired) if (open || isPinned) { - return addEventListenerWithCleanup(document, 'click', handleDocumentClick as EventListener); + return addEventListenerWithCleanup(document, "click", handleDocumentClick as EventListener); } }, [open, isPinned, handleDocumentClick]); @@ -160,11 +159,11 @@ export const Tooltip: React.FC = ({ const arrowClass = useMemo(() => { if (sidebarTooltip) return null; - const map: Record, string> = { - top: 'tooltip-arrow-bottom', - bottom: 'tooltip-arrow-top', - left: 'tooltip-arrow-left', - right: 'tooltip-arrow-right', + const map: Record, string> = { + top: "tooltip-arrow-bottom", + bottom: "tooltip-arrow-top", + left: "tooltip-arrow-left", + right: "tooltip-arrow-right", }; return map[resolvedPosition] || map.right; }, [resolvedPosition, sidebarTooltip]); @@ -173,8 +172,8 @@ export const Tooltip: React.FC = ({ (key: string) => styles[key as keyof typeof styles] || styles[key.replace(/-([a-z])/g, (_, l) => l.toUpperCase()) as keyof typeof styles] || - '', - [] + "", + [], ); // === Trigger handlers === @@ -189,7 +188,7 @@ export const Tooltip: React.FC = ({ if (!isPinned && !disabled) openWithDelay(); (children.props as any)?.onPointerEnter?.(e); }, - [isPinned, openWithDelay, children.props, disabled] + [isPinned, openWithDelay, children.props, disabled], ); const handlePointerLeave = useCallback( @@ -198,7 +197,6 @@ export const Tooltip: React.FC = ({ // Moving into the tooltip → keep open if (isDomNode(related) && tooltipRef.current && tooltipRef.current.contains(related)) { - (children.props as any)?.onPointerLeave?.(e); return; } @@ -213,7 +211,7 @@ export const Tooltip: React.FC = ({ if (allowAutoClose && !isPinned) setOpen(false); (children.props as any)?.onPointerLeave?.(e); }, - [clearTimers, isPinned, setOpen, children.props, allowAutoClose] + [clearTimers, isPinned, setOpen, children.props, allowAutoClose], ); const handleMouseDown = useCallback( @@ -221,7 +219,7 @@ export const Tooltip: React.FC = ({ clickPendingRef.current = true; (children.props as any)?.onMouseDown?.(e); }, - [children.props] + [children.props], ); const handleMouseUp = useCallback( @@ -230,7 +228,7 @@ export const Tooltip: React.FC = ({ queueMicrotask(() => (clickPendingRef.current = false)); (children.props as any)?.onMouseUp?.(e); }, - [children.props] + [children.props], ); const handleClick = useCallback( @@ -247,7 +245,7 @@ export const Tooltip: React.FC = ({ clickPendingRef.current = false; (children.props as any)?.onClick?.(e); }, - [clearTimers, pinOnClick, open, setOpen, children.props] + [clearTimers, pinOnClick, open, setOpen, children.props], ); // Keyboard / focus accessibility @@ -256,7 +254,7 @@ export const Tooltip: React.FC = ({ if (!isPinned && !disabled && openOnFocus) openWithDelay(); (children.props as any)?.onFocus?.(e); }, - [isPinned, openWithDelay, children.props, disabled, openOnFocus] + [isPinned, openWithDelay, children.props, disabled, openOnFocus], ); const handleBlur = useCallback( @@ -270,13 +268,16 @@ export const Tooltip: React.FC = ({ if (allowAutoClose && !isPinned) setOpen(false); (children.props as any)?.onBlur?.(e); }, - [isPinned, setOpen, children.props, allowAutoClose, clearTimers] + [isPinned, setOpen, children.props, allowAutoClose, clearTimers], ); - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if (manualCloseOnly) return; - if (e.key === 'Escape') setOpen(false); - }, [setOpen, manualCloseOnly]); + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (manualCloseOnly) return; + if (e.key === "Escape") setOpen(false); + }, + [setOpen, manualCloseOnly], + ); // Keep open while pointer is over the tooltip; close when leaving it (if not pinned) const handleTooltipPointerEnter = useCallback(() => { @@ -289,7 +290,7 @@ export const Tooltip: React.FC = ({ if (isDomNode(related) && triggerRef.current && triggerRef.current.contains(related)) return; if (allowAutoClose && !isPinned) setOpen(false); }, - [isPinned, setOpen, allowAutoClose] + [isPinned, setOpen, allowAutoClose], ); // Enhance child with handlers and ref @@ -297,10 +298,10 @@ export const Tooltip: React.FC = ({ ref: (node: HTMLElement | null) => { triggerRef.current = node || null; const originalRef = (children as any).ref; - if (typeof originalRef === 'function') originalRef(node); - else if (originalRef && typeof originalRef === 'object') (originalRef as any).current = node; + if (typeof originalRef === "function") originalRef(node); + else if (originalRef && typeof originalRef === "object") (originalRef as any).current = node; }, - 'aria-describedby': open ? tooltipIdRef.current : undefined, + "aria-describedby": open ? tooltipIdRef.current : undefined, onPointerEnter: handlePointerEnter, onPointerLeave: handlePointerLeave, onMouseDown: handleMouseDown, @@ -323,23 +324,30 @@ export const Tooltip: React.FC = ({ onPointerEnter={handleTooltipPointerEnter} onPointerLeave={handleTooltipPointerLeave} style={{ - position: 'fixed', + position: "fixed", top: coords.top, left: coords.left, - width: maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' as const : undefined), + width: maxWidth !== undefined ? maxWidth : sidebarTooltip ? ("25rem" as const) : undefined, minWidth, zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE, - visibility: positionReady ? 'visible' : 'hidden', + visibility: positionReady ? "visible" : "hidden", opacity: positionReady ? 1 : 0, - color: 'var(--text-primary)', + color: "var(--text-primary)", ...containerStyle, }} - className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`} - onClick={pinOnClick ? (e) => { e.stopPropagation(); setIsPinned(true); } : undefined} + className={`${styles["tooltip-container"]} ${isPinned ? styles.pinned : ""}`} + onClick={ + pinOnClick + ? (e) => { + e.stopPropagation(); + setIsPinned(true); + } + : undefined + } > {shouldShowCloseButton && ( @@ -254,7 +246,7 @@ const UpdateModal: React.FC = ({ - {t('update.loadingDetailedInfo', 'Loading detailed information...')} + {t("update.loadingDetailedInfo", "Loading detailed information...")}
@@ -262,10 +254,10 @@ const UpdateModal: React.FC = ({ - {t('update.availableUpdates', 'Available Updates')} + {t("update.availableUpdates", "Available Updates")} - {fullUpdateInfo.new_versions.length} {fullUpdateInfo.new_versions.length === 1 ? 'version' : 'versions'} + {fullUpdateInfo.new_versions.length} {fullUpdateInfo.new_versions.length === 1 ? "version" : "versions"} @@ -275,9 +267,9 @@ const UpdateModal: React.FC = ({ = ({ align="center" p="md" style={{ - cursor: 'pointer', - background: isExpanded ? 'var(--mantine-color-gray-0)' : 'transparent', - transition: 'background 0.15s ease', + cursor: "pointer", + background: isExpanded ? "var(--mantine-color-gray-0)" : "transparent", + transition: "background 0.15s ease", }} onClick={() => toggleVersion(index)} > - {t('update.version', 'Version')} + {t("update.version", "Version")} {version.version} @@ -314,18 +306,18 @@ const UpdateModal: React.FC = ({ onClick={(e) => e.stopPropagation()} rightSection={} > - {t('update.releaseNotes', 'Release Notes')} + {t("update.releaseNotes", "Release Notes")} {isExpanded ? ( - + ) : ( - + )} - + @@ -339,21 +331,23 @@ const UpdateModal: React.FC = ({ {version.compatibility.breaking_changes && ( - + - {t('update.breakingChanges', 'Breaking Changes')} + {t("update.breakingChanges", "Breaking Changes")} {version.compatibility.breaking_description || - t('update.breakingChangesDefault', 'This version contains breaking changes.')} + t("update.breakingChangesDefault", "This version contains breaking changes.")} {version.compatibility.migration_guide_url && ( )} @@ -384,7 +378,7 @@ const UpdateModal: React.FC = ({ {downloadUrl && ( )} diff --git a/frontend/src/core/components/shared/UploadToServerModal.tsx b/frontend/src/core/components/shared/UploadToServerModal.tsx index ecf424e60b..5bab2d8efa 100644 --- a/frontend/src/core/components/shared/UploadToServerModal.tsx +++ b/frontend/src/core/components/shared/UploadToServerModal.tsx @@ -1,15 +1,15 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { Modal, Stack, Text, Button, Group, Alert } from '@mantine/core'; -import CloudUploadIcon from '@mui/icons-material/CloudUpload'; -import { useTranslation } from 'react-i18next'; +import React, { useCallback, useEffect, useState } from "react"; +import { Modal, Stack, Text, Button, Group, Alert } from "@mantine/core"; +import CloudUploadIcon from "@mui/icons-material/CloudUpload"; +import { useTranslation } from "react-i18next"; -import { alert } from '@app/components/toast'; -import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex'; -import type { StirlingFileStub } from '@app/types/fileContext'; -import { uploadHistoryChain } from '@app/services/serverStorageUpload'; -import { fileStorage } from '@app/services/fileStorage'; -import { useFileActions } from '@app/contexts/FileContext'; -import type { FileId } from '@app/types/file'; +import { alert } from "@app/components/toast"; +import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import { uploadHistoryChain } from "@app/services/serverStorageUpload"; +import { fileStorage } from "@app/services/fileStorage"; +import { useFileActions } from "@app/contexts/FileContext"; +import type { FileId } from "@app/types/file"; interface UploadToServerModalProps { opened: boolean; @@ -18,12 +18,7 @@ interface UploadToServerModalProps { onUploaded?: () => Promise | void; } -const UploadToServerModal: React.FC = ({ - opened, - onClose, - file, - onUploaded, -}) => { +const UploadToServerModal: React.FC = ({ opened, onClose, file, onUploaded }) => { const { t } = useTranslation(); const { actions } = useFileActions(); const [isUploading, setIsUploading] = useState(false); @@ -43,10 +38,7 @@ const UploadToServerModal: React.FC = ({ try { const originalFileId = (file.originalFileId || file.id) as FileId; const remoteId = file.remoteStorageId; - const { remoteId: storedId, updatedAt, chain } = await uploadHistoryChain( - originalFileId, - remoteId - ); + const { remoteId: storedId, updatedAt, chain } = await uploadHistoryChain(originalFileId, remoteId); for (const stub of chain) { actions.updateStirlingFileStub(stub.id, { @@ -62,8 +54,8 @@ const UploadToServerModal: React.FC = ({ } alert({ - alertType: 'success', - title: t('storageUpload.success', 'Uploaded to server'), + alertType: "success", + title: t("storageUpload.success", "Uploaded to server"), expandable: false, durationMs: 3000, }); @@ -72,10 +64,8 @@ const UploadToServerModal: React.FC = ({ } onClose(); } catch (error) { - console.error('Failed to upload file to server:', error); - setErrorMessage( - t('storageUpload.failure', 'Upload failed. Please check your login and storage settings.') - ); + console.error("Failed to upload file to server:", error); + setErrorMessage(t("storageUpload.failure", "Upload failed. Please check your login and storage settings.")); } finally { setIsUploading(false); } @@ -86,44 +76,34 @@ const UploadToServerModal: React.FC = ({ opened={opened} onClose={onClose} centered - title={t('storageUpload.title', 'Upload to Server')} + title={t("storageUpload.title", "Upload to Server")} zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL} > - {t( - 'storageUpload.description', - 'This uploads the current file to server storage for your own access.' - )} + {t("storageUpload.description", "This uploads the current file to server storage for your own access.")} - {t('storageUpload.fileLabel', 'File')}: {file.name} + {t("storageUpload.fileLabel", "File")}: {file.name} - {t( - 'storageUpload.hint', - 'Public links and access modes are controlled by your server settings.' - )} + {t("storageUpload.hint", "Public links and access modes are controlled by your server settings.")} {errorMessage && ( - + {errorMessage} )} - diff --git a/frontend/src/core/components/shared/UserSelector.tsx b/frontend/src/core/components/shared/UserSelector.tsx index 22c99ec61d..5e292bacda 100644 --- a/frontend/src/core/components/shared/UserSelector.tsx +++ b/frontend/src/core/components/shared/UserSelector.tsx @@ -1,25 +1,25 @@ -import { useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { MultiSelect, Loader, Text, Button, Stack } from '@mantine/core'; -import { useNavigate } from 'react-router-dom'; -import { alert } from '@app/components/toast'; -import { UserSummary } from '@app/types/signingSession'; -import apiClient from '@app/services/apiClient'; -import { useAuth } from '@app/auth/UseSession'; -import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex'; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { MultiSelect, Loader, Text, Button, Stack } from "@mantine/core"; +import { useNavigate } from "react-router-dom"; +import { alert } from "@app/components/toast"; +import { UserSummary } from "@app/types/signingSession"; +import apiClient from "@app/services/apiClient"; +import { useAuth } from "@app/auth/UseSession"; +import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from "@app/styles/zIndex"; interface UserSelectorProps { value: number[]; onChange: (userIds: number[]) => void; placeholder?: string; - size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + size?: "xs" | "sm" | "md" | "lg" | "xl"; disabled?: boolean; } type SelectItem = { value: string; label: string }; type GroupedData = { group: string; items: SelectItem[] }; -const UserSelector = ({ value, onChange, placeholder, size = 'sm', disabled = false }: UserSelectorProps) => { +const UserSelector = ({ value, onChange, placeholder, size = "sm", disabled = false }: UserSelectorProps) => { const { t } = useTranslation(); const { user } = useAuth(); const navigate = useNavigate(); @@ -30,8 +30,8 @@ const UserSelector = ({ value, onChange, placeholder, size = 'sm', disabled = fa useEffect(() => { const fetchUsers = async () => { try { - const response = await apiClient.get('/api/v1/user/users'); - console.log('Users API response:', response.data); + const response = await apiClient.get("/api/v1/user/users"); + console.log("Users API response:", response.data); const fetchedUsers = response.data || []; // Process selectData inside useEffect - group by team @@ -41,16 +41,15 @@ const UserSelector = ({ value, onChange, placeholder, size = 'sm', disabled = fa fetchedUsers .filter((u: UserSummary) => u && u.userId && u.username) .filter((u: UserSummary) => u.userId !== currentUserId) // Exclude current user - .filter((u: UserSummary) => u.teamName?.toLowerCase() !== 'internal') // Exclude internal users + .filter((u: UserSummary) => u.teamName?.toLowerCase() !== "internal") // Exclude internal users .forEach((user: UserSummary) => { - const teamName = user.teamName || t('certSign.collab.userSelector.noTeam', 'No Team'); + const teamName = user.teamName || t("certSign.collab.userSelector.noTeam", "No Team"); if (!usersByTeam[teamName]) { usersByTeam[teamName] = []; } - const displayName = user.displayName || user.username || 'Unknown'; - const username = user.username || 'unknown'; - const label = - displayName !== username ? `${displayName} (@${username})` : displayName; + const displayName = user.displayName || user.username || "Unknown"; + const username = user.username || "unknown"; + const label = displayName !== username ? `${displayName} (@${username})` : displayName; usersByTeam[teamName].push({ value: String(user.userId), label, @@ -63,14 +62,14 @@ const UserSelector = ({ value, onChange, placeholder, size = 'sm', disabled = fa items: items.sort((a, b) => a.label.localeCompare(b.label)), })); - console.log('Processed selectData:', processed); + console.log("Processed selectData:", processed); setSelectData(processed); } catch (error) { - console.error('Failed to load users:', error); + console.error("Failed to load users:", error); alert({ - alertType: 'error', - title: t('common.error'), - body: t('certSign.collab.userSelector.loadError', 'Failed to load users'), + alertType: "error", + title: t("common.error"), + body: t("certSign.collab.userSelector.loadError", "Failed to load users"), }); } finally { setLoading(false); @@ -83,8 +82,8 @@ const UserSelector = ({ value, onChange, placeholder, size = 'sm', disabled = fa // Process stringValue when value prop changes useEffect(() => { const safeValue = Array.isArray(value) ? value : []; - const result = safeValue.map((id) => (id != null ? id.toString() : '')).filter(Boolean); - console.log('stringValue for MultiSelect:', result); + const result = safeValue.map((id) => (id != null ? id.toString() : "")).filter(Boolean); + console.log("stringValue for MultiSelect:", result); setStringValue(result); }, [value]); @@ -97,10 +96,10 @@ const UserSelector = ({ value, onChange, placeholder, size = 'sm', disabled = fa return ( - {t('certSign.collab.userSelector.noUsers', 'No other users found.')} + {t("certSign.collab.userSelector.noUsers", "No other users found.")} - ); @@ -111,12 +110,10 @@ const UserSelector = ({ value, onChange, placeholder, size = 'sm', disabled = fa data={selectData} value={stringValue} onChange={(selectedIds) => { - const parsedIds = selectedIds - .map((id) => parseInt(id, 10)) - .filter((id) => !isNaN(id)); + const parsedIds = selectedIds.map((id) => parseInt(id, 10)).filter((id) => !isNaN(id)); onChange(parsedIds); }} - placeholder={placeholder || t('certSign.collab.userSelector.placeholder', 'Select users...')} + placeholder={placeholder || t("certSign.collab.userSelector.placeholder", "Select users...")} searchable clearable size={size} diff --git a/frontend/src/core/components/shared/ZipWarningModal.tsx b/frontend/src/core/components/shared/ZipWarningModal.tsx index 909cf1b31b..7a6e32c971 100644 --- a/frontend/src/core/components/shared/ZipWarningModal.tsx +++ b/frontend/src/core/components/shared/ZipWarningModal.tsx @@ -15,9 +15,9 @@ interface ZipWarningModalProps { const WARNING_ICON_STYLE: CSSProperties = { fontSize: 36, - display: 'block', - margin: '0 auto 8px', - color: 'var(--mantine-color-blue-6)' + display: "block", + margin: "0 auto 8px", + color: "var(--mantine-color-blue-6)", }; const ZipWarningModal = ({ opened, onConfirm, onCancel, fileCount, zipFileName }: ZipWarningModalProps) => { @@ -41,7 +41,7 @@ const ZipWarningModal = ({ opened, onConfirm, onCancel, fileCount, zipFileName } {t("zipWarning.message", { count: fileCount, - defaultValue: "This ZIP contains {{count}} files. Extract anyway?" + defaultValue: "This ZIP contains {{count}} files. Extract anyway?", })} diff --git a/frontend/src/core/components/shared/config/LoginRequiredBanner.tsx b/frontend/src/core/components/shared/config/LoginRequiredBanner.tsx index f16e38f4ea..d540bc989f 100644 --- a/frontend/src/core/components/shared/config/LoginRequiredBanner.tsx +++ b/frontend/src/core/components/shared/config/LoginRequiredBanner.tsx @@ -1,6 +1,6 @@ -import { Alert, Text } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import LocalIcon from '@app/components/shared/LocalIcon'; +import { Alert, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import LocalIcon from "@app/components/shared/LocalIcon"; interface LoginRequiredBannerProps { show: boolean; @@ -18,20 +18,26 @@ export default function LoginRequiredBanner({ show }: LoginRequiredBannerProps) return ( } - title={t('admin.settings.loginDisabled.title', 'Login Mode Required')} + title={t("admin.settings.loginDisabled.title", "Login Mode Required")} color="blue" variant="light" styles={{ root: { - borderLeft: '4px solid var(--mantine-color-blue-6)' - } + borderLeft: "4px solid var(--mantine-color-blue-6)", + }, }} > - {t('admin.settings.loginDisabled.message', 'Login mode must be enabled to modify admin settings. Please set SECURITY_ENABLELOGIN=true in your environment or security.enableLogin: true in settings.yml, then restart the server.')} + {t( + "admin.settings.loginDisabled.message", + "Login mode must be enabled to modify admin settings. Please set SECURITY_ENABLELOGIN=true in your environment or security.enableLogin: true in settings.yml, then restart the server.", + )} - {t('admin.settings.loginDisabled.readOnly', 'The settings below show example values for reference. Enable login mode to view and edit actual configuration.')} + {t( + "admin.settings.loginDisabled.readOnly", + "The settings below show example values for reference. Enable login mode to view and edit actual configuration.", + )} ); diff --git a/frontend/src/core/components/shared/config/OverviewHeader.tsx b/frontend/src/core/components/shared/config/OverviewHeader.tsx index 7be820620a..fe694d29b8 100644 --- a/frontend/src/core/components/shared/config/OverviewHeader.tsx +++ b/frontend/src/core/components/shared/config/OverviewHeader.tsx @@ -1,14 +1,16 @@ -import { Text } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; +import { Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; export function OverviewHeader() { const { t } = useTranslation(); return (
- {t('config.overview.title', 'Application Configuration')} + + {t("config.overview.title", "Application Configuration")} + - {t('config.overview.description', 'Current application settings and configuration details.')} + {t("config.overview.description", "Current application settings and configuration details.")}
); diff --git a/frontend/src/core/components/shared/config/PendingBadge.tsx b/frontend/src/core/components/shared/config/PendingBadge.tsx index cdb3306f80..625c2499e2 100644 --- a/frontend/src/core/components/shared/config/PendingBadge.tsx +++ b/frontend/src/core/components/shared/config/PendingBadge.tsx @@ -1,22 +1,22 @@ -import { Badge } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; +import { Badge } from "@mantine/core"; +import { useTranslation } from "react-i18next"; interface PendingBadgeProps { show: boolean; - size?: 'xs' | 'sm' | 'md' | 'lg'; + size?: "xs" | "sm" | "md" | "lg"; } /** * Badge to show when a setting has been saved but requires restart to take effect. */ -export default function PendingBadge({ show, size = 'xs' }: PendingBadgeProps) { +export default function PendingBadge({ show, size = "xs" }: PendingBadgeProps) { const { t } = useTranslation(); if (!show) return null; return ( - {t('admin.settings.restartRequired', 'Restart Required')} + {t("admin.settings.restartRequired", "Restart Required")} ); } diff --git a/frontend/src/core/components/shared/config/RestartConfirmationModal.tsx b/frontend/src/core/components/shared/config/RestartConfirmationModal.tsx index b97b17a0c6..9f8c75cee3 100644 --- a/frontend/src/core/components/shared/config/RestartConfirmationModal.tsx +++ b/frontend/src/core/components/shared/config/RestartConfirmationModal.tsx @@ -1,8 +1,8 @@ -import { Modal, Text, Group, Button, Stack } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import ScheduleIcon from '@mui/icons-material/Schedule'; -import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; +import { Modal, Text, Group, Button, Stack } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import ScheduleIcon from "@mui/icons-material/Schedule"; +import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex"; interface RestartConfirmationModalProps { opened: boolean; @@ -10,11 +10,7 @@ interface RestartConfirmationModalProps { onRestart: () => void; } -export default function RestartConfirmationModal({ - opened, - onClose, - onRestart, -}: RestartConfirmationModalProps) { +export default function RestartConfirmationModal({ opened, onClose, onRestart }: RestartConfirmationModalProps) { const { t } = useTranslation(); return ( @@ -23,7 +19,7 @@ export default function RestartConfirmationModal({ onClose={onClose} title={ - {t('admin.settings.restart.title', 'Restart Required')} + {t("admin.settings.restart.title", "Restart Required")} } centered @@ -34,32 +30,21 @@ export default function RestartConfirmationModal({ {t( - 'admin.settings.restart.message', - 'Settings have been saved successfully. A server restart is required for the changes to take effect.' + "admin.settings.restart.message", + "Settings have been saved successfully. A server restart is required for the changes to take effect.", )} - {t( - 'admin.settings.restart.question', - 'Would you like to restart the server now or later?' - )} + {t("admin.settings.restart.question", "Would you like to restart the server now or later?")} - - diff --git a/frontend/src/core/components/shared/config/SettingsSearchBar.tsx b/frontend/src/core/components/shared/config/SettingsSearchBar.tsx index cbbbd97076..b08a8b6184 100644 --- a/frontend/src/core/components/shared/config/SettingsSearchBar.tsx +++ b/frontend/src/core/components/shared/config/SettingsSearchBar.tsx @@ -1,10 +1,10 @@ -import React, { useMemo, useState, useCallback } from 'react'; -import { Select, Text } from '@mantine/core'; -import { useTranslation } from 'react-i18next'; -import LocalIcon from '@app/components/shared/LocalIcon'; -import { NavKey, VALID_NAV_KEYS } from '@app/components/shared/config/types'; -import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; -import type { ConfigNavSection, ConfigNavItem } from '@app/components/shared/config/configNavSections'; +import React, { useMemo, useState, useCallback } from "react"; +import { Select, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types"; +import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex"; +import type { ConfigNavSection, ConfigNavItem } from "@app/components/shared/config/configNavSections"; interface SettingsSearchBarProps { configNavSections: ConfigNavSection[]; @@ -22,35 +22,35 @@ interface SettingsSearchOption { } const SETTINGS_SEARCH_TRANSLATION_PREFIXES: Partial> = { - general: ['settings.general'], - hotkeys: ['settings.hotkeys'], - account: ['account'], - people: ['settings.workspace'], - teams: ['settings.workspace', 'settings.team'], - 'api-keys': ['settings.developer'], - connectionMode: ['settings.connection'], - planBilling: ['settings.planBilling'], - adminGeneral: ['admin.settings.general'], - adminFeatures: ['admin.settings.features'], - adminEndpoints: ['admin.settings.endpoints'], - adminDatabase: ['admin.settings.database'], - adminAdvanced: ['admin.settings.advanced'], - adminSecurity: ['admin.settings.security'], + general: ["settings.general"], + hotkeys: ["settings.hotkeys"], + account: ["account"], + people: ["settings.workspace"], + teams: ["settings.workspace", "settings.team"], + "api-keys": ["settings.developer"], + connectionMode: ["settings.connection"], + planBilling: ["settings.planBilling"], + adminGeneral: ["admin.settings.general"], + adminFeatures: ["admin.settings.features"], + adminEndpoints: ["admin.settings.endpoints"], + adminDatabase: ["admin.settings.database"], + adminAdvanced: ["admin.settings.advanced"], + adminSecurity: ["admin.settings.security"], adminConnections: [ - 'admin.settings.connections', - 'admin.settings.mail', - 'admin.settings.security', - 'admin.settings.telegram', - 'admin.settings.premium', - 'admin.settings.general', - 'settings.securityAuth', - 'settings.connection', + "admin.settings.connections", + "admin.settings.mail", + "admin.settings.security", + "admin.settings.telegram", + "admin.settings.premium", + "admin.settings.general", + "settings.securityAuth", + "settings.connection", ], - adminPlan: ['settings.planBilling', 'admin.settings.premium', 'settings.licensingAnalytics'], - adminAudit: ['settings.licensingAnalytics'], - adminUsage: ['settings.licensingAnalytics'], - adminLegal: ['admin.settings.legal'], - adminPrivacy: ['admin.settings.privacy'], + adminPlan: ["settings.planBilling", "admin.settings.premium", "settings.licensingAnalytics"], + adminAudit: ["settings.licensingAnalytics"], + adminUsage: ["settings.licensingAnalytics"], + adminLegal: ["admin.settings.legal"], + adminPrivacy: ["admin.settings.privacy"], }; const getTranslationPrefixesForNavKey = (key: string): string[] => { @@ -58,8 +58,8 @@ const getTranslationPrefixesForNavKey = (key: string): string[] => { const inferredPrefixes: string[] = []; - if (key.startsWith('admin')) { - const adminSuffix = key.replace(/^admin/, ''); + if (key.startsWith("admin")) { + const adminSuffix = key.replace(/^admin/, ""); const normalizedAdminSuffix = adminSuffix.charAt(0).toLowerCase() + adminSuffix.slice(1); inferredPrefixes.push(`admin.settings.${normalizedAdminSuffix}`); } else { @@ -70,7 +70,7 @@ const getTranslationPrefixesForNavKey = (key: string): string[] => { }; const flattenTranslationStrings = (value: unknown): string[] => { - if (typeof value === 'string') { + if (typeof value === "string") { const trimmed = value.trim(); return trimmed ? [trimmed] : []; } @@ -79,7 +79,7 @@ const flattenTranslationStrings = (value: unknown): string[] => { return value.flatMap(flattenTranslationStrings); } - if (value && typeof value === 'object') { + if (value && typeof value === "object") { return Object.values(value as Record).flatMap(flattenTranslationStrings); } @@ -102,19 +102,15 @@ const buildMatchSnippet = (text: string, query: string): string => { const snippet = text.slice(start, end); if (snippet.length <= maxLength) { - return `${start > 0 ? '…' : ''}${snippet}${end < text.length ? '…' : ''}`; + return `${start > 0 ? "…" : ""}${snippet}${end < text.length ? "…" : ""}`; } - return `${start > 0 ? '…' : ''}${snippet.slice(0, maxLength)}${end < text.length ? '…' : ''}`; + return `${start > 0 ? "…" : ""}${snippet.slice(0, maxLength)}${end < text.length ? "…" : ""}`; }; -export const SettingsSearchBar: React.FC = ({ - configNavSections, - onNavigate, - isMobile, -}) => { +export const SettingsSearchBar: React.FC = ({ configNavSections, onNavigate, isMobile }) => { const { t } = useTranslation(); - const [searchValue, setSearchValue] = useState(''); + const [searchValue, setSearchValue] = useState(""); // Build a global index from every accessible settings tab in the modal navigation. // This does not render section components, so API calls still happen only when a tab is opened. @@ -125,16 +121,11 @@ export const SettingsSearchBar: React.FC = ({ .map((item: ConfigNavItem) => { const translationPrefixes = getTranslationPrefixesForNavKey(item.key); const translationContent = translationPrefixes.flatMap((prefix) => - flattenTranslationStrings(t(prefix, { returnObjects: true, defaultValue: {} } as any)) + flattenTranslationStrings(t(prefix, { returnObjects: true, defaultValue: {} } as any)), ); const searchableContent = Array.from( - new Set([ - item.label, - section.title, - `/settings/${item.key}`, - ...translationContent, - ]) + new Set([item.label, section.title, `/settings/${item.key}`, ...translationContent]), ); return { @@ -144,7 +135,7 @@ export const SettingsSearchBar: React.FC = ({ destinationPath: `/settings/${item.key}`, searchableContent, }; - }) + }), ); }, [configNavSections, t]); @@ -157,9 +148,7 @@ export const SettingsSearchBar: React.FC = ({ const normalizedQuery = query.toLocaleLowerCase(); return searchableSections.reduce((accumulator, option) => { - const matchedEntry = option.searchableContent.find((entry) => - entry.toLocaleLowerCase().includes(normalizedQuery) - ); + const matchedEntry = option.searchableContent.find((entry) => entry.toLocaleLowerCase().includes(normalizedQuery)); if (!matchedEntry) { return accumulator; @@ -174,12 +163,15 @@ export const SettingsSearchBar: React.FC = ({ }, []); }, [searchValue, searchableSections]); - const handleSearchNavigation = useCallback(async (value: string | null) => { - if (!value) return; - if (!VALID_NAV_KEYS.includes(value as NavKey)) return; - await onNavigate(value as NavKey); - setSearchValue(''); - }, [onNavigate]); + const handleSearchNavigation = useCallback( + async (value: string | null) => { + if (!value) return; + if (!VALID_NAV_KEYS.includes(value as NavKey)) return; + await onNavigate(value as NavKey); + setSearchValue(""); + }, + [onNavigate], + ); return (