mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
fix(i18n): wrap hard-coded English strings in t() across UI (#6566)
## Summary
Audit + bulk fix of hard-coded English UI strings - `aria-label`,
`title`, `placeholder`, `label`, and raw JSX literals that bypassed i18n
entirely. Each literal now goes through `t("key", "English Default")`
from `react-i18next`, and every new key has a corresponding entry in
`en-GB/translation.toml` so translators can pick it up.
## What this fixes
Strings were rendered untranslated in every non-EN locale because they
never went through `t()` at all (not just "value not translated yet").
Affects screen-reader labels, tooltips, form placeholders, empty/loading
states, plan card content, and the entire workflow ParticipantView.
## Coverage (~143 keys / 50 files)
- **Viewer chrome** - search bar (close, clear, prev/next, "of N"
results), link/signature/redaction actions, viewer error state, zoom
labels
- **Page editor** - undo/redo/rotate/delete toolbar tooltips, empty
state, bulk selection operator chip tooltips
- **Shared primitives** - Tooltip close, InfoBanner dismiss, TextInput
clear, Toast dismiss/toggle, UpdateModal close, EditableSecretField
edit, DropdownListWithFooter search, FileCard/FileDropdownMenu actions,
EmptyFilesState + AddFileCard upload
- **Tools** - Image upload + hint, ColorControl eyedropper, sign Use
Signature, CompressSettings, OCR loading, PageLayout
margin/border/row/col placeholders, FormFill switch + save + re-scan
- **Proprietary admin** - OverviewHeader signed-in line + logout,
AdminPremiumSection moved-features list (via `<Trans>`),
AdminPlanSection no-data alert, AdminAdvancedSection temp-dir
placeholders, AdminEndpointsSection multiselect placeholders,
AdminMailSection + AdminDatabaseSection password placeholders
- **Onboarding** - MFASetupSlide QR loading + auth code label,
SecurityCheckSlide role select + options
- **ParticipantView** - entire sign-document UI (~30 strings: loading,
error, badges, headings, cert-type Select, all input labels and
placeholders, action buttons, completion + expired alerts) - file
previously imported `useTranslation` but only used `t()` for cert
validation
- **planConstants.ts refactor** - replaced `PLAN_FEATURES` /
`PLAN_HIGHLIGHTS` const exports with `usePlanFeatures()` /
`usePlanHighlights()` hooks. Service layer (`licenseService.getPlans`)
updated to accept feature/highlight maps so it stays hook-free. Callers
(`usePlans`, `CheckoutContext`) resolve the hooks at the React boundary
- **Previously catalogued offenders** - `FileSidebarFileItem`
open/close-viewer aria-labels, `quickAccessBar/ActiveToolButton` "Back
to all tools" tooltip + aria, `AppConfigModal` close button
## Notes
- One small refactor in `usePageSelectionTips.ts` was needed to resolve
a TOML key-shape conflict: the existing scalar keys
`bulkSelection.operators.{and,not,comma}` needed to become tables to
hold the new `.title` subkeys for OperatorsSection's chip tooltips. The
existing descriptions moved to `[bulkSelection.operators.descriptions]`
and the three i18n key paths in usePageSelectionTips were updated to
match.
- Viewer sidebar close buttons
(Bookmark/Layer/Thumbnail/Attachment/Comments) were on the audit list
but are NOT on main - they're added by the unmerged PR #6552
(feat/viewer-sidebar-ux). Those particular strings will need wrapping
when that PR lands.
- TOML hook (`toml-sort-fix`) ran and re-sorted the translation file.
## Test plan
- [ ] `task frontend:typecheck` passes (core + proprietary + desktop
variants)
- [ ] `task frontend:lint` passes
- [ ] Switching language to Deutsch / Русский: previously-English
`aria-label`s + tooltips + placeholders + plan card bullets now render
translated (when the locale has values) or fall back to the English
default (when it doesn't)
- [ ] Plan page bullet points in EN render unchanged
- [ ] Sign-document flow (ParticipantView) renders unchanged in EN
This commit is contained in:
@@ -12,7 +12,6 @@ cancel = "Cancel"
|
|||||||
changedCredsMessage = "Credentials changed!"
|
changedCredsMessage = "Credentials changed!"
|
||||||
chooseFile = "Choose File"
|
chooseFile = "Choose File"
|
||||||
close = "Close"
|
close = "Close"
|
||||||
color = "Colour"
|
|
||||||
comingSoon = "Coming soon"
|
comingSoon = "Coming soon"
|
||||||
confirm = "Confirm"
|
confirm = "Confirm"
|
||||||
confirmClose = "Confirm Close"
|
confirmClose = "Confirm Close"
|
||||||
@@ -199,6 +198,10 @@ ssoDescription = "Two-factor authentication is managed by your identity provider
|
|||||||
ssoManaged = "Configure MFA through your identity provider."
|
ssoManaged = "Configure MFA through your identity provider."
|
||||||
title = "Two-factor authentication"
|
title = "Two-factor authentication"
|
||||||
|
|
||||||
|
[account.overview]
|
||||||
|
logOut = "Log out"
|
||||||
|
signedInAs = "Signed in as: {{email}}"
|
||||||
|
|
||||||
[add-page-numbers]
|
[add-page-numbers]
|
||||||
tags = "paginate,label,organize,index"
|
tags = "paginate,label,organize,index"
|
||||||
|
|
||||||
@@ -226,6 +229,9 @@ title = "What it does"
|
|||||||
[AddAttachmentsRequest.tooltip.header]
|
[AddAttachmentsRequest.tooltip.header]
|
||||||
title = "About Add Attachments"
|
title = "About Add Attachments"
|
||||||
|
|
||||||
|
[addFileCard]
|
||||||
|
upload = "Upload"
|
||||||
|
|
||||||
[addImage]
|
[addImage]
|
||||||
applySignatures = "Apply Images"
|
applySignatures = "Apply Images"
|
||||||
header = "Add images to PDFs"
|
header = "Add images to PDFs"
|
||||||
@@ -606,6 +612,7 @@ label = "Temp File Management"
|
|||||||
[admin.settings.advanced.tempFileManagement.baseTmpDir]
|
[admin.settings.advanced.tempFileManagement.baseTmpDir]
|
||||||
description = "Base directory for temporary files (leave empty for default: java.io.tmpdir/stirling-pdf)"
|
description = "Base directory for temporary files (leave empty for default: java.io.tmpdir/stirling-pdf)"
|
||||||
label = "Base Temp Directory"
|
label = "Base Temp Directory"
|
||||||
|
placeholder = "Default: java.io.tmpdir/stirling-pdf"
|
||||||
|
|
||||||
[admin.settings.advanced.tempFileManagement.cleanupIntervalMinutes]
|
[admin.settings.advanced.tempFileManagement.cleanupIntervalMinutes]
|
||||||
description = "How often to run cleanup (in minutes)"
|
description = "How often to run cleanup (in minutes)"
|
||||||
@@ -618,6 +625,7 @@ label = "Cleanup System Temp"
|
|||||||
[admin.settings.advanced.tempFileManagement.libreofficeDir]
|
[admin.settings.advanced.tempFileManagement.libreofficeDir]
|
||||||
description = "Directory for LibreOffice temp files (leave empty for default: baseTmpDir/libreoffice)"
|
description = "Directory for LibreOffice temp files (leave empty for default: baseTmpDir/libreoffice)"
|
||||||
label = "LibreOffice Temp Directory"
|
label = "LibreOffice Temp Directory"
|
||||||
|
placeholder = "Default: baseTmpDir/libreoffice"
|
||||||
|
|
||||||
[admin.settings.advanced.tempFileManagement.maxAgeHours]
|
[admin.settings.advanced.tempFileManagement.maxAgeHours]
|
||||||
description = "Maximum age in hours before temp files are cleaned up"
|
description = "Maximum age in hours before temp files are cleaned up"
|
||||||
@@ -634,6 +642,7 @@ label = "Startup Cleanup"
|
|||||||
[admin.settings.advanced.tempFileManagement.systemTempDir]
|
[admin.settings.advanced.tempFileManagement.systemTempDir]
|
||||||
description = "System temp directory to clean (only used if cleanupSystemTemp is enabled)"
|
description = "System temp directory to clean (only used if cleanupSystemTemp is enabled)"
|
||||||
label = "System Temp Directory"
|
label = "System Temp Directory"
|
||||||
|
placeholder = "System temp directory path"
|
||||||
|
|
||||||
[admin.settings.advanced.tessdataDir]
|
[admin.settings.advanced.tessdataDir]
|
||||||
description = "Path to the tessdata directory for OCR language files"
|
description = "Path to the tessdata directory for OCR language files"
|
||||||
@@ -839,6 +848,7 @@ label = "Database Name"
|
|||||||
[admin.settings.database.password]
|
[admin.settings.database.password]
|
||||||
description = "Database authentication password"
|
description = "Database authentication password"
|
||||||
label = "Password"
|
label = "Password"
|
||||||
|
placeholder = "Enter database password"
|
||||||
|
|
||||||
[admin.settings.database.port]
|
[admin.settings.database.port]
|
||||||
description = "Database server port (not used if custom URL is provided)"
|
description = "Database server port (not used if custom URL is provided)"
|
||||||
@@ -870,10 +880,12 @@ label = "Hide unavailable tools by default"
|
|||||||
[admin.settings.endpoints.groupsToRemove]
|
[admin.settings.endpoints.groupsToRemove]
|
||||||
description = "Select endpoint groups to disable"
|
description = "Select endpoint groups to disable"
|
||||||
label = "Disabled Endpoint Groups"
|
label = "Disabled Endpoint Groups"
|
||||||
|
placeholder = "Select groups to disable"
|
||||||
|
|
||||||
[admin.settings.endpoints.toRemove]
|
[admin.settings.endpoints.toRemove]
|
||||||
description = "Select individual endpoints to disable"
|
description = "Select individual endpoints to disable"
|
||||||
label = "Disabled Endpoints"
|
label = "Disabled Endpoints"
|
||||||
|
placeholder = "Select endpoints to disable"
|
||||||
|
|
||||||
[admin.settings.enterpriseRequired]
|
[admin.settings.enterpriseRequired]
|
||||||
message = "An Enterprise license is required to access {{featureName}}. You are viewing demo data for reference."
|
message = "An Enterprise license is required to access {{featureName}}. You are viewing demo data for reference."
|
||||||
@@ -1088,6 +1100,7 @@ label = "SMTP Host"
|
|||||||
[admin.settings.mail.password]
|
[admin.settings.mail.password]
|
||||||
description = "Password for SMTP authentication"
|
description = "Password for SMTP authentication"
|
||||||
label = "SMTP Password"
|
label = "SMTP Password"
|
||||||
|
placeholder = "Enter SMTP password"
|
||||||
|
|
||||||
[admin.settings.mail.port]
|
[admin.settings.mail.port]
|
||||||
description = "The port number for SMTP connection (typically 25, 465, or 587)"
|
description = "The port number for SMTP connection (typically 25, 465, or 587)"
|
||||||
@@ -1097,6 +1110,10 @@ label = "SMTP Port"
|
|||||||
description = "Username for SMTP authentication"
|
description = "Username for SMTP authentication"
|
||||||
label = "SMTP Username"
|
label = "SMTP Username"
|
||||||
|
|
||||||
|
[admin.settings.plan.noData]
|
||||||
|
message = "Plans data is not available at the moment."
|
||||||
|
title = "No data available"
|
||||||
|
|
||||||
[admin.settings.premium]
|
[admin.settings.premium]
|
||||||
description = "Configure your premium or enterprise license key."
|
description = "Configure your premium or enterprise license key."
|
||||||
license = "License Configuration"
|
license = "License Configuration"
|
||||||
@@ -1143,6 +1160,9 @@ info = "If you have a license key or certificate file from a direct purchase, yo
|
|||||||
toggle = "Got a license key or certificate file?"
|
toggle = "Got a license key or certificate file?"
|
||||||
|
|
||||||
[admin.settings.premium.movedFeatures]
|
[admin.settings.premium.movedFeatures]
|
||||||
|
auditLogging = "<0>Audit Logging</0> (ENTERPRISE) - Security"
|
||||||
|
customMetadata = "<0>Custom Metadata</0> (PRO) - General"
|
||||||
|
databaseConfiguration = "<0>Database Configuration</0> (ENTERPRISE) - Database"
|
||||||
message = "Premium and Enterprise features are now organized in their respective sections:"
|
message = "Premium and Enterprise features are now organized in their respective sections:"
|
||||||
title = "Premium Features Distributed"
|
title = "Premium Features Distributed"
|
||||||
|
|
||||||
@@ -1526,10 +1546,10 @@ section_title = "Agents"
|
|||||||
show_less = "Show less"
|
show_less = "Show less"
|
||||||
start_chat = "Start chatting"
|
start_chat = "Start chatting"
|
||||||
stirling_description = "Your general-purpose PDF assistant"
|
stirling_description = "Your general-purpose PDF assistant"
|
||||||
stirling_running = "Running..."
|
|
||||||
stirling_full_name = "Stirling General Agent"
|
stirling_full_name = "Stirling General Agent"
|
||||||
stirling_long_description = "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents."
|
stirling_long_description = "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents."
|
||||||
stirling_name = "Stirling"
|
stirling_name = "Stirling"
|
||||||
|
stirling_running = "Running..."
|
||||||
stirling_tooltip = "Stirling agent"
|
stirling_tooltip = "Stirling agent"
|
||||||
view_all = "View all agents"
|
view_all = "View all agents"
|
||||||
|
|
||||||
@@ -2126,11 +2146,31 @@ placeholder = "Number of pages"
|
|||||||
title = "Last N Pages"
|
title = "Last N Pages"
|
||||||
|
|
||||||
[bulkSelection.operators]
|
[bulkSelection.operators]
|
||||||
|
text = "AND has higher precedence than comma. NOT applies within the document range."
|
||||||
|
title = "Operators"
|
||||||
|
|
||||||
|
[bulkSelection.operators.and]
|
||||||
|
title = "Combine selections (both conditions must be true)"
|
||||||
|
|
||||||
|
[bulkSelection.operators.descriptions]
|
||||||
and = "AND: & or \"and\" - require both conditions (e.g., 1-50 & even)"
|
and = "AND: & or \"and\" - require both conditions (e.g., 1-50 & even)"
|
||||||
comma = "Comma: , or | - combine selections (e.g., 1-10, 20)"
|
comma = "Comma: , or | - combine selections (e.g., 1-10, 20)"
|
||||||
not = "NOT: ! or \"not\" - exclude pages (e.g., 3n & not 30)"
|
not = "NOT: ! or \"not\" - exclude pages (e.g., 3n & not 30)"
|
||||||
text = "AND has higher precedence than comma. NOT applies within the document range."
|
|
||||||
title = "Operators"
|
[bulkSelection.operators.even]
|
||||||
|
title = "Select all even-numbered pages (2, 4, 6, 8...)"
|
||||||
|
|
||||||
|
[bulkSelection.operators.not]
|
||||||
|
title = "Exclude from selection"
|
||||||
|
|
||||||
|
[bulkSelection.operators.odd]
|
||||||
|
title = "Select all odd-numbered pages (1, 3, 5, 7...)"
|
||||||
|
|
||||||
|
[bulkSelection.operators.or]
|
||||||
|
title = "Add to selection (either condition can be true)"
|
||||||
|
|
||||||
|
[bulkSelection.pageSelection]
|
||||||
|
title = "Page Selection"
|
||||||
|
|
||||||
[bulkSelection.range]
|
[bulkSelection.range]
|
||||||
fromPlaceholder = "From"
|
fromPlaceholder = "From"
|
||||||
@@ -2782,6 +2822,9 @@ unknownTool = "Unknown tool"
|
|||||||
[cloudBadge]
|
[cloudBadge]
|
||||||
tooltip = "This operation will use your cloud credits"
|
tooltip = "This operation will use your cloud credits"
|
||||||
|
|
||||||
|
[color.eyeDropper]
|
||||||
|
tooltip = "Pick colour from screen"
|
||||||
|
|
||||||
[colorPicker]
|
[colorPicker]
|
||||||
title = "Choose colour"
|
title = "Choose colour"
|
||||||
|
|
||||||
@@ -3006,6 +3049,10 @@ title = "Compression Method"
|
|||||||
1 = "1-3 PDF compression,</br> 4-6 lite image compression,</br> 7-9 intense image compression Will dramatically reduce image quality"
|
1 = "1-3 PDF compression,</br> 4-6 lite image compression,</br> 7-9 intense image compression Will dramatically reduce image quality"
|
||||||
_value = "Compression Settings"
|
_value = "Compression Settings"
|
||||||
|
|
||||||
|
[compress.settings]
|
||||||
|
desiredSize = "Desired File Size"
|
||||||
|
desiredSizePlaceholder = "Enter size"
|
||||||
|
|
||||||
[compress.tooltip.description]
|
[compress.tooltip.description]
|
||||||
text = "Compression is an easy way to reduce your file size. Pick File Size to enter a target size and have us adjust quality for you. Pick Quality to set compression strength manually."
|
text = "Compression is an easy way to reduce your file size. Pick File Size to enter a target size and have us adjust quality for you. Pick Quality to set compression strength manually."
|
||||||
title = "Description"
|
title = "Description"
|
||||||
@@ -3496,6 +3543,13 @@ docsLink = "View installation documentation"
|
|||||||
message = "Stirling-PDF does not have permission to update itself on this machine."
|
message = "Stirling-PDF does not have permission to update itself on this machine."
|
||||||
title = "Administrator permissions required"
|
title = "Administrator permissions required"
|
||||||
|
|
||||||
|
[dropdownList]
|
||||||
|
searchPlaceholder = "Search..."
|
||||||
|
|
||||||
|
[editableSecretField]
|
||||||
|
edit = "Edit"
|
||||||
|
editSecretValue = "Edit secret value"
|
||||||
|
|
||||||
[editTableOfContents]
|
[editTableOfContents]
|
||||||
submit = "Apply table of contents"
|
submit = "Apply table of contents"
|
||||||
|
|
||||||
@@ -3583,6 +3637,9 @@ tabTitle = "Outline workspace"
|
|||||||
description = "Select the Edit Table of Contents tool to load its workspace."
|
description = "Select the Edit Table of Contents tool to load its workspace."
|
||||||
title = "Open the tool to start editing"
|
title = "Open the tool to start editing"
|
||||||
|
|
||||||
|
[emptyFilesState]
|
||||||
|
upload = "Upload"
|
||||||
|
|
||||||
[encryptedPdfUnlock]
|
[encryptedPdfUnlock]
|
||||||
description = "This PDF is password protected. Enter the password so you can continue working with it."
|
description = "This PDF is password protected. Enter the password so you can continue working with it."
|
||||||
emptyResponse = "Password removal did not produce a file."
|
emptyResponse = "Password removal did not produce a file."
|
||||||
@@ -3697,6 +3754,10 @@ title = "Settings"
|
|||||||
[extractPages.tooltip]
|
[extractPages.tooltip]
|
||||||
description = "Extracts the selected pages into a new PDF, preserving order."
|
description = "Extracts the selected pages into a new PDF, preserving order."
|
||||||
|
|
||||||
|
[fileCard]
|
||||||
|
openInFileEditor = "Open in File Editor"
|
||||||
|
viewInViewer = "View in Viewer"
|
||||||
|
|
||||||
[fileChooser]
|
[fileChooser]
|
||||||
click = "Click"
|
click = "Click"
|
||||||
dragAndDrop = "Drag & Drop"
|
dragAndDrop = "Drag & Drop"
|
||||||
@@ -3706,6 +3767,9 @@ extractPDF = "Extracting..."
|
|||||||
hoveredDragAndDrop = "Drag & Drop file(s) here"
|
hoveredDragAndDrop = "Drag & Drop file(s) here"
|
||||||
or = "or"
|
or = "or"
|
||||||
|
|
||||||
|
[fileDropdownMenu]
|
||||||
|
closeFile = "Close file"
|
||||||
|
|
||||||
[fileEditor]
|
[fileEditor]
|
||||||
addFiles = "Add Files"
|
addFiles = "Add Files"
|
||||||
pageCount = "{{count}} pages"
|
pageCount = "{{count}} pages"
|
||||||
@@ -3872,6 +3936,10 @@ openSettings = "Open settings"
|
|||||||
search = "Search"
|
search = "Search"
|
||||||
searchPlaceholder = "Search files..."
|
searchPlaceholder = "Search files..."
|
||||||
|
|
||||||
|
[fileSidebar.fileItem]
|
||||||
|
closeViewer = "Close viewer"
|
||||||
|
openInViewer = "Open in viewer"
|
||||||
|
|
||||||
[filesPage]
|
[filesPage]
|
||||||
addToWorkspace = "Add to workspace"
|
addToWorkspace = "Add to workspace"
|
||||||
addToWorkspaceCount = "Add {{count}} to workspace"
|
addToWorkspaceCount = "Add {{count}} to workspace"
|
||||||
@@ -4178,6 +4246,15 @@ title = "About Flattening PDFs"
|
|||||||
discord = "Discord"
|
discord = "Discord"
|
||||||
issues = "GitHub"
|
issues = "GitHub"
|
||||||
|
|
||||||
|
[formFill]
|
||||||
|
flattenAfterFilling = "Flatten after filling"
|
||||||
|
rescanFields = "Re-scan fields"
|
||||||
|
rescanFormFields = "Re-scan form fields"
|
||||||
|
save = "Save"
|
||||||
|
|
||||||
|
[formFill.sidebar]
|
||||||
|
close = "Close sidebar"
|
||||||
|
|
||||||
[getPdfInfo]
|
[getPdfInfo]
|
||||||
downloadJson = "Download JSON"
|
downloadJson = "Download JSON"
|
||||||
downloads = "Downloads"
|
downloads = "Downloads"
|
||||||
@@ -4737,6 +4814,11 @@ tags = "markup,web-content,transformation,convert"
|
|||||||
title = "HTML To PDF"
|
title = "HTML To PDF"
|
||||||
zoom = "Zoom level for displaying the website."
|
zoom = "Zoom level for displaying the website."
|
||||||
|
|
||||||
|
[image.upload]
|
||||||
|
hint = "Upload a PNG, JPG, SVG, or other image file to place on the PDF. SVG files will be converted to PNG for compatibility."
|
||||||
|
label = "Upload Image"
|
||||||
|
placeholder = "Select image file"
|
||||||
|
|
||||||
[imageToPdf]
|
[imageToPdf]
|
||||||
tags = "conversion,img,jpg,picture,photo"
|
tags = "conversion,img,jpg,picture,photo"
|
||||||
|
|
||||||
@@ -4755,6 +4837,9 @@ title = "Image to PDF"
|
|||||||
4 = "Merge into single PDF"
|
4 = "Merge into single PDF"
|
||||||
5 = "Convert to separate PDFs"
|
5 = "Convert to separate PDFs"
|
||||||
|
|
||||||
|
[infoBanner]
|
||||||
|
dismiss = "Dismiss"
|
||||||
|
|
||||||
[invite]
|
[invite]
|
||||||
acceptError = "Failed to create account"
|
acceptError = "Failed to create account"
|
||||||
accountFor = "Creating account for"
|
accountFor = "Creating account for"
|
||||||
@@ -5090,6 +5175,7 @@ failed = "OCR operation failed"
|
|||||||
|
|
||||||
[ocr.languagePicker]
|
[ocr.languagePicker]
|
||||||
additionalLanguages = "Looking for additional languages?"
|
additionalLanguages = "Looking for additional languages?"
|
||||||
|
loading = "Loading available languages..."
|
||||||
viewSetupGuide = "View setup guide →"
|
viewSetupGuide = "View setup guide →"
|
||||||
|
|
||||||
[ocr.operation]
|
[ocr.operation]
|
||||||
@@ -5226,6 +5312,10 @@ daysRemainingSingular = "{{days}} day remaining"
|
|||||||
title = "Your 30-Day Pro Trial"
|
title = "Your 30-Day Pro Trial"
|
||||||
trialEnds = "Trial ends {{date}}"
|
trialEnds = "Trial ends {{date}}"
|
||||||
|
|
||||||
|
[onboarding.mfa]
|
||||||
|
authenticationCode = "Authentication code"
|
||||||
|
qrCodeLoading = "Generating your QR code…"
|
||||||
|
|
||||||
[onboarding.planOverview]
|
[onboarding.planOverview]
|
||||||
adminBodyLoginDisabled = "Once you enable login mode, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge."
|
adminBodyLoginDisabled = "Once you enable login mode, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge."
|
||||||
adminBodyLoginEnabled = "As an admin, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge."
|
adminBodyLoginEnabled = "As an admin, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge."
|
||||||
@@ -5235,6 +5325,9 @@ userTitle = "Plan Overview"
|
|||||||
|
|
||||||
[onboarding.securityCheck]
|
[onboarding.securityCheck]
|
||||||
message = "The application has undergone significant changes recently. Your server admin's attention may be required. Please confirm your role to continue."
|
message = "The application has undergone significant changes recently. Your server admin's attention may be required. Please confirm your role to continue."
|
||||||
|
roleAdmin = "Admin"
|
||||||
|
rolePlaceholder = "Confirm your role"
|
||||||
|
roleUser = "User"
|
||||||
|
|
||||||
[onboarding.serverLicense]
|
[onboarding.serverLicense]
|
||||||
freeBody = "Our <strong>Open-Core</strong> licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. To scale uninterrupted, we recommend the Stirling Server plan - <strong>unlimited seats</strong> and <strong>SSO support</strong> for $99/server/mo."
|
freeBody = "Our <strong>Open-Core</strong> licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. To scale uninterrupted, we recommend the Stirling Server plan - <strong>unlimited seats</strong> and <strong>SSO support</strong> for $99/server/mo."
|
||||||
@@ -5360,6 +5453,17 @@ title = "Page Editor"
|
|||||||
zoomIn = "Zoom In"
|
zoomIn = "Zoom In"
|
||||||
zoomOut = "Zoom Out"
|
zoomOut = "Zoom Out"
|
||||||
|
|
||||||
|
[pageEditor.emptyState]
|
||||||
|
body = "Add files to start editing pages"
|
||||||
|
title = "No PDF files loaded"
|
||||||
|
|
||||||
|
[pageEditor.toolbar]
|
||||||
|
delete = "Delete Selected"
|
||||||
|
redo = "Redo"
|
||||||
|
rotateLeft = "Rotate Selected Left"
|
||||||
|
rotateRight = "Rotate Selected Right"
|
||||||
|
undo = "Undo"
|
||||||
|
|
||||||
[pageExtracter]
|
[pageExtracter]
|
||||||
header = "Extract Pages"
|
header = "Extract Pages"
|
||||||
placeholder = "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)"
|
placeholder = "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)"
|
||||||
@@ -5371,12 +5475,14 @@ addBorder = "Add Borders"
|
|||||||
borderWidth = "Border Thickness"
|
borderWidth = "Border Thickness"
|
||||||
bottom = "Bottom Margin"
|
bottom = "Bottom Margin"
|
||||||
cols = "Columns"
|
cols = "Columns"
|
||||||
|
colsPlaceholder = "Enter columns"
|
||||||
header = "Multi Page Layout"
|
header = "Multi Page Layout"
|
||||||
innerMargin = "Inner Margin"
|
innerMargin = "Inner Margin"
|
||||||
left = "Left Margin"
|
left = "Left Margin"
|
||||||
pagesPerSheet = "Pages per sheet:"
|
pagesPerSheet = "Pages per sheet:"
|
||||||
right = "Right Margin"
|
right = "Right Margin"
|
||||||
rows = "Rows"
|
rows = "Rows"
|
||||||
|
rowsPlaceholder = "Enter rows"
|
||||||
submit = "Submit"
|
submit = "Submit"
|
||||||
tags = "merge,composite,single-view,organize"
|
tags = "merge,composite,single-view,organize"
|
||||||
title = "Multi Page Layout"
|
title = "Multi Page Layout"
|
||||||
@@ -5418,6 +5524,14 @@ innerMarginTooLarge = "Inner margin is too large for the selected layout."
|
|||||||
outerHorizontalMarginsTooLarge = "Left/Right margins are too large for this page size."
|
outerHorizontalMarginsTooLarge = "Left/Right margins are too large for this page size."
|
||||||
outerVerticalMarginsTooLarge = "Top/Bottom margins are too large for this page size."
|
outerVerticalMarginsTooLarge = "Top/Bottom margins are too large for this page size."
|
||||||
|
|
||||||
|
[pageLayout.margins]
|
||||||
|
borderThicknessPlaceholder = "Enter border thickness"
|
||||||
|
bottomPlaceholder = "Enter bottom margin"
|
||||||
|
innerPlaceholder = "Enter inner margin"
|
||||||
|
leftPlaceholder = "Enter left margin"
|
||||||
|
rightPlaceholder = "Enter right margin"
|
||||||
|
topPlaceholder = "Enter top margin"
|
||||||
|
|
||||||
[pageLayout.marginsBorders.tooltip.borders]
|
[pageLayout.marginsBorders.tooltip.borders]
|
||||||
text = "Enable borders to draw lines around each placed page. This can help visual separation or trimming."
|
text = "Enable borders to draw lines around each placed page. This can help visual separation or trimming."
|
||||||
title = "Add Borders"
|
title = "Add Borders"
|
||||||
@@ -6071,6 +6185,26 @@ pdfTools = "Basic PDF Tools"
|
|||||||
priority = "Priority Support"
|
priority = "Priority Support"
|
||||||
title = "Feature"
|
title = "Feature"
|
||||||
|
|
||||||
|
[plan.features]
|
||||||
|
allPdfOperations = "All PDF operations"
|
||||||
|
auditing = "Auditing"
|
||||||
|
communitySupport = "Community support"
|
||||||
|
customPdfMetadata = "Custom PDF metadata"
|
||||||
|
editingTextInPdfs = "Editing text in pdfs"
|
||||||
|
externalDatabase = "External Database"
|
||||||
|
googleDriveIntegration = "Google drive integration"
|
||||||
|
prometheusSupport = "Prometheus Support"
|
||||||
|
regularUpdates = "Regular updates"
|
||||||
|
saml = "SAML"
|
||||||
|
secureLoginSupport = "Secure Login Support"
|
||||||
|
selfHostedDeployment = "Self-hosted deployment"
|
||||||
|
sso = "SSO"
|
||||||
|
unlimitedUsers = "Unlimited users"
|
||||||
|
upToFiveUsers = "Up to 5 users"
|
||||||
|
upToFiveUsersLowercase = "up to 5 users"
|
||||||
|
usageTracking = "Usage tracking"
|
||||||
|
usersLimitedToSeats = "Users limited to seats"
|
||||||
|
|
||||||
[plan.free]
|
[plan.free]
|
||||||
forever = "Forever free"
|
forever = "Forever free"
|
||||||
highlight1 = "Limited Tool Usage Per week"
|
highlight1 = "Limited Tool Usage Per week"
|
||||||
@@ -6079,6 +6213,22 @@ highlight3 = "Community support"
|
|||||||
included = "Included"
|
included = "Included"
|
||||||
name = "Free"
|
name = "Free"
|
||||||
|
|
||||||
|
[plan.highlights]
|
||||||
|
advancedIntegrations = "Advanced integrations"
|
||||||
|
allBasicFeatures = "All basic features"
|
||||||
|
cancelAnytime = "Cancel anytime"
|
||||||
|
customPdfMetadata = "Custom PDF metadata"
|
||||||
|
editingTextInPdfsCaps = "Editing text in PDFs"
|
||||||
|
enterpriseFeatures = "Enterprise features (SAML, Auditing)"
|
||||||
|
perSeatLicensing = "Per-seat licensing"
|
||||||
|
saveWithAnnualBilling = "Save with annual billing"
|
||||||
|
selfHosted = "Self-hosted"
|
||||||
|
selfHostedOnInfrastructure = "Self-hosted on your infrastructure"
|
||||||
|
ssoOAuth = "SSO (OAuth2/OIDC)"
|
||||||
|
unlimitedUsers = "Unlimited users"
|
||||||
|
upToFiveUsers = "Up to 5 users"
|
||||||
|
usageTrackingPrometheus = "Usage tracking & Prometheus"
|
||||||
|
|
||||||
[plan.licenseWarning]
|
[plan.licenseWarning]
|
||||||
body = "You have {{total}} users but the free tier only supports {{limit}} per server. Upgrade to keep Stirling PDF running smoothly."
|
body = "You have {{total}} users but the free tier only supports {{limit}} per server. Upgrade to keep Stirling PDF running smoothly."
|
||||||
cta = "See plans"
|
cta = "See plans"
|
||||||
@@ -6389,6 +6539,7 @@ allSessions = "All Sessions"
|
|||||||
allTools = "Tools"
|
allTools = "Tools"
|
||||||
automate = "Automate"
|
automate = "Automate"
|
||||||
back = "Back"
|
back = "Back"
|
||||||
|
backToAllTools = "Back to all tools"
|
||||||
certSign = "Certificate Sign"
|
certSign = "Certificate Sign"
|
||||||
completedSessions = "Completed Sessions"
|
completedSessions = "Completed Sessions"
|
||||||
completedTab = "Completed"
|
completedTab = "Completed"
|
||||||
@@ -7060,6 +7211,9 @@ bullet3 = "Critical for approval workflows and legal chains of custody"
|
|||||||
description = "The order you specify when creating the session determines who signs first."
|
description = "The order you specify when creating the session determines who signs first."
|
||||||
title = "Signature Order"
|
title = "Signature Order"
|
||||||
|
|
||||||
|
[settings]
|
||||||
|
close = "Close"
|
||||||
|
|
||||||
[settings.configuration]
|
[settings.configuration]
|
||||||
advanced = "Advanced"
|
advanced = "Advanced"
|
||||||
database = "Database"
|
database = "Database"
|
||||||
@@ -7542,6 +7696,7 @@ sharedDescription = "All users can see and use these signatures."
|
|||||||
sharedHeading = "Shared Signatures"
|
sharedHeading = "Shared Signatures"
|
||||||
tempStorageDescription = "Signatures are stored in your browser only. They will be lost if you clear browser data or switch browsers."
|
tempStorageDescription = "Signatures are stored in your browser only. They will be lost if you clear browser data or switch browsers."
|
||||||
tempStorageTitle = "Temporary browser storage"
|
tempStorageTitle = "Temporary browser storage"
|
||||||
|
use = "Use signature"
|
||||||
|
|
||||||
[sign.saved.status]
|
[sign.saved.status]
|
||||||
saved = "Saved"
|
saved = "Saved"
|
||||||
@@ -8122,9 +8277,18 @@ center = "Center"
|
|||||||
left = "Left"
|
left = "Left"
|
||||||
right = "Right"
|
right = "Right"
|
||||||
|
|
||||||
|
[textInput]
|
||||||
|
clear = "Clear input"
|
||||||
|
|
||||||
[theme]
|
[theme]
|
||||||
toggle = "Toggle Theme"
|
toggle = "Toggle Theme"
|
||||||
|
|
||||||
|
[time.relative]
|
||||||
|
daysAgo = "{{count}}d ago"
|
||||||
|
hoursAgo = "{{count}}h ago"
|
||||||
|
justNow = "just now"
|
||||||
|
minutesAgo = "{{count}}m ago"
|
||||||
|
|
||||||
[timestampPdf]
|
[timestampPdf]
|
||||||
completed = "PDF timestamped successfully"
|
completed = "PDF timestamped successfully"
|
||||||
desc = "Add an RFC 3161 document timestamp to your PDF using a trusted Time Stamp Authority (TSA) server."
|
desc = "Add an RFC 3161 document timestamp to your PDF using a trusted Time Stamp Authority (TSA) server."
|
||||||
@@ -8151,11 +8315,9 @@ label = "Select a TSA server"
|
|||||||
[timestampPdf.steps]
|
[timestampPdf.steps]
|
||||||
settings = "Settings"
|
settings = "Settings"
|
||||||
|
|
||||||
[time.relative]
|
[toast]
|
||||||
daysAgo = "{{count}}d ago"
|
dismiss = "Dismiss"
|
||||||
hoursAgo = "{{count}}h ago"
|
toggleDetails = "Toggle details"
|
||||||
justNow = "just now"
|
|
||||||
minutesAgo = "{{count}}m ago"
|
|
||||||
|
|
||||||
[tool]
|
[tool]
|
||||||
endpointUnavailable = "This tool is unavailable on your server."
|
endpointUnavailable = "This tool is unavailable on your server."
|
||||||
@@ -8243,6 +8405,9 @@ verification = "Verification"
|
|||||||
noSearchResults = "No tools found"
|
noSearchResults = "No tools found"
|
||||||
noTools = "No tools available"
|
noTools = "No tools available"
|
||||||
|
|
||||||
|
[tooltip]
|
||||||
|
close = "Close tooltip"
|
||||||
|
|
||||||
[unlockPDFForms]
|
[unlockPDFForms]
|
||||||
description = "This tool will remove read-only restrictions from PDF form fields, making them editable and fillable."
|
description = "This tool will remove read-only restrictions from PDF form fields, making them editable and fillable."
|
||||||
filenamePrefix = "unlocked_forms"
|
filenamePrefix = "unlocked_forms"
|
||||||
@@ -8269,6 +8434,7 @@ breakingChangesDefault = "This version contains breaking changes."
|
|||||||
breakingChangesDetected = "Breaking Changes Detected"
|
breakingChangesDetected = "Breaking Changes Detected"
|
||||||
breakingChangesMessage = "Some versions contain breaking changes. Please review the migration guides below before updating."
|
breakingChangesMessage = "Some versions contain breaking changes. Please review the migration guides below before updating."
|
||||||
close = "Close"
|
close = "Close"
|
||||||
|
closeModal = "Close update modal"
|
||||||
current = "Current Version"
|
current = "Current Version"
|
||||||
defaultRecommendation = "This update contains important fixes and improvements."
|
defaultRecommendation = "This update contains important fixes and improvements."
|
||||||
downloadLatest = "Download Latest"
|
downloadLatest = "Download Latest"
|
||||||
@@ -8520,6 +8686,9 @@ typeInsertText = "Insert Text"
|
|||||||
typeReplaceText = "Replace Text"
|
typeReplaceText = "Replace Text"
|
||||||
viewComment = "View comment"
|
viewComment = "View comment"
|
||||||
|
|
||||||
|
[viewer.error]
|
||||||
|
noFileProvided = "Error: No file provided to viewer"
|
||||||
|
|
||||||
[viewer.formBar]
|
[viewer.formBar]
|
||||||
apply = "Apply Changes"
|
apply = "Apply Changes"
|
||||||
dismiss = "Dismiss"
|
dismiss = "Dismiss"
|
||||||
@@ -8529,6 +8698,9 @@ title = "Form Fields"
|
|||||||
unsavedBadge = "Unsaved"
|
unsavedBadge = "Unsaved"
|
||||||
unsavedDesc = "You have unsaved changes"
|
unsavedDesc = "You have unsaved changes"
|
||||||
|
|
||||||
|
[viewer.link]
|
||||||
|
delete = "Delete link"
|
||||||
|
|
||||||
[viewer.nonPdf]
|
[viewer.nonPdf]
|
||||||
columnDefault = "Column {{index}}"
|
columnDefault = "Column {{index}}"
|
||||||
convertToPdf = "Convert to PDF"
|
convertToPdf = "Convert to PDF"
|
||||||
@@ -8544,6 +8716,19 @@ renderMarkdown = "Render markdown"
|
|||||||
sortedBy = "Sorted by: {{column}}"
|
sortedBy = "Sorted by: {{column}}"
|
||||||
textStats = "{{lines}} lines · {{size}}"
|
textStats = "{{lines}} lines · {{size}}"
|
||||||
|
|
||||||
|
[viewer.redaction]
|
||||||
|
removeMark = "Remove this mark"
|
||||||
|
|
||||||
|
[viewer.search]
|
||||||
|
clear = "Clear search"
|
||||||
|
close = "Close search"
|
||||||
|
next = "Next result"
|
||||||
|
previous = "Previous result"
|
||||||
|
resultsOf = "of {{total}}"
|
||||||
|
|
||||||
|
[viewer.signature]
|
||||||
|
delete = "Delete signature"
|
||||||
|
|
||||||
[viewPdf]
|
[viewPdf]
|
||||||
header = "View PDF"
|
header = "View PDF"
|
||||||
tags = "view,read,annotate,text,image,highlight,edit"
|
tags = "view,read,annotate,text,image,highlight,edit"
|
||||||
@@ -8792,6 +8977,37 @@ toggleSidebar = "Toggle Sidebar"
|
|||||||
toggleTheme = "Toggle Theme"
|
toggleTheme = "Toggle Theme"
|
||||||
viewer = "Viewer"
|
viewer = "Viewer"
|
||||||
|
|
||||||
|
[workflow.participant]
|
||||||
|
accessExpired = "Your access to this document has expired."
|
||||||
|
certificateFile = "Certificate File"
|
||||||
|
certificateFilePlaceholder = "Select certificate file"
|
||||||
|
certificatePassword = "Certificate Password"
|
||||||
|
certificateType = "Certificate Type"
|
||||||
|
certTypeJks = "JKS Keystore"
|
||||||
|
certTypeP12 = "P12/PKCS12 Certificate"
|
||||||
|
certTypeServer = "Server Certificate (if available)"
|
||||||
|
completedDeclined = "You have declined this document."
|
||||||
|
completedSigned = "You have signed this document."
|
||||||
|
decline = "Decline"
|
||||||
|
downloadDocument = "Download Document"
|
||||||
|
dueDate = "Due Date: {{date}}"
|
||||||
|
errorTitle = "Error"
|
||||||
|
from = "From: {{name}}"
|
||||||
|
loadingSession = "Loading session..."
|
||||||
|
location = "Location"
|
||||||
|
locationPlaceholder = "e.g., San Francisco, CA"
|
||||||
|
pageNumber = "Page Number (optional)"
|
||||||
|
reason = "Reason"
|
||||||
|
reasonPlaceholder = "e.g., Document approval"
|
||||||
|
sessionNotFound = "Session not found or access denied."
|
||||||
|
signDocument = "Sign Document"
|
||||||
|
statusDeclined = "Declined"
|
||||||
|
statusNotified = "Notified"
|
||||||
|
statusPending = "Pending"
|
||||||
|
statusSigned = "Signed"
|
||||||
|
statusViewed = "Viewed"
|
||||||
|
submitSignature = "Submit Signature"
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
title = "Workspace"
|
title = "Workspace"
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import ColorizeIcon from "@mui/icons-material/Colorize";
|
import ColorizeIcon from "@mui/icons-material/Colorize";
|
||||||
|
|
||||||
// safari and firefox do not support the eye dropper API, only edge, chrome and opera do.
|
// safari and firefox do not support the eye dropper API, only edge, chrome and opera do.
|
||||||
@@ -33,6 +34,7 @@ export function ColorControl({
|
|||||||
label,
|
label,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: ColorControlProps) {
|
}: ColorControlProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [opened, setOpened] = useState(false);
|
const [opened, setOpened] = useState(false);
|
||||||
// Buffer the colour locally so the picker stays responsive during drag.
|
// Buffer the colour locally so the picker stays responsive during drag.
|
||||||
// Only propagate to the parent (which triggers expensive annotation updates)
|
// Only propagate to the parent (which triggers expensive annotation updates)
|
||||||
@@ -111,7 +113,9 @@ export function ColorControl({
|
|||||||
/>
|
/>
|
||||||
{supportsEyeDropper && (
|
{supportsEyeDropper && (
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Tooltip label="Pick colour from screen">
|
<Tooltip
|
||||||
|
label={t("color.eyeDropper.tooltip", "Pick colour from screen")}
|
||||||
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Stack } from "@mantine/core";
|
import { Stack } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { BaseAnnotationTool } from "@app/components/annotation/shared/BaseAnnotationTool";
|
import { BaseAnnotationTool } from "@app/components/annotation/shared/BaseAnnotationTool";
|
||||||
import { ImageUploader } from "@app/components/annotation/shared/ImageUploader";
|
import { ImageUploader } from "@app/components/annotation/shared/ImageUploader";
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ export const ImageTool: React.FC<ImageToolProps> = ({
|
|||||||
onImageChange,
|
onImageChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [, setImageData] = useState<string | null>(null);
|
const [, setImageData] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleImageUpload = async (file: File | null) => {
|
const handleImageUpload = async (file: File | null) => {
|
||||||
@@ -57,9 +59,12 @@ export const ImageTool: React.FC<ImageToolProps> = ({
|
|||||||
<ImageUploader
|
<ImageUploader
|
||||||
onImageChange={handleImageUpload}
|
onImageChange={handleImageUpload}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="Upload Image"
|
label={t("image.upload.label", "Upload Image")}
|
||||||
placeholder="Select image file"
|
placeholder={t("image.upload.placeholder", "Select image file")}
|
||||||
hint="Upload a PNG, JPG, SVG, or other image file to place on the PDF. SVG files will be converted to PNG for compatibility."
|
hint={t(
|
||||||
|
"image.upload.hint",
|
||||||
|
"Upload a PNG, JPG, SVG, or other image file to place on the PDF. SVG files will be converted to PNG for compatibility.",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</BaseAnnotationTool>
|
</BaseAnnotationTool>
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ const AddFileCard = ({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
aria-label="Upload"
|
aria-label={t("addFileCard.upload", "Upload")}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--landing-button-bg)",
|
backgroundColor: "var(--landing-button-bg)",
|
||||||
color: "var(--landing-button-color)",
|
color: "var(--landing-button-color)",
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ const EmptyFilesState: React.FC = () => {
|
|||||||
onMouseLeave={() => setIsUploadHover(false)}
|
onMouseLeave={() => setIsUploadHover(false)}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
aria-label="Upload"
|
aria-label={t("emptyFilesState.upload", "Upload")}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--bg-file-manager)",
|
backgroundColor: "var(--bg-file-manager)",
|
||||||
color: "var(--landing-button-color)",
|
color: "var(--landing-button-color)",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { QRCodeSVG } from "qrcode.react";
|
import { QRCodeSVG } from "qrcode.react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { SlideConfig } from "@app/types/types";
|
import { SlideConfig } from "@app/types/types";
|
||||||
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||||
import { accountService } from "@app/services/accountService";
|
import { accountService } from "@app/services/accountService";
|
||||||
@@ -31,6 +32,7 @@ interface MFASetupSlideProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(
|
const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -179,7 +181,9 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
|||||||
{mfaLoading && !isReady && (
|
{mfaLoading && !isReady && (
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<Loader size="sm" />
|
<Loader size="sm" />
|
||||||
<Text size="sm">Generating your QR code…</Text>
|
<Text size="sm">
|
||||||
|
{t("onboarding.mfa.qrCodeLoading", "Generating your QR code…")}
|
||||||
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -189,7 +193,10 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
|||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<TextInput
|
<TextInput
|
||||||
id="mfa-setup-code"
|
id="mfa-setup-code"
|
||||||
label="Authentication code"
|
label={t(
|
||||||
|
"onboarding.mfa.authenticationCode",
|
||||||
|
"Authentication code",
|
||||||
|
)}
|
||||||
placeholder="123456"
|
placeholder="123456"
|
||||||
value={mfaSetupCode}
|
value={mfaSetupCode}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
|
|||||||
@@ -37,11 +37,20 @@ export default function SecurityCheckSlide({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
placeholder="Confirm your role"
|
placeholder={i18n.t(
|
||||||
|
"onboarding.securityCheck.rolePlaceholder",
|
||||||
|
"Confirm your role",
|
||||||
|
)}
|
||||||
value={selectedRole}
|
value={selectedRole}
|
||||||
data={[
|
data={[
|
||||||
{ value: "admin", label: "Admin" },
|
{
|
||||||
{ value: "user", label: "User" },
|
value: "admin",
|
||||||
|
label: i18n.t("onboarding.securityCheck.roleAdmin", "Admin"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "user",
|
||||||
|
label: i18n.t("onboarding.securityCheck.roleUser", "User"),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
onRoleSelect((value as "admin" | "user") ?? null)
|
onRoleSelect((value as "admin" | "user") ?? null)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||||
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
|
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useFileState, useFileActions } from "@app/contexts/FileContext";
|
import { useFileState, useFileActions } from "@app/contexts/FileContext";
|
||||||
import {
|
import {
|
||||||
useNavigationGuard,
|
useNavigationGuard,
|
||||||
@@ -33,6 +34,7 @@ export interface PageEditorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
// Use split contexts to prevent re-renders
|
// Use split contexts to prevent re-renders
|
||||||
const { state, selectors } = useFileState();
|
const { state, selectors } = useFileState();
|
||||||
const { actions } = useFileActions();
|
const { actions } = useFileActions();
|
||||||
@@ -688,9 +690,14 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
|||||||
<Text size="lg" c="dimmed">
|
<Text size="lg" c="dimmed">
|
||||||
📄
|
📄
|
||||||
</Text>
|
</Text>
|
||||||
<Text c="dimmed">No PDF files loaded</Text>
|
<Text c="dimmed">
|
||||||
|
{t("pageEditor.emptyState.title", "No PDF files loaded")}
|
||||||
|
</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Add files to start editing pages
|
{t(
|
||||||
|
"pageEditor.emptyState.body",
|
||||||
|
"Add files to start editing pages",
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import RotateLeftIcon from "@mui/icons-material/RotateLeft";
|
|||||||
import RotateRightIcon from "@mui/icons-material/RotateRight";
|
import RotateRightIcon from "@mui/icons-material/RotateRight";
|
||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
import InsertPageBreakIcon from "@mui/icons-material/InsertPageBreak";
|
import InsertPageBreakIcon from "@mui/icons-material/InsertPageBreak";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface PageEditorControlsProps {
|
interface PageEditorControlsProps {
|
||||||
// Close/Reset functions
|
// Close/Reset functions
|
||||||
@@ -51,6 +52,7 @@ const PageEditorControls = ({
|
|||||||
displayDocument,
|
displayDocument,
|
||||||
splitPositions,
|
splitPositions,
|
||||||
}: PageEditorControlsProps) => {
|
}: PageEditorControlsProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
// Calculate split tooltip text using smart toggle logic
|
// Calculate split tooltip text using smart toggle logic
|
||||||
const getSplitTooltip = () => {
|
const getSplitTooltip = () => {
|
||||||
if (!splitPositions || !displayDocument || selectedPageIds.length === 0) {
|
if (!splitPositions || !displayDocument || selectedPageIds.length === 0) {
|
||||||
@@ -131,7 +133,7 @@ const PageEditorControls = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Undo/Redo */}
|
{/* Undo/Redo */}
|
||||||
<Tooltip label="Undo">
|
<Tooltip label={t("pageEditor.toolbar.undo", "Undo")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={onUndo}
|
onClick={onUndo}
|
||||||
disabled={!canUndo}
|
disabled={!canUndo}
|
||||||
@@ -145,7 +147,7 @@ const PageEditorControls = ({
|
|||||||
<UndoIcon />
|
<UndoIcon />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label="Redo">
|
<Tooltip label={t("pageEditor.toolbar.redo", "Redo")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={onRedo}
|
onClick={onRedo}
|
||||||
disabled={!canRedo}
|
disabled={!canRedo}
|
||||||
@@ -170,7 +172,9 @@ const PageEditorControls = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Page Operations */}
|
{/* Page Operations */}
|
||||||
<Tooltip label="Rotate Selected Left">
|
<Tooltip
|
||||||
|
label={t("pageEditor.toolbar.rotateLeft", "Rotate Selected Left")}
|
||||||
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={() => onRotate("left")}
|
onClick={() => onRotate("left")}
|
||||||
disabled={selectedPageIds.length === 0}
|
disabled={selectedPageIds.length === 0}
|
||||||
@@ -187,7 +191,9 @@ const PageEditorControls = ({
|
|||||||
<RotateLeftIcon />
|
<RotateLeftIcon />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label="Rotate Selected Right">
|
<Tooltip
|
||||||
|
label={t("pageEditor.toolbar.rotateRight", "Rotate Selected Right")}
|
||||||
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={() => onRotate("right")}
|
onClick={() => onRotate("right")}
|
||||||
disabled={selectedPageIds.length === 0}
|
disabled={selectedPageIds.length === 0}
|
||||||
@@ -204,7 +210,7 @@ const PageEditorControls = ({
|
|||||||
<RotateRightIcon />
|
<RotateRightIcon />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip label="Delete Selected">
|
<Tooltip label={t("pageEditor.toolbar.delete", "Delete Selected")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
disabled={selectedPageIds.length === 0}
|
disabled={selectedPageIds.length === 0}
|
||||||
|
|||||||
+20
-5
@@ -26,7 +26,10 @@ const OperatorsSection = ({
|
|||||||
className={classes.operatorChip}
|
className={classes.operatorChip}
|
||||||
onClick={() => onInsertOperator("and")}
|
onClick={() => onInsertOperator("and")}
|
||||||
disabled={!csvInput.trim()}
|
disabled={!csvInput.trim()}
|
||||||
title="Combine selections (both conditions must be true)"
|
title={t(
|
||||||
|
"bulkSelection.operators.and.title",
|
||||||
|
"Combine selections (both conditions must be true)",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Text size="xs" fw={500}>
|
<Text size="xs" fw={500}>
|
||||||
and
|
and
|
||||||
@@ -38,7 +41,10 @@ const OperatorsSection = ({
|
|||||||
className={classes.operatorChip}
|
className={classes.operatorChip}
|
||||||
onClick={() => onInsertOperator("or")}
|
onClick={() => onInsertOperator("or")}
|
||||||
disabled={!csvInput.trim()}
|
disabled={!csvInput.trim()}
|
||||||
title="Add to selection (either condition can be true)"
|
title={t(
|
||||||
|
"bulkSelection.operators.or.title",
|
||||||
|
"Add to selection (either condition can be true)",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Text size="xs" fw={500}>
|
<Text size="xs" fw={500}>
|
||||||
or
|
or
|
||||||
@@ -50,7 +56,10 @@ const OperatorsSection = ({
|
|||||||
className={classes.operatorChip}
|
className={classes.operatorChip}
|
||||||
onClick={() => onInsertOperator("not")}
|
onClick={() => onInsertOperator("not")}
|
||||||
disabled={!csvInput.trim()}
|
disabled={!csvInput.trim()}
|
||||||
title="Exclude from selection"
|
title={t(
|
||||||
|
"bulkSelection.operators.not.title",
|
||||||
|
"Exclude from selection",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Text size="xs" fw={500}>
|
<Text size="xs" fw={500}>
|
||||||
not
|
not
|
||||||
@@ -64,7 +73,10 @@ const OperatorsSection = ({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className={classes.operatorChip}
|
className={classes.operatorChip}
|
||||||
onClick={() => onInsertOperator("even")}
|
onClick={() => onInsertOperator("even")}
|
||||||
title="Select all even-numbered pages (2, 4, 6, 8...)"
|
title={t(
|
||||||
|
"bulkSelection.operators.even.title",
|
||||||
|
"Select all even-numbered pages (2, 4, 6, 8...)",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Text size="xs" fw={500}>
|
<Text size="xs" fw={500}>
|
||||||
even
|
even
|
||||||
@@ -75,7 +87,10 @@ const OperatorsSection = ({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className={classes.operatorChip}
|
className={classes.operatorChip}
|
||||||
onClick={() => onInsertOperator("odd")}
|
onClick={() => onInsertOperator("odd")}
|
||||||
title="Select all odd-numbered pages (1, 3, 5, 7...)"
|
title={t(
|
||||||
|
"bulkSelection.operators.odd.title",
|
||||||
|
"Select all odd-numbered pages (1, 3, 5, 7...)",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Text size="xs" fw={500}>
|
<Text size="xs" fw={500}>
|
||||||
odd
|
odd
|
||||||
|
|||||||
+3
-1
@@ -45,7 +45,9 @@ const PageSelectionInput = ({
|
|||||||
height="1rem"
|
height="1rem"
|
||||||
style={{ color: "var(--text-instruction)" }}
|
style={{ color: "var(--text-instruction)" }}
|
||||||
/>
|
/>
|
||||||
<Text>Page Selection</Text>
|
<Text>
|
||||||
|
{t("bulkSelection.pageSelection.title", "Page Selection")}
|
||||||
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{typeof advancedOpened === "boolean" && (
|
{typeof advancedOpened === "boolean" && (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import React, {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { Badge, Modal, Text, ActionIcon, Tooltip, Group } from "@mantine/core";
|
import { Badge, Modal, Text, ActionIcon, Tooltip, Group } from "@mantine/core";
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
import { useConfigNavSections } from "@app/components/shared/config/configNavSections";
|
import { useConfigNavSections } from "@app/components/shared/config/configNavSections";
|
||||||
import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types";
|
import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types";
|
||||||
@@ -33,6 +34,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
|||||||
opened,
|
opened,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [active, setActive] = useState<NavKey>("general");
|
const [active, setActive] = useState<NavKey>("general");
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -332,7 +334,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
|||||||
ref={closeButtonRef}
|
ref={closeButtonRef}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
aria-label="Close"
|
aria-label={t("settings.close", "Close")}
|
||||||
data-autofocus
|
data-autofocus
|
||||||
>
|
>
|
||||||
<LocalIcon icon="close-rounded" width={18} height={18} />
|
<LocalIcon icon="close-rounded" width={18} height={18} />
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Group,
|
Group,
|
||||||
TextInput,
|
TextInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import UnfoldMoreIcon from "@mui/icons-material/UnfoldMore";
|
import UnfoldMoreIcon from "@mui/icons-material/UnfoldMore";
|
||||||
import SearchIcon from "@mui/icons-material/Search";
|
import SearchIcon from "@mui/icons-material/Search";
|
||||||
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
|
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
|
||||||
@@ -71,6 +72,7 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
|
|||||||
withinPortal = true,
|
withinPortal = true,
|
||||||
zIndex = Z_INDEX_AUTOMATE_DROPDOWN,
|
zIndex = Z_INDEX_AUTOMATE_DROPDOWN,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
const isMultiValue = Array.isArray(value);
|
const isMultiValue = Array.isArray(value);
|
||||||
@@ -184,7 +186,7 @@ const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Search..."
|
placeholder={t("dropdownList.searchPlaceholder", "Search...")}
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
leftSection={<SearchIcon style={{ fontSize: "1rem" }} />}
|
leftSection={<SearchIcon style={{ fontSize: "1rem" }} />}
|
||||||
|
|||||||
@@ -98,8 +98,11 @@ export default function EditableSecretField({
|
|||||||
variant="light"
|
variant="light"
|
||||||
onClick={handleEdit}
|
onClick={handleEdit}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
title="Edit"
|
title={t("editableSecretField.edit", "Edit")}
|
||||||
aria-label="Edit secret value"
|
aria-label={t(
|
||||||
|
"editableSecretField.editSecretValue",
|
||||||
|
"Edit secret value",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<LocalIcon icon="edit" width="1rem" height="1rem" />
|
<LocalIcon icon="edit" width="1rem" height="1rem" />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ const FileCard = ({
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{onView && (
|
{onView && (
|
||||||
<Tooltip label="View in Viewer">
|
<Tooltip label={t("fileCard.viewInViewer", "View in Viewer")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
@@ -126,7 +126,9 @@ const FileCard = ({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{onEdit && (
|
{onEdit && (
|
||||||
<Tooltip label="Open in File Editor">
|
<Tooltip
|
||||||
|
label={t("fileCard.openInFileEditor", "Open in File Editor")}
|
||||||
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Menu, Loader, Group, Text, ActionIcon, Tooltip } from "@mantine/core";
|
import { Menu, Loader, Group, Text, ActionIcon, Tooltip } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
|
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
|
||||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
@@ -28,6 +29,7 @@ export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
|
|||||||
switchingTo,
|
switchingTo,
|
||||||
viewOptionStyle,
|
viewOptionStyle,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Menu trigger="click" position="bottom" width="30rem">
|
<Menu trigger="click" position="bottom" width="30rem">
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
@@ -95,7 +97,10 @@ export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{onFileRemove && (
|
{onFileRemove && (
|
||||||
<Tooltip label="Close file" withArrow>
|
<Tooltip
|
||||||
|
label={t("fileDropdownMenu.closeFile", "Close file")}
|
||||||
|
withArrow
|
||||||
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
component="div"
|
component="div"
|
||||||
size="xs"
|
size="xs"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useCallback, useRef } from "react";
|
import { useState, useCallback, useRef } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
||||||
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
|
import VisibilityOffOutlinedIcon from "@mui/icons-material/VisibilityOffOutlined";
|
||||||
import type { FileId } from "@app/types/file";
|
import type { FileId } from "@app/types/file";
|
||||||
@@ -141,6 +142,7 @@ export function FileItem({
|
|||||||
onClick,
|
onClick,
|
||||||
onEyeClick,
|
onEyeClick,
|
||||||
}: FileItemProps) {
|
}: FileItemProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const ext = getFileExtension(name);
|
const ext = getFileExtension(name);
|
||||||
const dateLabel = lastModified ? formatFileDate(lastModified) : "";
|
const dateLabel = lastModified ? formatFileDate(lastModified) : "";
|
||||||
const typeLabel = ext ? ext.toUpperCase() : "File";
|
const typeLabel = ext ? ext.toUpperCase() : "File";
|
||||||
@@ -215,7 +217,11 @@ export function FileItem({
|
|||||||
}}
|
}}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={isViewedInViewer ? "Close viewer" : "Open in viewer"}
|
aria-label={
|
||||||
|
isViewedInViewer
|
||||||
|
? t("fileSidebar.fileItem.closeViewer", "Close viewer")
|
||||||
|
: t("fileSidebar.fileItem.openInViewer", "Open in viewer")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<VisibilityOutlinedIcon
|
<VisibilityOutlinedIcon
|
||||||
className="file-sidebar-eye-open"
|
className="file-sidebar-eye-open"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { ReactNode } from "react";
|
import React, { ReactNode } from "react";
|
||||||
import { Paper, Group, Text, Button, ActionIcon, Stack } from "@mantine/core";
|
import { Paper, Group, Text, Button, ActionIcon, Stack } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
|
|
||||||
type InfoBannerTone = "info" | "warning";
|
type InfoBannerTone = "info" | "warning";
|
||||||
@@ -85,6 +86,7 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
|||||||
closeIconColor,
|
closeIconColor,
|
||||||
compact = false,
|
compact = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
if (!show) {
|
if (!show) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -192,7 +194,7 @@ export const InfoBanner: React.FC<InfoBannerProps> = ({
|
|||||||
color={closeIconColor ? undefined : "gray"}
|
color={closeIconColor ? undefined : "gray"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleDismiss}
|
onClick={handleDismiss}
|
||||||
aria-label="Dismiss"
|
aria-label={t("infoBanner.dismiss", "Dismiss")}
|
||||||
style={closeIconColor ? { color: closeIconColor } : undefined}
|
style={closeIconColor ? { color: closeIconColor } : undefined}
|
||||||
>
|
>
|
||||||
<LocalIcon
|
<LocalIcon
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { forwardRef } from "react";
|
import React, { forwardRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
import styles from "@app/components/shared/textInput/TextInput.module.css";
|
import styles from "@app/components/shared/textInput/TextInput.module.css";
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
|||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
if (onClear) {
|
if (onClear) {
|
||||||
onClear();
|
onClear();
|
||||||
@@ -112,7 +114,7 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
|||||||
type="button"
|
type="button"
|
||||||
className={styles.clearButton}
|
className={styles.clearButton}
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
aria-label="Clear input"
|
aria-label={t("textInput.clear", "Clear input")}
|
||||||
>
|
>
|
||||||
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
|
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import React, {
|
|||||||
useCallback,
|
useCallback,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
import { addEventListenerWithCleanup } from "@app/utils/genericUtils";
|
import { addEventListenerWithCleanup } from "@app/utils/genericUtils";
|
||||||
import { useTooltipPosition } from "@app/hooks/useTooltipPosition";
|
import { useTooltipPosition } from "@app/hooks/useTooltipPosition";
|
||||||
@@ -68,6 +69,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
manualCloseOnly = false,
|
manualCloseOnly = false,
|
||||||
showCloseButton = false,
|
showCloseButton = false,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [internalOpen, setInternalOpen] = useState(false);
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
const [isPinned, setIsPinned] = useState(false);
|
const [isPinned, setIsPinned] = useState(false);
|
||||||
const { tooltipLogo } = useLogoAssets();
|
const { tooltipLogo } = useLogoAssets();
|
||||||
@@ -401,8 +403,8 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
|||||||
setIsPinned(false);
|
setIsPinned(false);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
title="Close tooltip"
|
title={t("tooltip.close", "Close tooltip")}
|
||||||
aria-label="Close tooltip"
|
aria-label={t("tooltip.close", "Close tooltip")}
|
||||||
>
|
>
|
||||||
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
|
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ const UpdateModal: React.FC<UpdateModalProps> = ({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
aria-label="Close update modal"
|
aria-label={t("update.closeModal", "Close update modal")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { ActionIcon, Slider } from "@mantine/core";
|
import { ActionIcon, Slider } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useViewer } from "@app/contexts/ViewerContext";
|
import { useViewer } from "@app/contexts/ViewerContext";
|
||||||
import { useNavigationState } from "@app/contexts/NavigationContext";
|
import { useNavigationState } from "@app/contexts/NavigationContext";
|
||||||
import ZoomInIcon from "@mui/icons-material/ZoomIn";
|
import ZoomInIcon from "@mui/icons-material/ZoomIn";
|
||||||
@@ -9,6 +10,7 @@ import ZoomOutIcon from "@mui/icons-material/ZoomOut";
|
|||||||
* Compact zoom controls rendered inline in the WorkbenchBar when the current workbench is "viewer".
|
* Compact zoom controls rendered inline in the WorkbenchBar when the current workbench is "viewer".
|
||||||
*/
|
*/
|
||||||
export function ViewerInlineControls() {
|
export function ViewerInlineControls() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { workbench } = useNavigationState();
|
const { workbench } = useNavigationState();
|
||||||
const viewer = useViewer();
|
const viewer = useViewer();
|
||||||
|
|
||||||
@@ -39,7 +41,7 @@ export function ViewerInlineControls() {
|
|||||||
radius="md"
|
radius="md"
|
||||||
className="workbench-bar-action-icon"
|
className="workbench-bar-action-icon"
|
||||||
onClick={() => viewer.zoomActions.zoomOut()}
|
onClick={() => viewer.zoomActions.zoomOut()}
|
||||||
aria-label="Zoom out"
|
aria-label={t("viewer.zoomOut", "Zoom out")}
|
||||||
>
|
>
|
||||||
<ZoomOutIcon sx={{ fontSize: "1rem" }} />
|
<ZoomOutIcon sx={{ fontSize: "1rem" }} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -68,7 +70,7 @@ export function ViewerInlineControls() {
|
|||||||
radius="md"
|
radius="md"
|
||||||
className="workbench-bar-action-icon"
|
className="workbench-bar-action-icon"
|
||||||
onClick={() => viewer.zoomActions.zoomIn()}
|
onClick={() => viewer.zoomActions.zoomIn()}
|
||||||
aria-label="Zoom in"
|
aria-label={t("viewer.zoomIn", "Zoom in")}
|
||||||
>
|
>
|
||||||
<ZoomInIcon sx={{ fontSize: "1rem" }} />
|
<ZoomInIcon sx={{ fontSize: "1rem" }} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { ActionIcon, Divider } from "@mantine/core";
|
import { ActionIcon, Divider } from "@mantine/core";
|
||||||
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
|
import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||||
import {
|
import {
|
||||||
useNavigationState,
|
useNavigationState,
|
||||||
@@ -37,6 +38,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({
|
|||||||
setActiveButton,
|
setActiveButton,
|
||||||
tooltipPosition = "right",
|
tooltipPosition = "right",
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } =
|
const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } =
|
||||||
useToolWorkflow();
|
useToolWorkflow();
|
||||||
const { hasUnsavedChanges } = useNavigationState();
|
const { hasUnsavedChanges } = useNavigationState();
|
||||||
@@ -158,7 +160,11 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({
|
|||||||
<div className="current-tool-content">
|
<div className="current-tool-content">
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={isBackHover ? "Back to all tools" : indicatorTool.name}
|
content={
|
||||||
|
isBackHover
|
||||||
|
? t("quickAccess.backToAllTools", "Back to all tools")
|
||||||
|
: indicatorTool.name
|
||||||
|
}
|
||||||
position={tooltipPosition}
|
position={tooltipPosition}
|
||||||
arrow
|
arrow
|
||||||
maxWidth={140}
|
maxWidth={140}
|
||||||
@@ -183,7 +189,9 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({
|
|||||||
onMouseEnter={() => setIsBackHover(true)}
|
onMouseEnter={() => setIsBackHover(true)}
|
||||||
onMouseLeave={() => setIsBackHover(false)}
|
onMouseLeave={() => setIsBackHover(false)}
|
||||||
aria-label={
|
aria-label={
|
||||||
isBackHover ? "Back to all tools" : indicatorTool.name
|
isBackHover
|
||||||
|
? t("quickAccess.backToAllTools", "Back to all tools")
|
||||||
|
: indicatorTool.name
|
||||||
}
|
}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isBackHover
|
backgroundColor: isBackHover
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useToast } from "@app/components/toast/ToastContext";
|
import { useToast } from "@app/components/toast/ToastContext";
|
||||||
import { ToastInstance, ToastLocation } from "@app/components/toast/types";
|
import { ToastInstance, ToastLocation } from "@app/components/toast/types";
|
||||||
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
||||||
@@ -38,6 +39,7 @@ function getDefaultIconName(t: ToastInstance): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ToastRenderer() {
|
export default function ToastRenderer() {
|
||||||
|
const { t: translate } = useTranslation();
|
||||||
const { toasts, dismiss } = useToast();
|
const { toasts, dismiss } = useToast();
|
||||||
|
|
||||||
const grouped = toasts.reduce<Record<ToastLocation, ToastInstance[]>>(
|
const grouped = toasts.reduce<Record<ToastLocation, ToastInstance[]>>(
|
||||||
@@ -88,7 +90,10 @@ export default function ToastRenderer() {
|
|||||||
<div className="toast-controls">
|
<div className="toast-controls">
|
||||||
{t.expandable && (
|
{t.expandable && (
|
||||||
<button
|
<button
|
||||||
aria-label="Toggle details"
|
aria-label={translate(
|
||||||
|
"toast.toggleDetails",
|
||||||
|
"Toggle details",
|
||||||
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const evt = new CustomEvent("toast:toggle", {
|
const evt = new CustomEvent("toast:toggle", {
|
||||||
detail: { id: t.id },
|
detail: { id: t.id },
|
||||||
@@ -101,7 +106,7 @@ export default function ToastRenderer() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
aria-label="Dismiss"
|
aria-label={translate("toast.dismiss", "Dismiss")}
|
||||||
onClick={() => dismiss(t.id)}
|
onClick={() => dismiss(t.id)}
|
||||||
className="toast-button"
|
className="toast-button"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -99,11 +99,14 @@ const CompressSettings = ({
|
|||||||
{parameters.compressionMethod === "filesize" && (
|
{parameters.compressionMethod === "filesize" && (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
Desired File Size
|
{t("compress.settings.desiredSize", "Desired File Size")}
|
||||||
</Text>
|
</Text>
|
||||||
<div style={{ display: "flex", gap: "8px", alignItems: "flex-end" }}>
|
<div style={{ display: "flex", gap: "8px", alignItems: "flex-end" }}>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
placeholder="Enter size"
|
placeholder={t(
|
||||||
|
"compress.settings.desiredSizePlaceholder",
|
||||||
|
"Enter size",
|
||||||
|
)}
|
||||||
value={parameters.fileSizeValue}
|
value={parameters.fileSizeValue}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
onParameterChange("fileSizeValue", value?.toString() || "")
|
onParameterChange("fileSizeValue", value?.toString() || "")
|
||||||
|
|||||||
@@ -139,7 +139,9 @@ const LanguagePicker: React.FC<LanguagePickerProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
<Loader size="xs" />
|
<Loader size="xs" />
|
||||||
<Text size="sm">Loading available languages...</Text>
|
<Text size="sm">
|
||||||
|
{t("ocr.languagePicker.loading", "Loading available languages...")}
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-6
@@ -75,7 +75,7 @@ export default function PageLayoutMarginsBordersSettings({
|
|||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label={t("pageLayout.top", "Top Margin")}
|
label={t("pageLayout.top", "Top Margin")}
|
||||||
placeholder="Enter top margin"
|
placeholder={t("pageLayout.margins.topPlaceholder", "Enter top margin")}
|
||||||
value={parameters.topMargin}
|
value={parameters.topMargin}
|
||||||
onChange={(v) => onParameterChange("topMargin", Number(v))}
|
onChange={(v) => onParameterChange("topMargin", Number(v))}
|
||||||
min={0}
|
min={0}
|
||||||
@@ -85,7 +85,10 @@ export default function PageLayoutMarginsBordersSettings({
|
|||||||
/>
|
/>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label={t("pageLayout.bottom", "Bottom Margin")}
|
label={t("pageLayout.bottom", "Bottom Margin")}
|
||||||
placeholder="Enter bottom margin"
|
placeholder={t(
|
||||||
|
"pageLayout.margins.bottomPlaceholder",
|
||||||
|
"Enter bottom margin",
|
||||||
|
)}
|
||||||
value={parameters.bottomMargin}
|
value={parameters.bottomMargin}
|
||||||
onChange={(v) => onParameterChange("bottomMargin", Number(v))}
|
onChange={(v) => onParameterChange("bottomMargin", Number(v))}
|
||||||
min={0}
|
min={0}
|
||||||
@@ -95,7 +98,10 @@ export default function PageLayoutMarginsBordersSettings({
|
|||||||
/>
|
/>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label={t("pageLayout.left", "Left Margin")}
|
label={t("pageLayout.left", "Left Margin")}
|
||||||
placeholder="Enter left margin"
|
placeholder={t(
|
||||||
|
"pageLayout.margins.leftPlaceholder",
|
||||||
|
"Enter left margin",
|
||||||
|
)}
|
||||||
value={parameters.leftMargin}
|
value={parameters.leftMargin}
|
||||||
onChange={(v) => onParameterChange("leftMargin", Number(v))}
|
onChange={(v) => onParameterChange("leftMargin", Number(v))}
|
||||||
min={0}
|
min={0}
|
||||||
@@ -104,7 +110,10 @@ export default function PageLayoutMarginsBordersSettings({
|
|||||||
/>
|
/>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label={t("pageLayout.right", "Right Margin")}
|
label={t("pageLayout.right", "Right Margin")}
|
||||||
placeholder="Enter right margin"
|
placeholder={t(
|
||||||
|
"pageLayout.margins.rightPlaceholder",
|
||||||
|
"Enter right margin",
|
||||||
|
)}
|
||||||
value={parameters.rightMargin}
|
value={parameters.rightMargin}
|
||||||
onChange={(v) => onParameterChange("rightMargin", Number(v))}
|
onChange={(v) => onParameterChange("rightMargin", Number(v))}
|
||||||
min={0}
|
min={0}
|
||||||
@@ -114,7 +123,10 @@ export default function PageLayoutMarginsBordersSettings({
|
|||||||
/>
|
/>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label={t("pageLayout.innerMargin", "Inner Margin")}
|
label={t("pageLayout.innerMargin", "Inner Margin")}
|
||||||
placeholder="Enter inner margin"
|
placeholder={t(
|
||||||
|
"pageLayout.margins.innerPlaceholder",
|
||||||
|
"Enter inner margin",
|
||||||
|
)}
|
||||||
value={parameters.innerMargin}
|
value={parameters.innerMargin}
|
||||||
onChange={(v) => onParameterChange("innerMargin", Number(v))}
|
onChange={(v) => onParameterChange("innerMargin", Number(v))}
|
||||||
min={0}
|
min={0}
|
||||||
@@ -137,7 +149,10 @@ export default function PageLayoutMarginsBordersSettings({
|
|||||||
{parameters.addBorder && (
|
{parameters.addBorder && (
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label={t("pageLayout.borderWidth", "Border Thickness")}
|
label={t("pageLayout.borderWidth", "Border Thickness")}
|
||||||
placeholder="Enter border thickness"
|
placeholder={t(
|
||||||
|
"pageLayout.margins.borderThicknessPlaceholder",
|
||||||
|
"Enter border thickness",
|
||||||
|
)}
|
||||||
value={parameters.borderWidth}
|
value={parameters.borderWidth}
|
||||||
onChange={(v) => onParameterChange("borderWidth", Number(v))}
|
onChange={(v) => onParameterChange("borderWidth", Number(v))}
|
||||||
min={1}
|
min={1}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export default function PageLayoutSettings({
|
|||||||
<>
|
<>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label={t("pageLayout.rows", "Rows")}
|
label={t("pageLayout.rows", "Rows")}
|
||||||
placeholder="Enter rows"
|
placeholder={t("pageLayout.rowsPlaceholder", "Enter rows")}
|
||||||
value={parameters.rows}
|
value={parameters.rows}
|
||||||
onChange={(v) => onParameterChange("rows", Number(v))}
|
onChange={(v) => onParameterChange("rows", Number(v))}
|
||||||
min={1}
|
min={1}
|
||||||
@@ -90,7 +90,7 @@ export default function PageLayoutSettings({
|
|||||||
|
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label={t("pageLayout.cols", "Columns")}
|
label={t("pageLayout.cols", "Columns")}
|
||||||
placeholder="Enter columns"
|
placeholder={t("pageLayout.colsPlaceholder", "Enter columns")}
|
||||||
value={parameters.cols}
|
value={parameters.cols}
|
||||||
onChange={(v) => onParameterChange("cols", Number(v))}
|
onChange={(v) => onParameterChange("cols", Number(v))}
|
||||||
min={1}
|
min={1}
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ export const SavedSignaturesSection = ({
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="blue"
|
color="blue"
|
||||||
aria-label="Use signature"
|
aria-label={t("sign.saved.use", "Use signature")}
|
||||||
onClick={() => onUseSignature(activePersonalSignature)}
|
onClick={() => onUseSignature(activePersonalSignature)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
@@ -480,7 +480,7 @@ export const SavedSignaturesSection = ({
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="blue"
|
color="blue"
|
||||||
aria-label="Use signature"
|
aria-label={t("sign.saved.use", "Use signature")}
|
||||||
onClick={() => onUseSignature(activeSharedSignature)}
|
onClick={() => onUseSignature(activeSharedSignature)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
@@ -618,7 +618,7 @@ export const SavedSignaturesSection = ({
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="blue"
|
color="blue"
|
||||||
aria-label="Use signature"
|
aria-label={t("sign.saved.use", "Use signature")}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onUseSignature(activeLocalStorageSignature)
|
onUseSignature(activeLocalStorageSignature)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,16 +32,16 @@ export const usePageSelectionTips = (): TooltipContent => {
|
|||||||
),
|
),
|
||||||
bullets: [
|
bullets: [
|
||||||
t(
|
t(
|
||||||
"bulkSelection.operators.and",
|
"bulkSelection.operators.descriptions.and",
|
||||||
'AND: & or "and" — require both conditions (e.g., 1-50 & even)',
|
'AND: & or "and" - require both conditions (e.g., 1-50 & even)',
|
||||||
),
|
),
|
||||||
t(
|
t(
|
||||||
"bulkSelection.operators.comma",
|
"bulkSelection.operators.descriptions.comma",
|
||||||
"Comma: , or | — combine selections (e.g., 1-10, 20)",
|
"Comma: , or | - combine selections (e.g., 1-10, 20)",
|
||||||
),
|
),
|
||||||
t(
|
t(
|
||||||
"bulkSelection.operators.not",
|
"bulkSelection.operators.descriptions.not",
|
||||||
'NOT: ! or "not" — exclude pages (e.g., 3n & not 30)',
|
'NOT: ! or "not" - exclude pages (e.g., 3n & not 30)',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1176,7 +1176,12 @@ const EmbedPdfViewerContent = ({
|
|||||||
|
|
||||||
{!effectiveFile ? (
|
{!effectiveFile ? (
|
||||||
<Center style={{ flex: 1 }}>
|
<Center style={{ flex: 1 }}>
|
||||||
<Text c="red">Error: No file provided to viewer</Text>
|
<Text c="red">
|
||||||
|
{t(
|
||||||
|
"viewer.error.noFileProvided",
|
||||||
|
"Error: No file provided to viewer",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
</Center>
|
</Center>
|
||||||
) : isCurrentFileEncrypted ? (
|
) : isCurrentFileEncrypted ? (
|
||||||
<Center style={{ flex: 1 }}>
|
<Center style={{ flex: 1 }}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useCallback, useState, useMemo, useRef } from "react";
|
import React, { useCallback, useState, useMemo, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useDocumentState } from "@embedpdf/core/react";
|
import { useDocumentState } from "@embedpdf/core/react";
|
||||||
import { useScroll } from "@embedpdf/plugin-scroll/react";
|
import { useScroll } from "@embedpdf/plugin-scroll/react";
|
||||||
import { useAnnotation } from "@embedpdf/plugin-annotation/react";
|
import { useAnnotation } from "@embedpdf/plugin-annotation/react";
|
||||||
@@ -143,6 +144,7 @@ const LinkToolbar: React.FC<LinkToolbarProps> = React.memo(
|
|||||||
onMouseEnter,
|
onMouseEnter,
|
||||||
onMouseLeave,
|
onMouseLeave,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const centerX =
|
const centerX =
|
||||||
(annotationLink.rect.origin.x + annotationLink.rect.size.width / 2) *
|
(annotationLink.rect.origin.x + annotationLink.rect.size.width / 2) *
|
||||||
scale;
|
scale;
|
||||||
@@ -170,8 +172,8 @@ const LinkToolbar: React.FC<LinkToolbarProps> = React.memo(
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDelete(annotationLink);
|
onDelete(annotationLink);
|
||||||
}}
|
}}
|
||||||
aria-label="Delete link"
|
aria-label={t("viewer.link.delete", "Delete link")}
|
||||||
title="Delete link"
|
title={t("viewer.link.delete", "Delete link")}
|
||||||
>
|
>
|
||||||
<TrashIcon />
|
<TrashIcon />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
forwardRef,
|
forwardRef,
|
||||||
useRef,
|
useRef,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { createPluginRegistration } from "@embedpdf/core";
|
import { createPluginRegistration } from "@embedpdf/core";
|
||||||
import type { PluginRegistry } from "@embedpdf/core";
|
import type { PluginRegistry } from "@embedpdf/core";
|
||||||
import { EmbedPDF } from "@embedpdf/core/react";
|
import { EmbedPDF } from "@embedpdf/core/react";
|
||||||
@@ -157,6 +158,7 @@ export const LocalEmbedPDFWithAnnotations = forwardRef<
|
|||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||||
const annotationApiRef = useRef<any>(null);
|
const annotationApiRef = useRef<any>(null);
|
||||||
const zoomApiRef = useRef<any>(null);
|
const zoomApiRef = useRef<any>(null);
|
||||||
@@ -653,7 +655,10 @@ export const LocalEmbedPDFWithAnnotations = forwardRef<
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
aria-label="Delete signature"
|
aria-label={t(
|
||||||
|
"viewer.signature.delete",
|
||||||
|
"Delete signature",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<CloseIcon
|
<CloseIcon
|
||||||
style={{ fontSize: "0.8rem" }}
|
style={{ fontSize: "0.8rem" }}
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ function RedactionSelectionMenuInner({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group gap="sm" wrap="nowrap" justify="center">
|
<Group gap="sm" wrap="nowrap" justify="center">
|
||||||
<Tooltip label="Remove this mark">
|
<Tooltip label={t("viewer.redaction.removeMark", "Remove this mark")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleCloseClick}
|
onClick={handleCloseClick}
|
||||||
aria-label="Close search"
|
aria-label={t("viewer.search.close", "Close search")}
|
||||||
>
|
>
|
||||||
<LocalIcon icon="close" width="1rem" height="1rem" />
|
<LocalIcon icon="close" width="1rem" height="1rem" />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -227,7 +227,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
|
|||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
onClick={handleClearSearch}
|
onClick={handleClearSearch}
|
||||||
aria-label="Clear search"
|
aria-label={t("viewer.search.clear", "Clear search")}
|
||||||
>
|
>
|
||||||
<LocalIcon icon="close" width="0.875rem" height="0.875rem" />
|
<LocalIcon icon="close" width="0.875rem" height="0.875rem" />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -267,7 +267,9 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
|
|||||||
disabled={!resultInfo || resultInfo.totalResults === 0}
|
disabled={!resultInfo || resultInfo.totalResults === 0}
|
||||||
/>
|
/>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
of {resultInfo?.totalResults || 0}
|
{t("viewer.search.resultsOf", "of {{total}}", {
|
||||||
|
total: resultInfo?.totalResults || 0,
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
@@ -277,7 +279,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={handlePrevious}
|
onClick={handlePrevious}
|
||||||
disabled={!resultInfo || resultInfo.currentIndex <= 1}
|
disabled={!resultInfo || resultInfo.currentIndex <= 1}
|
||||||
aria-label="Previous result"
|
aria-label={t("viewer.search.previous", "Previous result")}
|
||||||
>
|
>
|
||||||
<LocalIcon icon="keyboard-arrow-up" width="1rem" height="1rem" />
|
<LocalIcon icon="keyboard-arrow-up" width="1rem" height="1rem" />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -288,7 +290,7 @@ export function SearchInterface({ visible, onClose }: SearchInterfaceProps) {
|
|||||||
disabled={
|
disabled={
|
||||||
!resultInfo || resultInfo.currentIndex >= resultInfo.totalResults
|
!resultInfo || resultInfo.currentIndex >= resultInfo.totalResults
|
||||||
}
|
}
|
||||||
aria-label="Next result"
|
aria-label={t("viewer.search.next", "Next result")}
|
||||||
>
|
>
|
||||||
<LocalIcon icon="keyboard-arrow-down" width="1rem" height="1rem" />
|
<LocalIcon icon="keyboard-arrow-down" width="1rem" height="1rem" />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ interface FormFieldSidebarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FormFieldSidebar({ visible, onToggle }: FormFieldSidebarProps) {
|
export function FormFieldSidebar({ visible, onToggle }: FormFieldSidebarProps) {
|
||||||
useTranslation();
|
const { t } = useTranslation();
|
||||||
const { state, setValue, setActiveField } = useFormFill();
|
const { state, setValue, setActiveField } = useFormFill();
|
||||||
const { fields, activeFieldName, loading } = state;
|
const { fields, activeFieldName, loading } = state;
|
||||||
const activeFieldRef = useRef<HTMLDivElement>(null);
|
const activeFieldRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -119,7 +119,7 @@ export function FormFieldSidebar({ visible, onToggle }: FormFieldSidebarProps) {
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
aria-label="Close sidebar"
|
aria-label={t("formFill.sidebar.close", "Close sidebar")}
|
||||||
>
|
>
|
||||||
<CloseIcon sx={{ fontSize: 16 }} />
|
<CloseIcon sx={{ fontSize: 16 }} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
useFormFill,
|
useFormFill,
|
||||||
useAllFormValues,
|
useAllFormValues,
|
||||||
@@ -120,6 +121,7 @@ const _MODE_TABS: ModeTabDef[] = [
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const FormFill = (_props: BaseToolProps) => {
|
const FormFill = (_props: BaseToolProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { selectedTool } = useNavigation();
|
const { selectedTool } = useNavigation();
|
||||||
const { selectors, state: fileState } = useFileState();
|
const { selectors, state: fileState } = useFileState();
|
||||||
|
|
||||||
@@ -488,7 +490,10 @@ const FormFill = (_props: BaseToolProps) => {
|
|||||||
|
|
||||||
{/* Flatten toggle */}
|
{/* Flatten toggle */}
|
||||||
<Switch
|
<Switch
|
||||||
label="Flatten after filling"
|
label={t(
|
||||||
|
"formFill.flattenAfterFilling",
|
||||||
|
"Flatten after filling",
|
||||||
|
)}
|
||||||
checked={flatten}
|
checked={flatten}
|
||||||
onChange={(e) => setFlatten(e.currentTarget.checked)}
|
onChange={(e) => setFlatten(e.currentTarget.checked)}
|
||||||
size="xs"
|
size="xs"
|
||||||
@@ -507,15 +512,22 @@ const FormFill = (_props: BaseToolProps) => {
|
|||||||
loading={saving}
|
loading={saving}
|
||||||
disabled={!formState.isDirty && !flattenChanged}
|
disabled={!formState.isDirty && !flattenChanged}
|
||||||
>
|
>
|
||||||
Save
|
{t("formFill.save", "Save")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Tooltip label="Re-scan fields" withArrow position="bottom">
|
<Tooltip
|
||||||
|
label={t("formFill.rescanFields", "Re-scan fields")}
|
||||||
|
withArrow
|
||||||
|
position="bottom"
|
||||||
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="light"
|
variant="light"
|
||||||
size="md"
|
size="md"
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
aria-label="Re-scan form fields"
|
aria-label={t(
|
||||||
|
"formFill.rescanFormFields",
|
||||||
|
"Re-scan form fields",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<RefreshIcon sx={{ fontSize: 16 }} />
|
<RefreshIcon sx={{ fontSize: 16 }} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
|||||||
@@ -39,13 +39,15 @@ export function OverviewHeader() {
|
|||||||
</Text>
|
</Text>
|
||||||
{user?.email && (
|
{user?.email && (
|
||||||
<Text size="xs" c="dimmed" mt="0.25rem">
|
<Text size="xs" c="dimmed" mt="0.25rem">
|
||||||
Signed in as: {user.email}
|
{t("account.overview.signedInAs", "Signed in as: {{email}}", {
|
||||||
|
email: user.email,
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{user && (
|
{user && (
|
||||||
<Button color="red" variant="filled" onClick={handleLogout}>
|
<Button color="red" variant="filled" onClick={handleLogout}>
|
||||||
Log out
|
{t("account.overview.logOut", "Log out")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+12
-3
@@ -809,7 +809,10 @@ export default function AdminAdvancedSection() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder="Default: java.io.tmpdir/stirling-pdf"
|
placeholder={t(
|
||||||
|
"admin.settings.advanced.tempFileManagement.baseTmpDir.placeholder",
|
||||||
|
"Default: java.io.tmpdir/stirling-pdf",
|
||||||
|
)}
|
||||||
disabled={!loginEnabled}
|
disabled={!loginEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -834,7 +837,10 @@ export default function AdminAdvancedSection() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder="Default: baseTmpDir/libreoffice"
|
placeholder={t(
|
||||||
|
"admin.settings.advanced.tempFileManagement.libreofficeDir.placeholder",
|
||||||
|
"Default: baseTmpDir/libreoffice",
|
||||||
|
)}
|
||||||
disabled={!loginEnabled}
|
disabled={!loginEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -859,7 +865,10 @@ export default function AdminAdvancedSection() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder="System temp directory path"
|
placeholder={t(
|
||||||
|
"admin.settings.advanced.tempFileManagement.systemTempDir.placeholder",
|
||||||
|
"System temp directory path",
|
||||||
|
)}
|
||||||
disabled={!loginEnabled}
|
disabled={!loginEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+4
-1
@@ -698,7 +698,10 @@ export default function AdminDatabaseSection() {
|
|||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setSettings({ ...settings, password: value })
|
setSettings({ ...settings, password: value })
|
||||||
}
|
}
|
||||||
placeholder="Enter database password"
|
placeholder={t(
|
||||||
|
"admin.settings.database.password.placeholder",
|
||||||
|
"Enter database password",
|
||||||
|
)}
|
||||||
disabled={!loginEnabled}
|
disabled={!loginEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+8
-2
@@ -296,7 +296,10 @@ export default function AdminEndpointsSection() {
|
|||||||
}))}
|
}))}
|
||||||
searchable
|
searchable
|
||||||
clearable
|
clearable
|
||||||
placeholder="Select endpoints to disable"
|
placeholder={t(
|
||||||
|
"admin.settings.endpoints.toRemove.placeholder",
|
||||||
|
"Select endpoints to disable",
|
||||||
|
)}
|
||||||
comboboxProps={{ zIndex: 1400 }}
|
comboboxProps={{ zIndex: 1400 }}
|
||||||
disabled={!loginEnabled}
|
disabled={!loginEnabled}
|
||||||
/>
|
/>
|
||||||
@@ -330,7 +333,10 @@ export default function AdminEndpointsSection() {
|
|||||||
}))}
|
}))}
|
||||||
searchable
|
searchable
|
||||||
clearable
|
clearable
|
||||||
placeholder="Select groups to disable"
|
placeholder={t(
|
||||||
|
"admin.settings.endpoints.groupsToRemove.placeholder",
|
||||||
|
"Select groups to disable",
|
||||||
|
)}
|
||||||
comboboxProps={{ zIndex: 1400 }}
|
comboboxProps={{ zIndex: 1400 }}
|
||||||
disabled={!loginEnabled}
|
disabled={!loginEnabled}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+4
-1
@@ -286,7 +286,10 @@ export default function AdminMailSection() {
|
|||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
setSettings({ ...settings, password: value })
|
setSettings({ ...settings, password: value })
|
||||||
}
|
}
|
||||||
placeholder="Enter SMTP password"
|
placeholder={t(
|
||||||
|
"admin.settings.mail.password.placeholder",
|
||||||
|
"Enter SMTP password",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -183,8 +183,14 @@ const AdminPlanSection: React.FC = () => {
|
|||||||
|
|
||||||
if (!plans || plans.length === 0) {
|
if (!plans || plans.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Alert color="yellow" title="No data available">
|
<Alert
|
||||||
Plans data is not available at the moment.
|
color="yellow"
|
||||||
|
title={t("admin.settings.plan.noData.title", "No data available")}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"admin.settings.plan.noData.message",
|
||||||
|
"Plans data is not available at the moment.",
|
||||||
|
)}
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-4
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
TextInput,
|
TextInput,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -132,17 +132,29 @@ export default function AdminPremiumSection() {
|
|||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<Text size="sm" component="span">
|
<Text size="sm" component="span">
|
||||||
<strong>Custom Metadata</strong> (PRO) - General
|
<Trans
|
||||||
|
i18nKey="admin.settings.premium.movedFeatures.customMetadata"
|
||||||
|
defaults="<0>Custom Metadata</0> (PRO) - General"
|
||||||
|
components={[<strong />]}
|
||||||
|
/>
|
||||||
</Text>
|
</Text>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<Text size="sm" component="span">
|
<Text size="sm" component="span">
|
||||||
<strong>Audit Logging</strong> (ENTERPRISE) - Security
|
<Trans
|
||||||
|
i18nKey="admin.settings.premium.movedFeatures.auditLogging"
|
||||||
|
defaults="<0>Audit Logging</0> (ENTERPRISE) - Security"
|
||||||
|
components={[<strong />]}
|
||||||
|
/>
|
||||||
</Text>
|
</Text>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
<List.Item>
|
<List.Item>
|
||||||
<Text size="sm" component="span">
|
<Text size="sm" component="span">
|
||||||
<strong>Database Configuration</strong> (ENTERPRISE) - Database
|
<Trans
|
||||||
|
i18nKey="admin.settings.premium.movedFeatures.databaseConfiguration"
|
||||||
|
defaults="<0>Database Configuration</0> (ENTERPRISE) - Database"
|
||||||
|
components={[<strong />]}
|
||||||
|
/>
|
||||||
</Text>
|
</Text>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
</List>
|
</List>
|
||||||
|
|||||||
+12
-7
@@ -11,7 +11,10 @@ import {
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { alert } from "@app/components/toast";
|
import { alert } from "@app/components/toast";
|
||||||
import { LicenseInfo, mapLicenseToTier } from "@app/services/licenseService";
|
import { LicenseInfo, mapLicenseToTier } from "@app/services/licenseService";
|
||||||
import { PLAN_FEATURES, PLAN_HIGHLIGHTS } from "@app/constants/planConstants";
|
import {
|
||||||
|
usePlanFeatures,
|
||||||
|
usePlanHighlights,
|
||||||
|
} from "@app/constants/planConstants";
|
||||||
import FeatureComparisonTable from "@app/components/shared/config/configSections/plan/FeatureComparisonTable";
|
import FeatureComparisonTable from "@app/components/shared/config/configSections/plan/FeatureComparisonTable";
|
||||||
import StaticCheckoutModal from "@app/components/shared/config/configSections/plan/StaticCheckoutModal";
|
import StaticCheckoutModal from "@app/components/shared/config/configSections/plan/StaticCheckoutModal";
|
||||||
import LicenseKeySection from "@app/components/shared/config/configSections/plan/LicenseKeySection";
|
import LicenseKeySection from "@app/components/shared/config/configSections/plan/LicenseKeySection";
|
||||||
@@ -32,6 +35,8 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({
|
|||||||
currentLicenseInfo,
|
currentLicenseInfo,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const planFeatures = usePlanFeatures();
|
||||||
|
const planHighlights = usePlanHighlights();
|
||||||
const [showComparison, setShowComparison] = useState(false);
|
const [showComparison, setShowComparison] = useState(false);
|
||||||
|
|
||||||
// Static checkout modal state
|
// Static checkout modal state
|
||||||
@@ -88,8 +93,8 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({
|
|||||||
price: 0,
|
price: 0,
|
||||||
currency: "£",
|
currency: "£",
|
||||||
period: "",
|
period: "",
|
||||||
highlights: PLAN_HIGHLIGHTS.FREE,
|
highlights: planHighlights.FREE,
|
||||||
features: PLAN_FEATURES.FREE,
|
features: planFeatures.FREE,
|
||||||
maxUsers: 5,
|
maxUsers: 5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -99,8 +104,8 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({
|
|||||||
currency: "",
|
currency: "",
|
||||||
period: "",
|
period: "",
|
||||||
popular: false,
|
popular: false,
|
||||||
highlights: PLAN_HIGHLIGHTS.SERVER_MONTHLY,
|
highlights: planHighlights.SERVER_MONTHLY,
|
||||||
features: PLAN_FEATURES.SERVER,
|
features: planFeatures.SERVER,
|
||||||
maxUsers: "Unlimited users",
|
maxUsers: "Unlimited users",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -109,8 +114,8 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({
|
|||||||
price: 0,
|
price: 0,
|
||||||
currency: "",
|
currency: "",
|
||||||
period: "",
|
period: "",
|
||||||
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_MONTHLY,
|
highlights: planHighlights.ENTERPRISE_MONTHLY,
|
||||||
features: PLAN_FEATURES.ENTERPRISE,
|
features: planFeatures.ENTERPRISE,
|
||||||
maxUsers: "Custom",
|
maxUsers: "Custom",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -185,14 +185,20 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
|
|||||||
return (
|
return (
|
||||||
<Stack align="center" justify="center" p="xl">
|
<Stack align="center" justify="center" p="xl">
|
||||||
<Loader size="lg" />
|
<Loader size="lg" />
|
||||||
<Text c="dimmed">Loading session...</Text>
|
<Text c="dimmed">
|
||||||
|
{t("workflow.participant.loadingSession", "Loading session...")}
|
||||||
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Alert icon={<InfoIcon fontSize="small" />} color="red" title="Error">
|
<Alert
|
||||||
|
icon={<InfoIcon fontSize="small" />}
|
||||||
|
color="red"
|
||||||
|
title={t("workflow.participant.errorTitle", "Error")}
|
||||||
|
>
|
||||||
{error}
|
{error}
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
@@ -201,7 +207,10 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
|
|||||||
if (!session || !participant) {
|
if (!session || !participant) {
|
||||||
return (
|
return (
|
||||||
<Alert icon={<InfoIcon fontSize="small" />} color="orange">
|
<Alert icon={<InfoIcon fontSize="small" />} color="orange">
|
||||||
Session not found or access denied.
|
{t(
|
||||||
|
"workflow.participant.sessionNotFound",
|
||||||
|
"Session not found or access denied.",
|
||||||
|
)}
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -209,15 +218,35 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
|
|||||||
const getStatusBadge = (status: string) => {
|
const getStatusBadge = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "SIGNED":
|
case "SIGNED":
|
||||||
return <Badge color="green">Signed</Badge>;
|
return (
|
||||||
|
<Badge color="green">
|
||||||
|
{t("workflow.participant.statusSigned", "Signed")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
case "DECLINED":
|
case "DECLINED":
|
||||||
return <Badge color="red">Declined</Badge>;
|
return (
|
||||||
|
<Badge color="red">
|
||||||
|
{t("workflow.participant.statusDeclined", "Declined")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
case "VIEWED":
|
case "VIEWED":
|
||||||
return <Badge color="blue">Viewed</Badge>;
|
return (
|
||||||
|
<Badge color="blue">
|
||||||
|
{t("workflow.participant.statusViewed", "Viewed")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
case "NOTIFIED":
|
case "NOTIFIED":
|
||||||
return <Badge color="yellow">Notified</Badge>;
|
return (
|
||||||
|
<Badge color="yellow">
|
||||||
|
{t("workflow.participant.statusNotified", "Notified")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
case "PENDING":
|
case "PENDING":
|
||||||
return <Badge color="gray">Pending</Badge>;
|
return (
|
||||||
|
<Badge color="gray">
|
||||||
|
{t("workflow.participant.statusPending", "Pending")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return <Badge>{status}</Badge>;
|
return <Badge>{status}</Badge>;
|
||||||
}
|
}
|
||||||
@@ -254,7 +283,9 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
|
|||||||
{session.documentName}
|
{session.documentName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
From: {session.ownerUsername}
|
{t("workflow.participant.from", "From: {{name}}", {
|
||||||
|
name: session.ownerUsername,
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
{getStatusBadge(participant.status)}
|
{getStatusBadge(participant.status)}
|
||||||
@@ -272,7 +303,9 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
|
|||||||
|
|
||||||
{session.dueDate && (
|
{session.dueDate && (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Due Date: {session.dueDate}
|
{t("workflow.participant.dueDate", "Due Date: {{date}}", {
|
||||||
|
date: session.dueDate,
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -283,7 +316,7 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
|
|||||||
onClick={() => downloadDocument(token)}
|
onClick={() => downloadDocument(token)}
|
||||||
variant="light"
|
variant="light"
|
||||||
>
|
>
|
||||||
Download Document
|
{t("workflow.participant.downloadDocument", "Download Document")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -293,17 +326,35 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
|
|||||||
<Card shadow="sm" padding="md" radius="md" withBorder>
|
<Card shadow="sm" padding="md" radius="md" withBorder>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text fw={500} size="lg">
|
<Text fw={500} size="lg">
|
||||||
Sign Document
|
{t("workflow.participant.signDocument", "Sign Document")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
label="Certificate Type"
|
label={t(
|
||||||
|
"workflow.participant.certificateType",
|
||||||
|
"Certificate Type",
|
||||||
|
)}
|
||||||
value={certType}
|
value={certType}
|
||||||
onChange={(value) => setCertType(value || "P12")}
|
onChange={(value) => setCertType(value || "P12")}
|
||||||
data={[
|
data={[
|
||||||
{ value: "P12", label: "P12/PKCS12 Certificate" },
|
{
|
||||||
{ value: "JKS", label: "JKS Keystore" },
|
value: "P12",
|
||||||
{ value: "SERVER", label: "Server Certificate (if available)" },
|
label: t(
|
||||||
|
"workflow.participant.certTypeP12",
|
||||||
|
"P12/PKCS12 Certificate",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "JKS",
|
||||||
|
label: t("workflow.participant.certTypeJks", "JKS Keystore"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "SERVER",
|
||||||
|
label: t(
|
||||||
|
"workflow.participant.certTypeServer",
|
||||||
|
"Server Certificate (if available)",
|
||||||
|
),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
size="sm"
|
size="sm"
|
||||||
data-testid="cert-type-select"
|
data-testid="cert-type-select"
|
||||||
@@ -312,8 +363,14 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
|
|||||||
{certType !== "SERVER" && (
|
{certType !== "SERVER" && (
|
||||||
<>
|
<>
|
||||||
<FileInput
|
<FileInput
|
||||||
label="Certificate File"
|
label={t(
|
||||||
placeholder="Select certificate file"
|
"workflow.participant.certificateFile",
|
||||||
|
"Certificate File",
|
||||||
|
)}
|
||||||
|
placeholder={t(
|
||||||
|
"workflow.participant.certificateFilePlaceholder",
|
||||||
|
"Select certificate file",
|
||||||
|
)}
|
||||||
value={certFile}
|
value={certFile}
|
||||||
onChange={setCertFile}
|
onChange={setCertFile}
|
||||||
accept=".p12,.pfx,.jks"
|
accept=".p12,.pfx,.jks"
|
||||||
@@ -322,7 +379,10 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Certificate Password"
|
label={t(
|
||||||
|
"workflow.participant.certificatePassword",
|
||||||
|
"Certificate Password",
|
||||||
|
)}
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||||
@@ -386,23 +446,32 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Location"
|
label={t("workflow.participant.location", "Location")}
|
||||||
placeholder="e.g., San Francisco, CA"
|
placeholder={t(
|
||||||
|
"workflow.participant.locationPlaceholder",
|
||||||
|
"e.g., San Francisco, CA",
|
||||||
|
)}
|
||||||
value={location}
|
value={location}
|
||||||
onChange={(e) => setLocation(e.currentTarget.value)}
|
onChange={(e) => setLocation(e.currentTarget.value)}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Reason"
|
label={t("workflow.participant.reason", "Reason")}
|
||||||
placeholder="e.g., Document approval"
|
placeholder={t(
|
||||||
|
"workflow.participant.reasonPlaceholder",
|
||||||
|
"e.g., Document approval",
|
||||||
|
)}
|
||||||
value={reason}
|
value={reason}
|
||||||
onChange={(e) => setReason(e.currentTarget.value)}
|
onChange={(e) => setReason(e.currentTarget.value)}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Page Number (optional)"
|
label={t(
|
||||||
|
"workflow.participant.pageNumber",
|
||||||
|
"Page Number (optional)",
|
||||||
|
)}
|
||||||
type="number"
|
type="number"
|
||||||
value={pageNumber}
|
value={pageNumber}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -423,7 +492,7 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
|
|||||||
color="green"
|
color="green"
|
||||||
data-testid="submit-signature-button"
|
data-testid="submit-signature-button"
|
||||||
>
|
>
|
||||||
Submit Signature
|
{t("workflow.participant.submitSignature", "Submit Signature")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -433,7 +502,7 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
|
|||||||
variant="light"
|
variant="light"
|
||||||
data-testid="decline-button"
|
data-testid="decline-button"
|
||||||
>
|
>
|
||||||
Decline
|
{t("workflow.participant.decline", "Decline")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -442,14 +511,24 @@ const ParticipantView: React.FC<ParticipantViewProps> = ({ token }) => {
|
|||||||
|
|
||||||
{participant.hasCompleted && (
|
{participant.hasCompleted && (
|
||||||
<Alert icon={<CheckCircleIcon fontSize="small" />} color="green">
|
<Alert icon={<CheckCircleIcon fontSize="small" />} color="green">
|
||||||
You have {participant.status === "SIGNED" ? "signed" : "declined"}{" "}
|
{participant.status === "SIGNED"
|
||||||
this document.
|
? t(
|
||||||
|
"workflow.participant.completedSigned",
|
||||||
|
"You have signed this document.",
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"workflow.participant.completedDeclined",
|
||||||
|
"You have declined this document.",
|
||||||
|
)}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{participant.isExpired && (
|
{participant.isExpired && (
|
||||||
<Alert icon={<InfoIcon fontSize="small" />} color="orange">
|
<Alert icon={<InfoIcon fontSize="small" />} color="orange">
|
||||||
Your access to this document has expired.
|
{t(
|
||||||
|
"workflow.participant.accessExpired",
|
||||||
|
"Your access to this document has expired.",
|
||||||
|
)}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,100 +1,219 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import type { PlanFeature } from "@app/types/license";
|
import type { PlanFeature } from "@app/types/license";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared plan feature definitions for Stirling PDF Self-Hosted
|
* Shared plan feature definitions for Stirling PDF Self-Hosted
|
||||||
* Used by both dynamic (Stripe) and static (fallback) plan displays
|
* Used by both dynamic (Stripe) and static (fallback) plan displays.
|
||||||
|
*
|
||||||
|
* These are exposed as hooks so that every feature/highlight string can be
|
||||||
|
* localized via `t()` while preserving the original shape.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const PLAN_FEATURES = {
|
export interface PlanFeaturesMap {
|
||||||
|
FREE: PlanFeature[];
|
||||||
|
SERVER: PlanFeature[];
|
||||||
|
ENTERPRISE: PlanFeature[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanHighlightsMap {
|
||||||
|
FREE: string[];
|
||||||
|
SERVER_MONTHLY: string[];
|
||||||
|
SERVER_YEARLY: string[];
|
||||||
|
ENTERPRISE_MONTHLY: string[];
|
||||||
|
ENTERPRISE_YEARLY: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePlanFeatures = (): PlanFeaturesMap => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const selfHostedDeployment = t(
|
||||||
|
"plan.features.selfHostedDeployment",
|
||||||
|
"Self-hosted deployment",
|
||||||
|
);
|
||||||
|
const allPdfOperations = t(
|
||||||
|
"plan.features.allPdfOperations",
|
||||||
|
"All PDF operations",
|
||||||
|
);
|
||||||
|
const secureLoginSupport = t(
|
||||||
|
"plan.features.secureLoginSupport",
|
||||||
|
"Secure Login Support",
|
||||||
|
);
|
||||||
|
const communitySupport = t(
|
||||||
|
"plan.features.communitySupport",
|
||||||
|
"Community support",
|
||||||
|
);
|
||||||
|
const regularUpdates = t("plan.features.regularUpdates", "Regular updates");
|
||||||
|
const upToFiveUsersLowercase = t(
|
||||||
|
"plan.features.upToFiveUsersLowercase",
|
||||||
|
"up to 5 users",
|
||||||
|
);
|
||||||
|
const upToFiveUsers = t("plan.features.upToFiveUsers", "Up to 5 users");
|
||||||
|
const unlimitedUsers = t("plan.features.unlimitedUsers", "Unlimited users");
|
||||||
|
const googleDriveIntegration = t(
|
||||||
|
"plan.features.googleDriveIntegration",
|
||||||
|
"Google drive integration",
|
||||||
|
);
|
||||||
|
const externalDatabase = t(
|
||||||
|
"plan.features.externalDatabase",
|
||||||
|
"External Database",
|
||||||
|
);
|
||||||
|
const editingTextInPdfs = t(
|
||||||
|
"plan.features.editingTextInPdfs",
|
||||||
|
"Editing text in pdfs",
|
||||||
|
);
|
||||||
|
const usersLimitedToSeats = t(
|
||||||
|
"plan.features.usersLimitedToSeats",
|
||||||
|
"Users limited to seats",
|
||||||
|
);
|
||||||
|
const sso = t("plan.features.sso", "SSO");
|
||||||
|
const saml = t("plan.features.saml", "SAML");
|
||||||
|
const auditing = t("plan.features.auditing", "Auditing");
|
||||||
|
const usageTracking = t("plan.features.usageTracking", "Usage tracking");
|
||||||
|
const prometheusSupport = t(
|
||||||
|
"plan.features.prometheusSupport",
|
||||||
|
"Prometheus Support",
|
||||||
|
);
|
||||||
|
const customPdfMetadata = t(
|
||||||
|
"plan.features.customPdfMetadata",
|
||||||
|
"Custom PDF metadata",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
FREE: [
|
FREE: [
|
||||||
{ name: "Self-hosted deployment", included: true },
|
{ name: selfHostedDeployment, included: true },
|
||||||
{ name: "All PDF operations", included: true },
|
{ name: allPdfOperations, included: true },
|
||||||
{ name: "Secure Login Support", included: true },
|
{ name: secureLoginSupport, included: true },
|
||||||
{ name: "Community support", included: true },
|
{ name: communitySupport, included: true },
|
||||||
{ name: "Regular updates", included: true },
|
{ name: regularUpdates, included: true },
|
||||||
{ name: "up to 5 users", included: true },
|
{ name: upToFiveUsersLowercase, included: true },
|
||||||
{ name: "Unlimited users", included: false },
|
{ name: unlimitedUsers, included: false },
|
||||||
{ name: "Google drive integration", included: false },
|
{ name: googleDriveIntegration, included: false },
|
||||||
{ name: "External Database", included: false },
|
{ name: externalDatabase, included: false },
|
||||||
{ name: "Editing text in pdfs", included: false },
|
{ name: editingTextInPdfs, included: false },
|
||||||
{ name: "Users limited to seats", included: false },
|
{ name: usersLimitedToSeats, included: false },
|
||||||
{ name: "SSO", included: false },
|
{ name: sso, included: false },
|
||||||
{ name: "SAML", included: false },
|
{ name: saml, included: false },
|
||||||
{ name: "Auditing", included: false },
|
{ name: auditing, included: false },
|
||||||
{ name: "Usage tracking", included: false },
|
{ name: usageTracking, included: false },
|
||||||
{ name: "Prometheus Support", included: false },
|
{ name: prometheusSupport, included: false },
|
||||||
{ name: "Custom PDF metadata", included: false },
|
{ name: customPdfMetadata, included: false },
|
||||||
] as PlanFeature[],
|
],
|
||||||
|
|
||||||
SERVER: [
|
SERVER: [
|
||||||
{ name: "Self-hosted deployment", included: true },
|
{ name: selfHostedDeployment, included: true },
|
||||||
{ name: "All PDF operations", included: true },
|
{ name: allPdfOperations, included: true },
|
||||||
{ name: "Secure Login Support", included: true },
|
{ name: secureLoginSupport, included: true },
|
||||||
{ name: "Community support", included: true },
|
{ name: communitySupport, included: true },
|
||||||
{ name: "Regular updates", included: true },
|
{ name: regularUpdates, included: true },
|
||||||
{ name: "Up to 5 users", included: false },
|
{ name: upToFiveUsers, included: false },
|
||||||
{ name: "Unlimited users", included: true },
|
{ name: unlimitedUsers, included: true },
|
||||||
{ name: "Google drive integration", included: true },
|
{ name: googleDriveIntegration, included: true },
|
||||||
{ name: "External Database", included: true },
|
{ name: externalDatabase, included: true },
|
||||||
{ name: "Editing text in pdfs", included: true },
|
{ name: editingTextInPdfs, included: true },
|
||||||
{ name: "Users limited to seats", included: false },
|
{ name: usersLimitedToSeats, included: false },
|
||||||
{ name: "SSO", included: true },
|
{ name: sso, included: true },
|
||||||
{ name: "SAML", included: false },
|
{ name: saml, included: false },
|
||||||
{ name: "Auditing", included: false },
|
{ name: auditing, included: false },
|
||||||
{ name: "Usage tracking", included: false },
|
{ name: usageTracking, included: false },
|
||||||
{ name: "Prometheus Support", included: false },
|
{ name: prometheusSupport, included: false },
|
||||||
{ name: "Custom PDF metadata", included: false },
|
{ name: customPdfMetadata, included: false },
|
||||||
] as PlanFeature[],
|
],
|
||||||
|
|
||||||
ENTERPRISE: [
|
ENTERPRISE: [
|
||||||
{ name: "Self-hosted deployment", included: true },
|
{ name: selfHostedDeployment, included: true },
|
||||||
{ name: "All PDF operations", included: true },
|
{ name: allPdfOperations, included: true },
|
||||||
{ name: "Secure Login Support", included: true },
|
{ name: secureLoginSupport, included: true },
|
||||||
{ name: "Community support", included: true },
|
{ name: communitySupport, included: true },
|
||||||
{ name: "Regular updates", included: true },
|
{ name: regularUpdates, included: true },
|
||||||
{ name: "up to 5 users", included: false },
|
{ name: upToFiveUsersLowercase, included: false },
|
||||||
{ name: "Unlimited users", included: false },
|
{ name: unlimitedUsers, included: false },
|
||||||
{ name: "Google drive integration", included: true },
|
{ name: googleDriveIntegration, included: true },
|
||||||
{ name: "External Database", included: true },
|
{ name: externalDatabase, included: true },
|
||||||
{ name: "Editing text in pdfs", included: true },
|
{ name: editingTextInPdfs, included: true },
|
||||||
{ name: "Users limited to seats", included: true },
|
{ name: usersLimitedToSeats, included: true },
|
||||||
{ name: "SSO", included: true },
|
{ name: sso, included: true },
|
||||||
{ name: "SAML", included: true },
|
{ name: saml, included: true },
|
||||||
{ name: "Auditing", included: true },
|
{ name: auditing, included: true },
|
||||||
{ name: "Usage tracking", included: true },
|
{ name: usageTracking, included: true },
|
||||||
{ name: "Prometheus Support", included: true },
|
{ name: prometheusSupport, included: true },
|
||||||
{ name: "Custom PDF metadata", included: true },
|
{ name: customPdfMetadata, included: true },
|
||||||
] as PlanFeature[],
|
],
|
||||||
} as const;
|
};
|
||||||
|
}, [t]);
|
||||||
|
};
|
||||||
|
|
||||||
export const PLAN_HIGHLIGHTS = {
|
export const usePlanHighlights = (): PlanHighlightsMap => {
|
||||||
FREE: ["Up to 5 users", "Self-hosted", "All basic features"],
|
const { t } = useTranslation();
|
||||||
SERVER_MONTHLY: [
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const selfHostedOnInfrastructure = t(
|
||||||
|
"plan.highlights.selfHostedOnInfrastructure",
|
||||||
"Self-hosted on your infrastructure",
|
"Self-hosted on your infrastructure",
|
||||||
|
);
|
||||||
|
const unlimitedUsers = t(
|
||||||
|
"plan.highlights.unlimitedUsers",
|
||||||
"Unlimited users",
|
"Unlimited users",
|
||||||
|
);
|
||||||
|
const advancedIntegrations = t(
|
||||||
|
"plan.highlights.advancedIntegrations",
|
||||||
"Advanced integrations",
|
"Advanced integrations",
|
||||||
"SSO (OAuth2/OIDC)",
|
);
|
||||||
|
const ssoOAuth = t("plan.highlights.ssoOAuth", "SSO (OAuth2/OIDC)");
|
||||||
|
const editingTextInPdfsCaps = t(
|
||||||
|
"plan.highlights.editingTextInPdfsCaps",
|
||||||
"Editing text in PDFs",
|
"Editing text in PDFs",
|
||||||
"Cancel anytime",
|
);
|
||||||
|
const enterpriseFeatures = t(
|
||||||
|
"plan.highlights.enterpriseFeatures",
|
||||||
|
"Enterprise features (SAML, Auditing)",
|
||||||
|
);
|
||||||
|
const usageTrackingPrometheus = t(
|
||||||
|
"plan.highlights.usageTrackingPrometheus",
|
||||||
|
"Usage tracking & Prometheus",
|
||||||
|
);
|
||||||
|
const customPdfMetadata = t(
|
||||||
|
"plan.highlights.customPdfMetadata",
|
||||||
|
"Custom PDF metadata",
|
||||||
|
);
|
||||||
|
const saveWithAnnualBilling = t(
|
||||||
|
"plan.highlights.saveWithAnnualBilling",
|
||||||
|
"Save with annual billing",
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
FREE: [
|
||||||
|
t("plan.highlights.upToFiveUsers", "Up to 5 users"),
|
||||||
|
t("plan.highlights.selfHosted", "Self-hosted"),
|
||||||
|
t("plan.highlights.allBasicFeatures", "All basic features"),
|
||||||
|
],
|
||||||
|
SERVER_MONTHLY: [
|
||||||
|
selfHostedOnInfrastructure,
|
||||||
|
unlimitedUsers,
|
||||||
|
advancedIntegrations,
|
||||||
|
ssoOAuth,
|
||||||
|
editingTextInPdfsCaps,
|
||||||
|
t("plan.highlights.cancelAnytime", "Cancel anytime"),
|
||||||
],
|
],
|
||||||
SERVER_YEARLY: [
|
SERVER_YEARLY: [
|
||||||
"Self-hosted on your infrastructure",
|
selfHostedOnInfrastructure,
|
||||||
"Unlimited users",
|
unlimitedUsers,
|
||||||
"Advanced integrations",
|
advancedIntegrations,
|
||||||
"SSO (OAuth2/OIDC)",
|
ssoOAuth,
|
||||||
"Editing text in PDFs",
|
editingTextInPdfsCaps,
|
||||||
"Save with annual billing",
|
saveWithAnnualBilling,
|
||||||
],
|
],
|
||||||
ENTERPRISE_MONTHLY: [
|
ENTERPRISE_MONTHLY: [
|
||||||
"Enterprise features (SAML, Auditing)",
|
enterpriseFeatures,
|
||||||
"Usage tracking & Prometheus",
|
usageTrackingPrometheus,
|
||||||
"Custom PDF metadata",
|
customPdfMetadata,
|
||||||
"Per-seat licensing",
|
t("plan.highlights.perSeatLicensing", "Per-seat licensing"),
|
||||||
],
|
],
|
||||||
ENTERPRISE_YEARLY: [
|
ENTERPRISE_YEARLY: [
|
||||||
"Enterprise features (SAML, Auditing)",
|
enterpriseFeatures,
|
||||||
"Usage tracking & Prometheus",
|
usageTrackingPrometheus,
|
||||||
"Custom PDF metadata",
|
customPdfMetadata,
|
||||||
"Save with annual billing",
|
saveWithAnnualBilling,
|
||||||
],
|
],
|
||||||
} as const;
|
};
|
||||||
|
}, [t]);
|
||||||
|
};
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import {
|
|||||||
import { useLicense } from "@app/contexts/LicenseContext";
|
import { useLicense } from "@app/contexts/LicenseContext";
|
||||||
import { isSupabaseConfigured } from "@app/services/supabaseClient";
|
import { isSupabaseConfigured } from "@app/services/supabaseClient";
|
||||||
import { getPreferredCurrency } from "@app/utils/currencyDetection";
|
import { getPreferredCurrency } from "@app/utils/currencyDetection";
|
||||||
|
import {
|
||||||
|
usePlanFeatures,
|
||||||
|
usePlanHighlights,
|
||||||
|
} from "@app/constants/planConstants";
|
||||||
|
|
||||||
export interface CheckoutOptions {
|
export interface CheckoutOptions {
|
||||||
minimumSeats?: number; // Override calculated seats for enterprise
|
minimumSeats?: number; // Override calculated seats for enterprise
|
||||||
@@ -57,6 +61,8 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { refetchLicense } = useLicense();
|
const { refetchLicense } = useLicense();
|
||||||
|
const planFeatures = usePlanFeatures();
|
||||||
|
const planHighlights = usePlanHighlights();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [selectedPlanGroup, setSelectedPlanGroup] =
|
const [selectedPlanGroup, setSelectedPlanGroup] =
|
||||||
@@ -85,7 +91,11 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setPlansLoading(true);
|
setPlansLoading(true);
|
||||||
const response = await licenseService.getPlans(currency);
|
const response = await licenseService.getPlans(
|
||||||
|
planFeatures,
|
||||||
|
planHighlights,
|
||||||
|
currency,
|
||||||
|
);
|
||||||
setPlans(response.plans);
|
setPlans(response.plans);
|
||||||
setPlansLoaded(true);
|
setPlansLoaded(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -95,7 +105,7 @@ export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
|
|||||||
setPlansLoading(false);
|
setPlansLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[plansLoading],
|
[plansLoading, planFeatures, planHighlights],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refetchPlans = useCallback(() => {
|
const refetchPlans = useCallback(() => {
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import licenseService, {
|
import licenseService, {
|
||||||
PlanTier,
|
PlanTier,
|
||||||
PlansResponse,
|
PlansResponse,
|
||||||
} from "@app/services/licenseService";
|
} from "@app/services/licenseService";
|
||||||
|
import {
|
||||||
|
usePlanFeatures,
|
||||||
|
usePlanHighlights,
|
||||||
|
} from "@app/constants/planConstants";
|
||||||
|
|
||||||
export interface UsePlansReturn {
|
export interface UsePlansReturn {
|
||||||
plans: PlanTier[];
|
plans: PlanTier[];
|
||||||
@@ -12,16 +16,22 @@ export interface UsePlansReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const usePlans = (currency: string = "gbp"): UsePlansReturn => {
|
export const usePlans = (currency: string = "gbp"): UsePlansReturn => {
|
||||||
|
const planFeatures = usePlanFeatures();
|
||||||
|
const planHighlights = usePlanHighlights();
|
||||||
const [plans, setPlans] = useState<PlanTier[]>([]);
|
const [plans, setPlans] = useState<PlanTier[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const fetchPlans = async () => {
|
const fetchPlans = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const data: PlansResponse = await licenseService.getPlans(currency);
|
const data: PlansResponse = await licenseService.getPlans(
|
||||||
|
planFeatures,
|
||||||
|
planHighlights,
|
||||||
|
currency,
|
||||||
|
);
|
||||||
setPlans(data.plans);
|
setPlans(data.plans);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error fetching plans:", err);
|
console.error("Error fetching plans:", err);
|
||||||
@@ -29,11 +39,11 @@ export const usePlans = (currency: string = "gbp"): UsePlansReturn => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [currency, planFeatures, planHighlights]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPlans();
|
fetchPlans();
|
||||||
}, [currency]);
|
}, [fetchPlans]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plans,
|
plans,
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import apiClient from "@app/services/apiClient";
|
import apiClient from "@app/services/apiClient";
|
||||||
import { supabase, isSupabaseConfigured } from "@app/services/supabaseClient";
|
import { supabase, isSupabaseConfigured } from "@app/services/supabaseClient";
|
||||||
import { getCheckoutMode } from "@app/utils/protocolDetection";
|
import { getCheckoutMode } from "@app/utils/protocolDetection";
|
||||||
import { PLAN_FEATURES, PLAN_HIGHLIGHTS } from "@app/constants/planConstants";
|
import type {
|
||||||
|
PlanFeaturesMap,
|
||||||
|
PlanHighlightsMap,
|
||||||
|
} from "@app/constants/planConstants";
|
||||||
import type { LicenseInfo, PlanFeature } from "@app/types/license";
|
import type { LicenseInfo, PlanFeature } from "@app/types/license";
|
||||||
|
|
||||||
export interface PlanTier {
|
export interface PlanTier {
|
||||||
@@ -102,9 +105,18 @@ const SELF_HOSTED_LOOKUP_KEYS = [
|
|||||||
|
|
||||||
const licenseService = {
|
const licenseService = {
|
||||||
/**
|
/**
|
||||||
* Get available plans with pricing for the specified currency
|
* Get available plans with pricing for the specified currency.
|
||||||
|
*
|
||||||
|
* Feature and highlight labels are passed in by the caller (a React hook /
|
||||||
|
* context that resolves them via `usePlanFeatures` / `usePlanHighlights`),
|
||||||
|
* which keeps this service free of React/i18n dependencies while still
|
||||||
|
* returning fully localized plan content.
|
||||||
*/
|
*/
|
||||||
async getPlans(currency: string = "usd"): Promise<PlansResponse> {
|
async getPlans(
|
||||||
|
planFeatures: PlanFeaturesMap,
|
||||||
|
planHighlights: PlanHighlightsMap,
|
||||||
|
currency: string = "usd",
|
||||||
|
): Promise<PlansResponse> {
|
||||||
try {
|
try {
|
||||||
// Check if Supabase is configured
|
// Check if Supabase is configured
|
||||||
if (!isSupabaseConfigured || !supabase) {
|
if (!isSupabaseConfigured || !supabase) {
|
||||||
@@ -179,8 +191,8 @@ const licenseService = {
|
|||||||
currency: currencySymbol,
|
currency: currencySymbol,
|
||||||
period: "/month",
|
period: "/month",
|
||||||
popular: false,
|
popular: false,
|
||||||
features: PLAN_FEATURES.SERVER,
|
features: planFeatures.SERVER,
|
||||||
highlights: PLAN_HIGHLIGHTS.SERVER_MONTHLY,
|
highlights: planHighlights.SERVER_MONTHLY,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "selfhosted:server:yearly",
|
id: "selfhosted:server:yearly",
|
||||||
@@ -190,8 +202,8 @@ const licenseService = {
|
|||||||
currency: currencySymbol,
|
currency: currencySymbol,
|
||||||
period: "/year",
|
period: "/year",
|
||||||
popular: true,
|
popular: true,
|
||||||
features: PLAN_FEATURES.SERVER,
|
features: planFeatures.SERVER,
|
||||||
highlights: PLAN_HIGHLIGHTS.SERVER_YEARLY,
|
highlights: planHighlights.SERVER_YEARLY,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "selfhosted:enterprise:monthly",
|
id: "selfhosted:enterprise:monthly",
|
||||||
@@ -203,8 +215,8 @@ const licenseService = {
|
|||||||
period: "/month",
|
period: "/month",
|
||||||
popular: false,
|
popular: false,
|
||||||
requiresSeats: true,
|
requiresSeats: true,
|
||||||
features: PLAN_FEATURES.ENTERPRISE,
|
features: planFeatures.ENTERPRISE,
|
||||||
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_MONTHLY,
|
highlights: planHighlights.ENTERPRISE_MONTHLY,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "selfhosted:enterprise:yearly",
|
id: "selfhosted:enterprise:yearly",
|
||||||
@@ -216,8 +228,8 @@ const licenseService = {
|
|||||||
period: "/year",
|
period: "/year",
|
||||||
popular: false,
|
popular: false,
|
||||||
requiresSeats: true,
|
requiresSeats: true,
|
||||||
features: PLAN_FEATURES.ENTERPRISE,
|
features: planFeatures.ENTERPRISE,
|
||||||
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_YEARLY,
|
highlights: planHighlights.ENTERPRISE_YEARLY,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -240,8 +252,8 @@ const licenseService = {
|
|||||||
currency: currencySymbol,
|
currency: currencySymbol,
|
||||||
period: "",
|
period: "",
|
||||||
popular: false,
|
popular: false,
|
||||||
features: PLAN_FEATURES.FREE,
|
features: planFeatures.FREE,
|
||||||
highlights: PLAN_HIGHLIGHTS.FREE,
|
highlights: planHighlights.FREE,
|
||||||
};
|
};
|
||||||
|
|
||||||
const allPlans = [freePlan, ...validPlans];
|
const allPlans = [freePlan, ...validPlans];
|
||||||
|
|||||||
Reference in New Issue
Block a user