Compare commits

..
Author SHA1 Message Date
Reece 7912fec5ec Fix mime type error 2025-10-02 12:42:05 +01:00
06b4c147bd ph-no-capture tags (#4579)
added no capture tags to stop posthog capturing recordings of pdf
content

---------

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
2025-10-02 11:36:11 +01:00
25154e4dbe Feature/v2/more landing zones (#4575)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-02 10:40:33 +01:00
989eea9e24 Feature/viewer annotation toggle (#4557)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: James Brunton <james@stirlingpdf.com>
2025-10-02 10:40:18 +01:00
510e1c38eb Fix/v2/automate_settings_gap_fill (#4574)
All implemented tools now support automation bar Sign. Sign will need
custom automation UI support

---------

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
2025-10-01 23:13:54 +01:00
stirlingbot[bot]GitHubstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
ec05c5c049 🌐 [V2] Sync Translations + Update README Progress Table (#4569)
### Description of Changes

This Pull Request was automatically generated to synchronize updates to
translation files and documentation for the **V2 branch**. Below are the
details of the changes made:

#### **1. Synchronization of Translation Files**
- Updated translation files
(`frontend/public/locales/*/translation.json`) to reflect changes in the
reference file `en-GB/translation.json`.
- Ensured consistency and synchronization across all supported language
files.
- Highlighted any missing or incomplete translations.

#### **2. Update README.md**
- Generated the translation progress table in `README.md`.
- Added a summary of the current translation status for all supported
languages.
- Included up-to-date statistics on translation coverage.

#### **Why these changes are necessary**
- Keeps translation files aligned with the latest reference updates.
- Ensures the documentation reflects the current translation progress.

---

Auto-generated by [create-pull-request][1].

[1]: https://github.com/peter-evans/create-pull-request

Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-10-01 20:29:57 +01:00
d86a13cc89 shortcuts and config menu (#4530)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-01 20:22:04 +01:00
85dedf4b28 feat: add mobile slider layout for home page (#4571)
- Fix mobile view toggle buttons not working when clicked (use
offsetWidth instead of clientWidth)
- Add flag to prevent scroll handler interference during programmatic
scrolls
- Move Files button from header to bottom navigation bar
- Add bottom navigation bar with All Tools, Automate, and Files buttons
- Add RightRail to mobile workspace view
- Auto-switch to Tools view when clicking All Tools or Automate in
mobile
- Style bottom bar buttons as full-width clickable areas with labels

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com># Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-01 13:21:13 +01:00
0fa53185f2 Add translations for ar-AR, de-DE, fr-FR, it-IT, pt-BR, ru-RU and ena… (#4572)
…ble in frontend

- Updated ar-AR (Arabic) to 98.7% completion (1088 entries)
- Updated fr-FR (French) to 97.3% completion (1296 entries)
- Updated pt-BR (Portuguese Brazil) to 98.6% completion (1294 entries)
- Updated ru-RU (Russian) to 98.1% completion (1277 entries)
- Updated ja-JP (Japanese) to 73.4% completion (796 entries, batches
1-2)
- Updated es-ES minor corrections
- Enabled 8 languages with >90% completion in LanguageSelector
- Added JSON validation scripts for translation quality assurance
- RTL support already enabled for ar-AR

Enabled languages: en-GB, ar-AR, de-DE, es-ES, fr-FR, it-IT, pt-BR,
ru-RU, zh-CN

🤖 Generated with [Claude Code](https://claude.com/claude-code)

# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-01 12:58:41 +01:00
89 changed files with 14178 additions and 3358 deletions
+5
View File
@@ -192,6 +192,11 @@ return useToolOperation({
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
## Translation Rules
- **CRITICAL**: Always update translations in `en-GB` only, never `en-US`
- Translation files are located in `frontend/public/locales/`
## Important Notes
- **Java Version**: Minimum JDK 17, supports and recommends JDK 21
+24 -24
View File
@@ -115,45 +115,45 @@ Stirling-PDF currently supports 40 languages!
| Language | Progress |
| -------------------------------------------- | -------------------------------------- |
| Arabic (العربية) (ar_AR) | ![37%](https://geps.dev/progress/37) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![37%](https://geps.dev/progress/37) |
| Arabic (العربية) (ar_AR) | ![97%](https://geps.dev/progress/97) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![36%](https://geps.dev/progress/36) |
| Basque (Euskara) (eu_ES) | ![21%](https://geps.dev/progress/21) |
| Bulgarian (Български) (bg_BG) | ![40%](https://geps.dev/progress/40) |
| Catalan (Català) (ca_CA) | ![39%](https://geps.dev/progress/39) |
| Croatian (Hrvatski) (hr_HR) | ![36%](https://geps.dev/progress/36) |
| Bulgarian (Български) (bg_BG) | ![39%](https://geps.dev/progress/39) |
| Catalan (Català) (ca_CA) | ![38%](https://geps.dev/progress/38) |
| Croatian (Hrvatski) (hr_HR) | ![35%](https://geps.dev/progress/35) |
| Czech (Česky) (cs_CZ) | ![39%](https://geps.dev/progress/39) |
| Danish (Dansk) (da_DK) | ![35%](https://geps.dev/progress/35) |
| Dutch (Nederlands) (nl_NL) | ![35%](https://geps.dev/progress/35) |
| Dutch (Nederlands) (nl_NL) | ![34%](https://geps.dev/progress/34) |
| English (English) (en_GB) | ![100%](https://geps.dev/progress/100) |
| English (US) (en_US) | ![100%](https://geps.dev/progress/100) |
| French (Français) (fr_FR) | ![42%](https://geps.dev/progress/42) |
| German (Deutsch) (de_DE) | ![98%](https://geps.dev/progress/98) |
| French (Français) (fr_FR) | ![96%](https://geps.dev/progress/96) |
| German (Deutsch) (de_DE) | ![97%](https://geps.dev/progress/97) |
| Greek (Ελληνικά) (el_GR) | ![39%](https://geps.dev/progress/39) |
| Hindi (हिंदी) (hi_IN) | ![40%](https://geps.dev/progress/40) |
| Hindi (हिंदी) (hi_IN) | ![39%](https://geps.dev/progress/39) |
| Hungarian (Magyar) (hu_HU) | ![43%](https://geps.dev/progress/43) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![36%](https://geps.dev/progress/36) |
| Irish (Gaeilge) (ga_IE) | ![40%](https://geps.dev/progress/40) |
| Italian (Italiano) (it_IT) | ![98%](https://geps.dev/progress/98) |
| Japanese (日本語) (ja_JP) | ![41%](https://geps.dev/progress/41) |
| Korean (한국어) (ko_KR) | ![40%](https://geps.dev/progress/40) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![35%](https://geps.dev/progress/35) |
| Irish (Gaeilge) (ga_IE) | ![39%](https://geps.dev/progress/39) |
| Italian (Italiano) (it_IT) | ![97%](https://geps.dev/progress/97) |
| Japanese (日本語) (ja_JP) | ![72%](https://geps.dev/progress/72) |
| Korean (한국어) (ko_KR) | ![39%](https://geps.dev/progress/39) |
| Norwegian (Norsk) (no_NB) | ![37%](https://geps.dev/progress/37) |
| Persian (فارسی) (fa_IR) | ![39%](https://geps.dev/progress/39) |
| Persian (فارسی) (fa_IR) | ![38%](https://geps.dev/progress/38) |
| Polish (Polski) (pl_PL) | ![41%](https://geps.dev/progress/41) |
| Portuguese (Português) (pt_PT) | ![40%](https://geps.dev/progress/40) |
| Portuguese Brazilian (Português) (pt_BR) | ![42%](https://geps.dev/progress/42) |
| Portuguese (Português) (pt_PT) | ![39%](https://geps.dev/progress/39) |
| Portuguese Brazilian (Português) (pt_BR) | ![97%](https://geps.dev/progress/97) |
| Romanian (Română) (ro_RO) | ![33%](https://geps.dev/progress/33) |
| Russian (Русский) (ru_RU) | ![43%](https://geps.dev/progress/43) |
| Russian (Русский) (ru_RU) | ![96%](https://geps.dev/progress/96) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![43%](https://geps.dev/progress/43) |
| Simplified Chinese (简体中文) (zh_CN) | ![99%](https://geps.dev/progress/99) |
| Slovakian (Slovensky) (sk_SK) | ![30%](https://geps.dev/progress/30) |
| Slovenian (Slovenščina) (sl_SI) | ![41%](https://geps.dev/progress/41) |
| Spanish (Español) (es_ES) | ![98%](https://geps.dev/progress/98) |
| Simplified Chinese (简体中文) (zh_CN) | ![98%](https://geps.dev/progress/98) |
| Slovakian (Slovensky) (sk_SK) | ![29%](https://geps.dev/progress/29) |
| Slovenian (Slovenščina) (sl_SI) | ![40%](https://geps.dev/progress/40) |
| Spanish (Español) (es_ES) | ![97%](https://geps.dev/progress/97) |
| Swedish (Svenska) (sv_SE) | ![38%](https://geps.dev/progress/38) |
| Thai (ไทย) (th_TH) | ![35%](https://geps.dev/progress/35) |
| Tibetan (བོད་ཡིག་) (bo_CN) | ![65%](https://geps.dev/progress/65) |
| Traditional Chinese (繁體中文) (zh_TW) | ![43%](https://geps.dev/progress/43) |
| Turkish (Türkçe) (tr_TR) | ![43%](https://geps.dev/progress/43) |
| Ukrainian (Українська) (uk_UA) | ![42%](https://geps.dev/progress/42) |
| Turkish (Türkçe) (tr_TR) | ![42%](https://geps.dev/progress/42) |
| Ukrainian (Українська) (uk_UA) | ![41%](https://geps.dev/progress/41) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![32%](https://geps.dev/progress/32) |
| Malayalam (മലയാളം) (ml_IN) | ![73%](https://geps.dev/progress/73) |
@@ -1916,6 +1916,7 @@ fileManager.storageError=Storage error occurred
fileManager.storageLow=Storage is running low. Consider removing old files.
fileManager.uploadError=Failed to upload some files.
fileManager.supportMessage=Powered by browser database storage for unlimited capacity
fileManager.loadingFiles=Loading files...
# Page Editor
pageEditor.title=Page Editor
File diff suppressed because it is too large Load Diff
+26 -2
View File
@@ -255,6 +255,22 @@
"cacheInputs": {
"name": "Save form inputs",
"help": "Enable to store previously used inputs for future runs"
},
"hotkeys": {
"title": "Keyboard Shortcuts",
"description": "Hover a tool to see its shortcut or customise it below. Click \"Change shortcut\" and press a new key combination. Press Esc to cancel.",
"errorModifier": {
"mac": "Include ⌘ (Command), ⌥ (Option), or another modifier in your shortcut.",
"windows": "Include Ctrl, Alt, or another modifier in your shortcut."
},
"errorConflict": "Shortcut already used by {{tool}}.",
"none": "Not assigned",
"customBadge": "Custom",
"defaultLabel": "Default: {{shortcut}}",
"capturing": "Press keys… (Esc to cancel)",
"change": "Change shortcut",
"reset": "Reset",
"shortcut": "Shortcut"
}
},
"changeCreds": {
@@ -868,6 +884,7 @@
"rotate": {
"title": "Rotate PDF",
"submit": "Apply Rotation",
"selectRotation": "Select Rotation Angle (Clockwise)",
"error": {
"failed": "An error occurred while rotating the PDF."
},
@@ -3062,7 +3079,12 @@
"panMode": "Pan Mode",
"rotateLeft": "Rotate Left",
"rotateRight": "Rotate Right",
"toggleSidebar": "Toggle Sidebar"
"toggleSidebar": "Toggle Sidebar",
"exportSelected": "Export Selected Pages",
"toggleAnnotations": "Toggle Annotations Visibility",
"annotationMode": "Toggle Annotation Mode",
"draw": "Draw",
"save": "Save"
},
"search": {
"title": "Search PDF",
@@ -3131,6 +3153,9 @@
"addFiles": "Add Files",
"dragFilesInOrClick": "Drag files in or click \"Add Files\" to browse"
},
"fileEditor": {
"addFiles": "Add Files"
},
"fileManager": {
"title": "Upload PDF Files",
"subtitle": "Add files to your storage for easy access across tools",
@@ -3166,7 +3191,6 @@
"googleDriveShort": "Drive",
"myFiles": "My Files",
"noRecentFiles": "No recent files found",
"dropFilesHint": "Drop files here to upload",
"googleDriveNotAvailable": "Google Drive integration not available",
"openFiles": "Open Files",
"openFile": "Open File",
@@ -38,7 +38,7 @@
"save": "Guardar",
"saveToBrowser": "Guardar en el navegador",
"close": "Cerrar",
"filesSelected": "archivos seleccionados",
"filesSelected": "{{count}} archivos seleccionados",
"noFavourites": "No se agregaron favoritos",
"downloadComplete": "Descarga completada",
"bored": "¿Aburrido de esperar?",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10 -7
View File
@@ -4,6 +4,7 @@ import { FileContextProvider } from "./contexts/FileContext";
import { NavigationProvider } from "./contexts/NavigationContext";
import { FilesModalProvider } from "./contexts/FilesModalContext";
import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext";
import { HotkeyProvider } from "./contexts/HotkeyContext";
import { SidebarProvider } from "./contexts/SidebarContext";
import ErrorBoundary from "./components/shared/ErrorBoundary";
import HomePage from "./pages/HomePage";
@@ -44,15 +45,17 @@ export default function App() {
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<SidebarProvider>
<ViewerProvider>
<SignatureProvider>
<RightRailProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<SignatureProvider>
<RightRailProvider>
<HomePage />
</RightRailProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
+2 -2
View File
@@ -20,7 +20,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
const [isDragging, setIsDragging] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const { loadRecentFiles, handleRemoveFile } = useFileManager();
const { loadRecentFiles, handleRemoveFile, loading } = useFileManager();
// File management handlers
const isFileSupported = useCallback((fileName: string) => {
@@ -123,7 +123,6 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
onDrop={handleNewFileUpload}
onDragEnter={() => setIsDragging(true)}
onDragLeave={() => setIsDragging(false)}
accept={{}}
multiple={true}
activateOnClick={false}
style={{
@@ -147,6 +146,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
onFileRemove={handleRemoveFileByIndex}
modalHeight={modalHeight}
refreshRecentFiles={refreshRecentFiles}
isLoading={loading}
>
{isMobile ? <MobileLayout /> : <DesktopLayout />}
</FileManagerProvider>
@@ -0,0 +1,177 @@
import React, { useRef, useState } from 'react';
import { Button, Group, useMantineColorScheme } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import AddIcon from '@mui/icons-material/Add';
import { useFilesModalContext } from '../../contexts/FilesModalContext';
import LocalIcon from '../shared/LocalIcon';
import { BASE_PATH } from '../../constants/app';
import styles from './FileEditor.module.css';
interface AddFileCardProps {
onFileSelect: (files: File[]) => void;
accept?: string;
multiple?: boolean;
}
const AddFileCard = ({
onFileSelect,
accept,
multiple = true
}: AddFileCardProps) => {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const { openFilesModal } = useFilesModalContext();
const { colorScheme } = useMantineColorScheme();
const [isUploadHover, setIsUploadHover] = useState(false);
const handleCardClick = () => {
openFilesModal();
};
const handleNativeUploadClick = (e: React.MouseEvent) => {
e.stopPropagation();
fileInputRef.current?.click();
};
const handleOpenFilesModal = (e: React.MouseEvent) => {
e.stopPropagation();
openFilesModal();
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
if (files.length > 0) {
onFileSelect(files);
}
// Reset input so same files can be selected again
event.target.value = '';
};
return (
<>
<input
ref={fileInputRef}
type="file"
accept={accept}
multiple={multiple}
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<div
className={`${styles.addFileCard} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative cursor-pointer`}
tabIndex={0}
role="button"
aria-label={t('fileEditor.addFiles', 'Add files')}
onClick={handleCardClick}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleCardClick();
}
}}
>
{/* Header bar - matches FileEditorThumbnail structure */}
<div className={`${styles.header} ${styles.addFileHeader}`}>
<div className={styles.logoMark}>
<AddIcon sx={{ color: 'inherit', fontSize: '1.5rem' }} />
</div>
<div className={styles.headerIndex}>
{t('fileEditor.addFiles', 'Add Files')}
</div>
<div className={styles.kebab} />
</div>
{/* Main content area */}
<div className={styles.addFileContent}>
{/* Stirling PDF Branding */}
<Group gap="xs" align="center">
<img
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoWhiteText.svg` : `${BASE_PATH}/branding/StirlingPDFLogoGreyText.svg`}
alt="Stirling PDF"
style={{ height: '2.2rem', width: 'auto' }}
/>
</Group>
{/* Add Files + Native Upload Buttons - styled like LandingPage */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.6rem',
width: '100%',
marginTop: '0.8rem',
marginBottom: '0.8rem'
}}
onMouseLeave={() => setIsUploadHover(false)}
>
<Button
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '2rem',
height: '38px',
paddingLeft: isUploadHover ? 0 : '1rem',
paddingRight: isUploadHover ? 0 : '1rem',
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
minWidth: isUploadHover ? '58px' : undefined,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
}}
onClick={handleOpenFilesModal}
onMouseEnter={() => setIsUploadHover(false)}
>
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
{!isUploadHover && (
<span>
{t('landing.addFiles', 'Add Files')}
</span>
)}
</Button>
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
height: '38px',
width: isUploadHover ? 'calc(100% - 58px - 0.6rem)' : '58px',
minWidth: '58px',
paddingLeft: isUploadHover ? '1rem' : 0,
paddingRight: isUploadHover ? '1rem' : 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
}}
onClick={handleNativeUploadClick}
onMouseEnter={() => setIsUploadHover(true)}
>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
{isUploadHover && (
<span style={{ marginLeft: '.5rem' }}>
{t('landing.uploadFromComputer', 'Upload from computer')}
</span>
)}
</Button>
</div>
{/* Instruction Text */}
<span
className="text-[var(--accent-interactive)]"
style={{ fontSize: '.8rem', textAlign: 'center', marginTop: '0.5rem' }}
>
{t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')}
</span>
</div>
</div>
</>
);
};
export default AddFileCard;
@@ -304,4 +304,84 @@
/* Light mode selected header stroke override */
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
outline-color: #3B4B6E;
}
/* =========================
Add File Card Styles
========================= */
.addFileCard {
background: var(--file-card-bg);
border: 2px dashed var(--border-default);
border-radius: 0.0625rem;
cursor: pointer;
transition: all 0.18s ease;
max-width: 100%;
max-height: 100%;
overflow: hidden;
margin-left: 0.5rem;
margin-right: 0.5rem;
opacity: 0.7;
}
.addFileCard:hover {
opacity: 1;
border-color: var(--color-blue-500);
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.addFileCard:focus {
outline: 2px solid var(--color-blue-500);
outline-offset: 2px;
}
.addFileHeader {
background: var(--bg-subtle);
color: var(--text-secondary);
border-bottom: 1px solid var(--border-default);
}
.addFileCard:hover .addFileHeader {
background: var(--color-blue-500);
color: white;
}
.addFileContent {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1.5rem 1rem;
gap: 0.5rem;
}
.addFileIcon {
display: flex;
align-items: center;
justify-content: center;
width: 5rem;
height: 5rem;
border-radius: 50%;
background: var(--bg-subtle);
transition: background-color 0.18s ease;
}
.addFileCard:hover .addFileIcon {
background: var(--color-blue-50);
}
.addFileText {
font-weight: 500;
transition: color 0.18s ease;
}
.addFileCard:hover .addFileText {
color: var(--text-primary);
}
.addFileSubtext {
font-size: 0.875rem;
opacity: 0.8;
}
@@ -8,6 +8,7 @@ import { useNavigationActions } from '../../contexts/NavigationContext';
import { zipFileService } from '../../services/zipFileService';
import { detectFileExtension } from '../../utils/fileUtils';
import FileEditorThumbnail from './FileEditorThumbnail';
import AddFileCard from './AddFileCard';
import FilePickerModal from '../shared/FilePickerModal';
import SkeletonLoader from '../shared/SkeletonLoader';
import { FileId, StirlingFile } from '../../types/fileContext';
@@ -171,8 +172,8 @@ const FileEditor = ({
// Process all extracted files
if (allExtractedFiles.length > 0) {
// Add files to context (they will be processed automatically)
await addFiles(allExtractedFiles);
// Add files to context and select them automatically
await addFiles(allExtractedFiles, { selectFiles: true });
showStatus(`Added ${allExtractedFiles.length} files`, 'success');
}
} catch (err) {
@@ -405,6 +406,14 @@ const FileEditor = ({
pointerEvents: 'auto'
}}
>
{/* Add File Card - only show when files exist */}
{activeStirlingFileStubs.length > 0 && (
<AddFileCard
key="add-file-card"
onFileSelect={handleFileUpload}
/>
)}
{activeStirlingFileStubs.map((record, index) => {
return (
<FileEditorThumbnail
@@ -324,7 +324,7 @@ const FileEditorThumbnail = ({
marginTop: '0.5rem',
marginBottom: '0.5rem',
}}>
<Text size="lg" fw={700} className={styles.title} lineClamp={2}>
<Text size="lg" fw={700} className={`${styles.title} ph-no-capture `} lineClamp={2}>
{file.name}
</Text>
<Text
@@ -350,6 +350,7 @@ const FileEditorThumbnail = ({
<div className={styles.previewPaper}>
{file.thumbnailUrl && (
<img
className="ph-no-capture"
src={file.thumbnailUrl}
alt={file.name}
draggable={false}
@@ -0,0 +1,115 @@
import React, { useState } from 'react';
import { Button, Group, Text, Stack, useMantineColorScheme } from '@mantine/core';
import HistoryIcon from '@mui/icons-material/History';
import { useTranslation } from 'react-i18next';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
import LocalIcon from '../shared/LocalIcon';
import { BASE_PATH } from '../../constants/app';
const EmptyFilesState: React.FC = () => {
const { t } = useTranslation();
const { colorScheme } = useMantineColorScheme();
const { onLocalFileClick } = useFileManagerContext();
const [isUploadHover, setIsUploadHover] = useState(false);
const handleUploadClick = () => {
onLocalFileClick();
};
return (
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2rem'
}}
>
{/* Container */}
<div
style={{
backgroundColor: 'transparent',
padding: '3rem 2rem',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '1.5rem',
minWidth: '20rem',
maxWidth: '28rem',
width: '100%'
}}
>
{/* No Recent Files Message */}
<Stack align="center" gap="sm">
<HistoryIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-5)' }} />
<Text c="dimmed" ta="center" size="lg">
{t('fileManager.noRecentFiles', 'No recent files')}
</Text>
</Stack>
{/* Stirling PDF Logo */}
<Group gap="xs" align="center">
<img
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoWhiteText.svg` : `${BASE_PATH}/branding/StirlingPDFLogoGreyText.svg`}
alt="Stirling PDF"
style={{ height: '2.2rem', width: 'auto' }}
/>
</Group>
{/* Upload Button */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
marginTop: '0.5rem',
marginBottom: '0.5rem'
}}
onMouseLeave={() => setIsUploadHover(false)}
>
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--bg-file-manager)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: isUploadHover ? '2rem' : '1rem',
height: '38px',
width: isUploadHover ? '100%' : '58px',
minWidth: '58px',
paddingLeft: isUploadHover ? '1rem' : 0,
paddingRight: isUploadHover ? '1rem' : 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease, border-radius .5s ease'
}}
onClick={handleUploadClick}
onMouseEnter={() => setIsUploadHover(true)}
>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
{isUploadHover && (
<span style={{ marginLeft: '.5rem' }}>
{t('landing.uploadFromComputer', 'Upload from computer')}
</span>
)}
</Button>
</div>
{/* Instruction Text */}
<span
className="text-[var(--accent-interactive)]"
style={{ fontSize: '.8rem', textAlign: 'center' }}
>
{t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')}
</span>
</div>
</div>
);
};
export default EmptyFilesState;
@@ -26,7 +26,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
<ScrollArea style={{ flex: 1 }} p="md">
<Stack gap="sm">
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.fileName', 'Name')}</Text>
<Text className='ph-no-capture' size="sm" c="dimmed">{t('fileManager.fileName', 'Name')}</Text>
<Text size="sm" fw={500} style={{ maxWidth: '60%', textAlign: 'right' }} truncate>
{currentFile ? currentFile.name : ''}
</Text>
@@ -1,10 +1,10 @@
import React from 'react';
import { Center, ScrollArea, Text, Stack } from '@mantine/core';
import CloudIcon from '@mui/icons-material/Cloud';
import HistoryIcon from '@mui/icons-material/History';
import { useTranslation } from 'react-i18next';
import FileListItem from './FileListItem';
import FileHistoryGroup from './FileHistoryGroup';
import EmptyFilesState from './EmptyFilesState';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
interface FileListAreaProps {
@@ -29,6 +29,7 @@ const FileListArea: React.FC<FileListAreaProps> = ({
onFileDoubleClick,
onDownloadSingle,
isFileSupported,
isLoading,
} = useFileManagerContext();
const { t } = useTranslation();
@@ -43,15 +44,11 @@ const FileListArea: React.FC<FileListAreaProps> = ({
scrollbarSize={8}
>
<Stack gap={0}>
{recentFiles.length === 0 ? (
{recentFiles.length === 0 && !isLoading ? (
<EmptyFilesState />
) : recentFiles.length === 0 && isLoading ? (
<Center style={{ height: '12.5rem' }}>
<Stack align="center" gap="sm">
<HistoryIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-5)' }} />
<Text c="dimmed" ta="center">{t('fileManager.noRecentFiles', 'No recent files')}</Text>
<Text size="xs" c="dimmed" ta="center" style={{ opacity: 0.7 }}>
{t('fileManager.dropFilesHint', 'Drop files anywhere to upload')}
</Text>
</Stack>
<Text c="dimmed" ta="center">{t('fileManager.loadingFiles', 'Loading files...')}</Text>
</Center>
) : (
filteredFiles.map((file, index) => {
@@ -93,7 +93,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
<Box style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" align="center">
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>{file.name}</Text>
<Text size="sm" fw={500} className='ph-no-capture' truncate style={{ flex: 1 }}>{file.name}</Text>
<Badge size="xs" variant="light" color={"blue"}>
v{currentVersion}
</Badge>
@@ -9,7 +9,6 @@ const HiddenFileInput: React.FC = () => {
ref={fileInputRef}
type="file"
multiple={true}
accept={["*/*"] as any}
onChange={onFileInputChange}
style={{ display: 'none' }}
data-testid="file-input"
@@ -0,0 +1,58 @@
import React from 'react';
import { HotkeyBinding } from '../../utils/hotkeys';
import { useHotkeys } from '../../contexts/HotkeyContext';
interface HotkeyDisplayProps {
binding: HotkeyBinding | null | undefined;
size?: 'sm' | 'md';
muted?: boolean;
}
const baseKeyStyle: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '0.375rem',
background: 'var(--mantine-color-gray-1)',
border: '1px solid var(--mantine-color-gray-3)',
padding: '0.125rem 0.35rem',
fontSize: '0.75rem',
lineHeight: 1,
fontFamily: 'var(--mantine-font-family-monospace, monospace)',
minWidth: '1.35rem',
color: 'var(--mantine-color-text)',
};
export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({ binding, size = 'sm', muted = false }) => {
const { getDisplayParts } = useHotkeys();
const parts = getDisplayParts(binding);
if (!binding || parts.length === 0) {
return null;
}
const keyStyle = size === 'md'
? { ...baseKeyStyle, fontSize: '0.85rem', padding: '0.2rem 0.5rem' }
: baseKeyStyle;
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
color: muted ? 'var(--mantine-color-dimmed)' : 'inherit',
fontWeight: muted ? 500 : 600,
}}
>
{parts.map((part, index) => (
<React.Fragment key={`${part}-${index}`}>
<kbd style={keyStyle}>{part}</kbd>
{index < parts.length - 1 && <span aria-hidden style={{ fontWeight: 400 }}>+</span>}
</React.Fragment>
))}
</span>
);
};
export default HotkeyDisplay;
@@ -317,6 +317,7 @@ const FileThumbnail = ({
>
{file.thumbnail && (
<img
className="ph-no-capture"
src={file.thumbnail}
alt={file.name}
draggable={false}
@@ -371,6 +371,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
</div>
) : thumbnailUrl ? (
<img
className="ph-no-capture"
src={thumbnailUrl}
alt={`Page ${page.pageNumber}`}
draggable={false}
@@ -0,0 +1,130 @@
/* AppConfigModal styles */
.modal-container {
display: flex;
gap: 0;
height: 37.5rem; /* 600px */
}
.modal-nav {
width: 15rem; /* 240px */
height: 37.5rem; /* 600px */
border-top-left-radius: 0.75rem; /* 12px */
border-bottom-left-radius: 0.75rem; /* 12px */
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Mobile: compact icon-only navigation */
@media (max-width: 1024px) {
.modal-container {
height: 100vh !important;
max-height: none !important;
}
.modal-nav {
width: 5rem; /* 80px - wider for larger icons */
height: 100vh !important;
max-height: none !important;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.modal-nav-scroll {
padding: 1rem 0.5rem;
}
.modal-nav-section {
margin-bottom: 1.5rem;
}
.modal-nav-item.mobile {
padding: 1rem;
justify-content: center;
border-radius: 0.75rem;
margin-bottom: 0.75rem;
}
.modal-content {
height: 100vh !important;
max-height: none !important;
border-radius: 0;
}
}
.modal-nav-scroll {
flex: 1;
overflow-y: auto;
padding: 1rem;
padding-bottom: 2rem;
scrollbar-width: none;
-ms-overflow-style: none;
}
.modal-nav-scroll::-webkit-scrollbar {
display: none;
}
.modal-nav-section {
margin-bottom: 1rem;
}
.modal-nav-section-items {
margin-top: 0.5rem;
}
.modal-nav-item {
cursor: pointer;
padding: 0.5rem 0.625rem; /* 8px 10px */
border-radius: 0.5rem; /* 8px */
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem; /* 4px */
}
.modal-content {
flex: 1;
height: 37.5rem; /* 600px */
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-content-scroll {
flex: 1;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.modal-content-scroll::-webkit-scrollbar {
display: none;
}
.modal-header {
position: sticky;
top: 0;
z-index: 5;
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
}
.modal-body {
padding: 2rem;
padding-top: 1rem;
}
.confirm-modal-content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.confirm-modal-buttons {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
+131 -114
View File
@@ -1,6 +1,11 @@
import React from 'react';
import { Modal, Button, Stack, Text, Code, ScrollArea, Group, Badge, Alert, Loader } from '@mantine/core';
import { useAppConfig } from '../../hooks/useAppConfig';
import React, { useMemo, useState, useEffect } from 'react';
import { Modal, Text, ActionIcon } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import LocalIcon from './LocalIcon';
import Overview from './config/configSections/Overview';
import { createConfigNavSections } from './config/configNavSections';
import { NavKey } from './config/types';
import './AppConfigModal.css';
interface AppConfigModalProps {
opened: boolean;
@@ -8,131 +13,143 @@ interface AppConfigModalProps {
}
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
const { config, loading, error, refetch } = useAppConfig();
const [active, setActive] = useState<NavKey>('overview');
const isMobile = useMediaQuery("(max-width: 1024px)");
const renderConfigSection = (title: string, data: any) => {
if (!data || typeof data !== 'object') return null;
useEffect(() => {
const handler = (ev: Event) => {
const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined;
if (detail?.key) {
setActive(detail.key);
}
};
window.addEventListener('appConfig:navigate', handler as EventListener);
return () => window.removeEventListener('appConfig:navigate', handler as EventListener);
}, []);
return (
<Stack gap="xs" mb="md">
<Text fw={600} size="md" c="blue">{title}</Text>
<Stack gap="xs" pl="md">
{Object.entries(data).map(([key, value]) => (
<Group key={key} wrap="nowrap" align="flex-start">
<Text size="sm" w={150} style={{ flexShrink: 0 }} c="dimmed">
{key}:
</Text>
{typeof value === 'boolean' ? (
<Badge color={value ? 'green' : 'red'} size="sm">
{value ? 'true' : 'false'}
</Badge>
) : typeof value === 'object' ? (
<Code block>{JSON.stringify(value, null, 2)}</Code>
) : (
String(value) || 'null'
)}
</Group>
))}
</Stack>
</Stack>
);
const colors = useMemo(() => ({
navBg: 'var(--modal-nav-bg)',
sectionTitle: 'var(--modal-nav-section-title)',
navItem: 'var(--modal-nav-item)',
navItemActive: 'var(--modal-nav-item-active)',
navItemActiveBg: 'var(--modal-nav-item-active-bg)',
contentBg: 'var(--modal-content-bg)',
headerBorder: 'var(--modal-header-border)',
}), []);
// Placeholder logout handler (not needed in open-source but keeps SaaS compatibility)
const handleLogout = () => {
// In SaaS this would sign out, in open-source it does nothing
console.log('Logout placeholder for SaaS compatibility');
};
const basicConfig = config ? {
appName: config.appName,
appNameNavbar: config.appNameNavbar,
baseUrl: config.baseUrl,
contextPath: config.contextPath,
serverPort: config.serverPort,
} : null;
// Left navigation structure and icons
const configNavSections = useMemo(() =>
createConfigNavSections(
Overview,
handleLogout
),
[]
);
const securityConfig = config ? {
enableLogin: config.enableLogin,
} : null;
const activeLabel = useMemo(() => {
for (const section of configNavSections) {
const found = section.items.find(i => i.key === active);
if (found) return found.label;
}
return '';
}, [configNavSections, active]);
const systemConfig = config ? {
enableAlphaFunctionality: config.enableAlphaFunctionality,
enableAnalytics: config.enableAnalytics,
} : null;
const premiumConfig = config ? {
premiumEnabled: config.premiumEnabled,
premiumKey: config.premiumKey ? '***hidden***' : null,
runningProOrHigher: config.runningProOrHigher,
runningEE: config.runningEE,
license: config.license,
} : null;
const integrationConfig = config ? {
GoogleDriveEnabled: config.GoogleDriveEnabled,
SSOAutoLogin: config.SSOAutoLogin,
} : null;
const legalConfig = config ? {
termsAndConditions: config.termsAndConditions,
privacyPolicy: config.privacyPolicy,
cookiePolicy: config.cookiePolicy,
impressum: config.impressum,
accessibilityStatement: config.accessibilityStatement,
} : null;
const activeComponent = useMemo(() => {
for (const section of configNavSections) {
const found = section.items.find(i => i.key === active);
if (found) return found.component;
}
return null;
}, [configNavSections, active]);
return (
<Modal
opened={opened}
onClose={onClose}
title="App Configuration (Testing)"
size="lg"
title={null}
size={isMobile ? "100%" : 980}
centered
radius="lg"
withCloseButton={false}
style={{ zIndex: 1000 }}
overlayProps={{ opacity: 0.35, blur: 2 }}
padding={0}
fullScreen={isMobile}
>
<Stack>
<Group justify="space-between">
<Text size="sm" c="dimmed">
This modal shows the current application configuration for testing purposes only.
</Text>
<Button size="xs" variant="light" onClick={refetch}>
Refresh
</Button>
</Group>
<div className="modal-container">
{/* Left navigation */}
<div
className={`modal-nav ${isMobile ? 'mobile' : ''}`}
style={{
background: colors.navBg,
borderRight: `1px solid ${colors.headerBorder}`,
}}
>
<div className="modal-nav-scroll">
{configNavSections.map(section => (
<div key={section.title} className="modal-nav-section">
{!isMobile && (
<Text size="xs" fw={600} c={colors.sectionTitle} style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
{section.title}
</Text>
)}
<div className="modal-nav-section-items">
{section.items.map(item => {
const isActive = active === item.key;
const color = isActive ? colors.navItemActive : colors.navItem;
const iconSize = isMobile ? 28 : 18;
return (
<div
key={item.key}
onClick={() => setActive(item.key)}
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
style={{
background: isActive ? colors.navItemActiveBg : 'transparent',
}}
>
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
{!isMobile && (
<Text size="sm" fw={500} style={{ color }}>
{item.label}
</Text>
)}
</div>
);
})}
</div>
</div>
))}
</div>
</div>
{loading && (
<Stack align="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">Loading configuration...</Text>
</Stack>
)}
{error && (
<Alert color="red" title="Error">
{error}
</Alert>
)}
{config && (
<ScrollArea h={400}>
<Stack gap="lg">
{renderConfigSection('Basic Configuration', basicConfig)}
{renderConfigSection('Security Configuration', securityConfig)}
{renderConfigSection('System Configuration', systemConfig)}
{renderConfigSection('Premium/Enterprise Configuration', premiumConfig)}
{renderConfigSection('Integration Configuration', integrationConfig)}
{renderConfigSection('Legal Configuration', legalConfig)}
{config.error && (
<Alert color="yellow" title="Configuration Warning">
{config.error}
</Alert>
)}
<Stack gap="xs">
<Text fw={600} size="md" c="blue">Raw Configuration</Text>
<Code block style={{ fontSize: '11px' }}>
{JSON.stringify(config, null, 2)}
</Code>
</Stack>
</Stack>
</ScrollArea>
)}
</Stack>
{/* Right content */}
<div className="modal-content">
<div className="modal-content-scroll">
{/* Sticky header with section title and small close button */}
<div
className="modal-header"
style={{
background: colors.contentBg,
borderBottom: `1px solid ${colors.headerBorder}`,
}}
>
<Text fw={700} size="lg">{activeLabel}</Text>
<ActionIcon variant="subtle" onClick={onClose} aria-label="Close">
<LocalIcon icon="close-rounded" width={18} height={18} />
</ActionIcon>
</div>
<div className="modal-body">
{activeComponent}
</div>
</div>
</div>
</div>
</Modal>
);
};
@@ -15,7 +15,7 @@ interface FileUploadButtonProps {
const FileUploadButton = ({
file,
onChange,
accept = "*/*",
accept,
disabled = false,
placeholder,
variant = "outline",
@@ -41,7 +41,6 @@ const LandingPage = () => {
{/* White PDF Page Background */}
<Dropzone
onDrop={handleFileDrop}
accept={["application/pdf", "application/zip", "application/x-zip-compressed"]}
multiple={true}
className="w-4/5 flex items-center justify-center h-[95%]"
style={{
@@ -178,7 +177,6 @@ const LandingPage = () => {
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.zip"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
@@ -269,8 +269,9 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
<ScrollArea h={190} type="scroll">
<div className={styles.languageGrid}>
{languageOptions.map((option, index) => {
const isEnglishGB = option.value === 'en-GB'; // Currently only English GB has enough translations to use
const isDisabled = !isEnglishGB;
// Enable languages with >90% translation completion
const enabledLanguages = ['en-GB', 'ar-AR', 'de-DE', 'es-ES', 'fr-FR', 'it-IT', 'pt-BR', 'ru-RU', 'zh-CN'];
const isDisabled = !enabledLanguages.includes(option.value);
return (
<LanguageItem
@@ -8,7 +8,7 @@ interface NavigationWarningModalProps {
}
const NavigationWarningModal = ({
onApplyAndContinue,
onApplyAndContinue: _onApplyAndContinue,
onExportAndContinue
}: NavigationWarningModalProps) => {
@@ -30,13 +30,6 @@ const NavigationWarningModal = ({
confirmNavigation();
};
const _handleApplyAndContinue = async () => {
if (onApplyAndContinue) {
await onApplyAndContinue();
}
setHasUnsavedChanges(false);
confirmNavigation();
};
const handleExportAndContinue = async () => {
if (onExportAndContinue) {
@@ -85,7 +78,7 @@ const NavigationWarningModal = ({
</Button>
{/* TODO:: Add this back in when it works */}
{/* {onApplyAndContinue && (
{/* {_onApplyAndContinue && (
<Button
variant="light"
color="blue"
@@ -12,6 +12,7 @@ import { ButtonConfig } from '../../types/sidebar';
import './quickAccessBar/QuickAccessBar.css';
import AllToolsNavButton from './AllToolsNavButton';
import ActiveToolButton from "./quickAccessBar/ActiveToolButton";
import AppConfigModal from './AppConfigModal';
import {
isNavButtonActive,
getNavButtonStyle,
@@ -217,7 +218,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
<div className="spacer" />
{/* Config button at the bottom */}
{/* {buttonConfigs
{buttonConfigs
.filter(config => config.id === 'config')
.map(config => (
<div key={config.id} className="flex flex-col items-center gap-1">
@@ -237,14 +238,14 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
{config.name}
</span>
</div>
))} */}
))}
</div>
</div>
{/* <AppConfigModal
<AppConfigModal
opened={configModalOpen}
onClose={() => setConfigModalOpen(false)}
/> */}
/>
</div>
);
});
@@ -15,6 +15,7 @@ import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
import { SearchInterface } from '../viewer/SearchInterface';
import { ViewerContext } from '../../contexts/ViewerContext';
import { useSignature } from '../../contexts/SignatureContext';
import ViewerAnnotationControls from './rightRail/ViewerAnnotationControls';
import { parseSelection } from '../../utils/bulkselection/parseSelection';
@@ -293,6 +294,9 @@ export default function RightRail() {
<LocalIcon icon="view-list" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Annotation Controls */}
<ViewerAnnotationControls currentView={currentView} />
</div>
<Divider className="right-rail-divider" />
</div>
@@ -0,0 +1,57 @@
import React from 'react';
import { NavKey } from './types';
import HotkeysSection from './configSections/HotkeysSection';
export interface ConfigNavItem {
key: NavKey;
label: string;
icon: string;
component: React.ReactNode;
}
export interface ConfigNavSection {
title: string;
items: ConfigNavItem[];
}
export interface ConfigColors {
navBg: string;
sectionTitle: string;
navItem: string;
navItemActive: string;
navItemActiveBg: string;
contentBg: string;
headerBorder: string;
}
export const createConfigNavSections = (
Overview: React.ComponentType<{ onLogoutClick: () => void }>,
onLogoutClick: () => void
): ConfigNavSection[] => {
const sections: ConfigNavSection[] = [
{
title: 'Account',
items: [
{
key: 'overview',
label: 'Overview',
icon: 'person-rounded',
component: <Overview onLogoutClick={onLogoutClick} />
},
],
},
{
title: 'Preferences',
items: [
{
key: 'hotkeys',
label: 'Keyboard Shortcuts',
icon: 'keyboard-rounded',
component: <HotkeysSection />
},
],
},
];
return sections;
};
@@ -0,0 +1,171 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Badge, Box, Button, Divider, Group, Paper, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useToolWorkflow } from '../../../../contexts/ToolWorkflowContext';
import { useHotkeys } from '../../../../contexts/HotkeyContext';
import HotkeyDisplay from '../../../hotkeys/HotkeyDisplay';
import { bindingEquals, eventToBinding } from '../../../../utils/hotkeys';
const rowStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
};
const rowHeaderStyle: React.CSSProperties = {
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'space-between',
gap: '0.5rem',
};
const HotkeysSection: React.FC = () => {
const { t } = useTranslation();
const { toolRegistry } = useToolWorkflow();
const { hotkeys, defaults, updateHotkey, resetHotkey, pauseHotkeys, resumeHotkeys, getDisplayParts, isMac } = useHotkeys();
const [editingTool, setEditingTool] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const tools = useMemo(() => Object.entries(toolRegistry), [toolRegistry]);
useEffect(() => {
if (!editingTool) {
return;
}
pauseHotkeys();
return () => {
resumeHotkeys();
};
}, [editingTool, pauseHotkeys, resumeHotkeys]);
useEffect(() => {
if (!editingTool) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
event.preventDefault();
event.stopPropagation();
if (event.key === 'Escape') {
setEditingTool(null);
setError(null);
return;
}
const binding = eventToBinding(event as KeyboardEvent);
if (!binding) {
const osKey = isMac ? 'mac' : 'windows';
const fallbackText = isMac
? 'Include ⌘ (Command), ⌥ (Option), or another modifier in your shortcut.'
: 'Include Ctrl, Alt, or another modifier in your shortcut.';
setError(t(`settings.hotkeys.errorModifier.${osKey}`, fallbackText));
return;
}
const conflictEntry = Object.entries(hotkeys).find(([toolId, existing]) => (
toolId !== editingTool && bindingEquals(existing, binding)
));
if (conflictEntry) {
const conflictTool = toolRegistry[conflictEntry[0]]?.name ?? conflictEntry[0];
setError(t('settings.hotkeys.errorConflict', 'Shortcut already used by {{tool}}.', { tool: conflictTool }));
return;
}
updateHotkey(editingTool, binding);
setEditingTool(null);
setError(null);
};
window.addEventListener('keydown', handleKeyDown, true);
return () => {
window.removeEventListener('keydown', handleKeyDown, true);
};
}, [editingTool, hotkeys, toolRegistry, updateHotkey, t]);
const handleStartCapture = (toolId: string) => {
setEditingTool(toolId);
setError(null);
};
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">Keyboard Shortcuts</Text>
<Text size="sm" c="dimmed">
Customize keyboard shortcuts for quick tool access. Click "Change shortcut" and press a new key combination. Press Esc to cancel.
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
{tools.map(([toolId, tool], index) => {
const currentBinding = hotkeys[toolId];
const defaultBinding = defaults[toolId];
const isEditing = editingTool === toolId;
const defaultParts = getDisplayParts(defaultBinding);
const defaultLabel = defaultParts.length > 0
? defaultParts.join(' + ')
: t('settings.hotkeys.none', 'Not assigned');
return (
<React.Fragment key={toolId}>
<Box style={rowStyle} data-testid={`hotkey-row-${toolId}`}>
<div style={rowHeaderStyle}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', minWidth: 0 }}>
<Text fw={600}>{tool.name}</Text>
<Group gap="xs" wrap="wrap" align="center">
<HotkeyDisplay binding={currentBinding} size="md" />
{!bindingEquals(currentBinding, defaultBinding) && (
<Badge variant="light" color="orange" radius="sm">
{t('settings.hotkeys.customBadge', 'Custom')}
</Badge>
)}
<Text size="xs" c="dimmed">
{t('settings.hotkeys.defaultLabel', 'Default: {{shortcut}}', { shortcut: defaultLabel })}
</Text>
</Group>
</div>
<Group gap="xs">
<Button
size="xs"
variant={isEditing ? 'filled' : 'default'}
color={isEditing ? 'blue' : undefined}
onClick={() => handleStartCapture(toolId)}
>
{isEditing
? t('settings.hotkeys.capturing', 'Press keys… (Esc to cancel)')
: t('settings.hotkeys.change', 'Change shortcut')}
</Button>
<Button
size="xs"
variant="subtle"
disabled={bindingEquals(currentBinding, defaultBinding)}
onClick={() => resetHotkey(toolId)}
>
{t('settings.hotkeys.reset', 'Reset')}
</Button>
</Group>
</div>
{isEditing && error && (
<Alert color="red" radius="sm" variant="filled">
{error}
</Alert>
)}
</Box>
{index < tools.length - 1 && <Divider />}
</React.Fragment>
);
})}
</Stack>
</Paper>
</Stack>
);
};
export default HotkeysSection;
@@ -0,0 +1,102 @@
import React from 'react';
import { Stack, Text, Code, Group, Badge, Alert, Loader } from '@mantine/core';
import { useAppConfig } from '../../../../hooks/useAppConfig';
const Overview: React.FC = () => {
const { config, loading, error } = useAppConfig();
const renderConfigSection = (title: string, data: any) => {
if (!data || typeof data !== 'object') return null;
return (
<Stack gap="xs" mb="md">
<Text fw={600} size="md" c="blue">{title}</Text>
<Stack gap="xs" pl="md">
{Object.entries(data).map(([key, value]) => (
<Group key={key} wrap="nowrap" align="flex-start">
<Text size="sm" w={150} style={{ flexShrink: 0 }} c="dimmed">
{key}:
</Text>
{typeof value === 'boolean' ? (
<Badge color={value ? 'green' : 'red'} size="sm">
{value ? 'true' : 'false'}
</Badge>
) : typeof value === 'object' ? (
<Code block>{JSON.stringify(value, null, 2)}</Code>
) : (
String(value) || 'null'
)}
</Group>
))}
</Stack>
</Stack>
);
};
const basicConfig = config ? {
appName: config.appName,
appNameNavbar: config.appNameNavbar,
baseUrl: config.baseUrl,
contextPath: config.contextPath,
serverPort: config.serverPort,
} : null;
const securityConfig = config ? {
enableLogin: config.enableLogin,
} : null;
const systemConfig = config ? {
enableAlphaFunctionality: config.enableAlphaFunctionality,
enableAnalytics: config.enableAnalytics,
} : null;
const integrationConfig = config ? {
GoogleDriveEnabled: config.GoogleDriveEnabled,
SSOAutoLogin: config.SSOAutoLogin,
} : null;
if (loading) {
return (
<Stack align="center" py="md">
<Loader size="sm" />
<Text size="sm" c="dimmed">Loading configuration...</Text>
</Stack>
);
}
if (error) {
return (
<Alert color="red" title="Error">
{error}
</Alert>
);
}
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">Application Configuration</Text>
<Text size="sm" c="dimmed">
Current application settings and configuration details.
</Text>
</div>
{config && (
<>
{renderConfigSection('Basic Configuration', basicConfig)}
{renderConfigSection('Security Configuration', securityConfig)}
{renderConfigSection('System Configuration', systemConfig)}
{renderConfigSection('Integration Configuration', integrationConfig)}
{config.error && (
<Alert color="yellow" title="Configuration Warning">
{config.error}
</Alert>
)}
</>
)}
</Stack>
);
};
export default Overview;
@@ -0,0 +1,19 @@
export type NavKey =
| 'overview'
| 'preferences'
| 'notifications'
| 'connections'
| 'general'
| 'people'
| 'teams'
| 'security'
| 'identity'
| 'plan'
| 'payments'
| 'requests'
| 'developer'
| 'api-keys'
| 'hotkeys';
// some of these are not used yet, but appear in figma designs
@@ -19,7 +19,7 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
children
}) => {
if (!file) return null;
const containerStyle = {
position: 'relative' as const,
cursor: onClick ? 'pointer' : 'default',
@@ -36,6 +36,7 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
return (
<Box style={containerStyle} onClick={onClick}>
<Image
className='ph-no-capture'
src={thumbnail}
alt={`Preview of ${file.name}`}
fit="contain"
@@ -49,11 +50,12 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
return (
<Box style={containerStyle} onClick={onClick}>
<Center style={{ width: '100%', height: '100%', backgroundColor: 'var(--mantine-color-gray-1)', borderRadius: '0.25rem' }}>
<PictureAsPdfIcon
style={{
fontSize: '2rem',
color: 'var(--mantine-color-gray-6)'
}}
<PictureAsPdfIcon
className='ph-no-capture'
style={{
fontSize: '2rem',
color: 'var(--mantine-color-gray-6)'
}}
/>
</Center>
{children}
@@ -61,4 +63,4 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
);
};
export default DocumentThumbnail;
export default DocumentThumbnail;
@@ -67,7 +67,7 @@
}
.right-rail-slot.visible {
max-height: 18rem; /* increased to fit additional controls + divider */
max-height: 40rem; /* increased to fit additional controls + divider */
opacity: 1;
}
@@ -77,14 +77,14 @@
opacity: 0;
}
100% {
max-height: 18rem;
max-height: 40rem;
opacity: 1;
}
}
@keyframes rightRailShrinkUp {
0% {
max-height: 18rem;
max-height: 40rem;
opacity: 1;
}
100% {
@@ -0,0 +1,222 @@
import React, { useState, useEffect } from 'react';
import { ActionIcon, Popover } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '../LocalIcon';
import { Tooltip } from '../Tooltip';
import { ViewerContext } from '../../../contexts/ViewerContext';
import { useSignature } from '../../../contexts/SignatureContext';
import { ColorSwatchButton, ColorPicker } from '../../annotation/shared/ColorPicker';
import { useFileState, useFileContext } from '../../../contexts/FileContext';
import { generateThumbnailWithMetadata } from '../../../utils/thumbnailUtils';
import { createProcessedFile } from '../../../contexts/file/fileActions';
import { createStirlingFile, createNewStirlingFileStub } from '../../../types/fileContext';
interface ViewerAnnotationControlsProps {
currentView: string;
}
export default function ViewerAnnotationControls({ currentView }: ViewerAnnotationControlsProps) {
const { t } = useTranslation();
const [selectedColor, setSelectedColor] = useState('#000000');
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [isHoverColorPickerOpen, setIsHoverColorPickerOpen] = useState(false);
// Viewer context for PDF controls - safely handle when not available
const viewerContext = React.useContext(ViewerContext);
// Signature context for accessing drawing API
const { signatureApiRef } = useSignature();
// File state for save functionality
const { state, selectors } = useFileState();
const { actions: fileActions } = useFileContext();
const activeFiles = selectors.getFiles();
// Turn off annotation mode when switching away from viewer
useEffect(() => {
if (currentView !== 'viewer' && viewerContext?.isAnnotationMode) {
viewerContext.setAnnotationMode(false);
}
}, [currentView, viewerContext]);
return (
<>
{/* Annotation Visibility Toggle */}
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position="left" offset={12} arrow>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.toggleAnnotationsVisibility();
}}
disabled={currentView !== 'viewer' || viewerContext?.isAnnotationMode}
>
<LocalIcon
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
width="1.5rem"
height="1.5rem"
/>
</ActionIcon>
</Tooltip>
{/* Annotation Mode Toggle with Drawing Controls */}
{viewerContext?.isAnnotationMode ? (
// When active: Show color picker on hover
<div
onMouseEnter={() => setIsHoverColorPickerOpen(true)}
onMouseLeave={() => setIsHoverColorPickerOpen(false)}
style={{ display: 'inline-flex' }}
>
<Popover
opened={isHoverColorPickerOpen}
onClose={() => setIsHoverColorPickerOpen(false)}
position="left"
withArrow
shadow="md"
offset={8}
>
<Popover.Target>
<ActionIcon
variant="filled"
color="blue"
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.toggleAnnotationMode();
setIsHoverColorPickerOpen(false); // Close hover color picker when toggling off
// Deactivate drawing tool when exiting annotation mode
if (signatureApiRef?.current) {
try {
signatureApiRef.current.deactivateTools();
} catch (error) {
console.log('Signature API not ready:', error);
}
}
}}
disabled={currentView !== 'viewer'}
aria-label="Drawing mode active"
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: '8rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem', padding: '0.5rem' }}>
<div style={{ fontSize: '0.8rem', fontWeight: 500 }}>Drawing Color</div>
<ColorSwatchButton
color={selectedColor}
size={32}
onClick={() => {
setIsHoverColorPickerOpen(false); // Close hover picker
setIsColorPickerOpen(true); // Open main color picker modal
}}
/>
</div>
</div>
</Popover.Dropdown>
</Popover>
</div>
) : (
// When inactive: Show "Draw" tooltip
<Tooltip content={t('rightRail.draw', 'Draw')} position="left" offset={12} arrow>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.toggleAnnotationMode();
// Activate ink drawing tool when entering annotation mode
if (signatureApiRef?.current && currentView === 'viewer') {
try {
signatureApiRef.current.activateDrawMode();
signatureApiRef.current.updateDrawSettings(selectedColor, 2);
} catch (error) {
console.log('Signature API not ready:', error);
}
}
}}
disabled={currentView !== 'viewer'}
aria-label={typeof t === 'function' ? t('rightRail.draw', 'Draw') : 'Draw'}
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
)}
{/* Save PDF with Annotations */}
<Tooltip content={t('rightRail.save', 'Save')} position="left" offset={12} arrow>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={async () => {
if (viewerContext?.exportActions?.saveAsCopy && currentView === 'viewer') {
try {
const pdfArrayBuffer = await viewerContext.exportActions.saveAsCopy();
if (pdfArrayBuffer) {
// Create new File object with flattened annotations
const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
// Get the original file name or use a default
const originalFileName = activeFiles.length > 0 ? activeFiles[0].name : 'document.pdf';
const newFile = new File([blob], originalFileName, { type: 'application/pdf' });
// Replace the current file in context with the saved version (exact same logic as Sign tool)
if (activeFiles.length > 0) {
// Generate thumbnail and metadata for the saved file
const thumbnailResult = await generateThumbnailWithMetadata(newFile);
const processedFileMetadata = createProcessedFile(thumbnailResult.pageCount, thumbnailResult.thumbnail);
// Get current file info
const currentFileIds = state.files.ids;
if (currentFileIds.length > 0) {
const currentFileId = currentFileIds[0];
const currentRecord = selectors.getStirlingFileStub(currentFileId);
if (!currentRecord) {
console.error('No file record found for:', currentFileId);
return;
}
// Create output stub and file (exact same as Sign tool)
const outputStub = createNewStirlingFileStub(newFile, undefined, thumbnailResult.thumbnail, processedFileMetadata);
const outputStirlingFile = createStirlingFile(newFile, outputStub.id);
// Replace the original file with the saved version
await fileActions.consumeFiles([currentFileId], [outputStirlingFile], [outputStub]);
}
}
}
} catch (error) {
console.error('Error saving PDF:', error);
}
}
}}
disabled={currentView !== 'viewer'}
>
<LocalIcon icon="save" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Color Picker Modal */}
<ColorPicker
isOpen={isColorPickerOpen}
onClose={() => setIsColorPickerOpen(false)}
selectedColor={selectedColor}
onColorChange={(color) => {
setSelectedColor(color);
// Update drawing tool color if annotation mode is active
if (viewerContext?.isAnnotationMode && signatureApiRef?.current && currentView === 'viewer') {
try {
signatureApiRef.current.updateDrawSettings(color, 2);
} catch (error) {
console.log('Unable to update drawing settings:', error);
}
}
}}
title="Choose Drawing Color"
/>
</>
);
}
@@ -0,0 +1,119 @@
/**
* AddAttachmentsSettings - Shared settings component for both tool UI and automation
*
* Allows selecting files to attach to PDFs.
*/
import { Stack, Text, Group, ActionIcon, Alert, ScrollArea, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AddAttachmentsParameters } from "../../../hooks/tools/addAttachments/useAddAttachmentsParameters";
import LocalIcon from "../../shared/LocalIcon";
interface AddAttachmentsSettingsProps {
parameters: AddAttachmentsParameters;
onParameterChange: <K extends keyof AddAttachmentsParameters>(key: K, value: AddAttachmentsParameters[K]) => void;
disabled?: boolean;
}
const AddAttachmentsSettings = ({ parameters, onParameterChange, disabled = false }: AddAttachmentsSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="md">
<Alert color="blue" variant="light">
<Text size="sm">
{t("AddAttachmentsRequest.info", "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.")}
</Text>
</Alert>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("AddAttachmentsRequest.selectFiles", "Select Files to Attach")}
</Text>
<input
type="file"
multiple
onChange={(e) => {
const files = Array.from(e.target.files || []);
// Append to existing attachments instead of replacing
const newAttachments = [...(parameters.attachments || []), ...files];
onParameterChange('attachments', newAttachments);
// Reset the input so the same file can be selected again
e.target.value = '';
}}
disabled={disabled}
style={{ display: 'none' }}
id="attachments-input"
/>
<Button
size="xs"
color="blue"
component="label"
htmlFor="attachments-input"
disabled={disabled}
leftSection={<LocalIcon icon="plus" width="14" height="14" />}
>
{parameters.attachments?.length > 0
? t("AddAttachmentsRequest.addMoreFiles", "Add more files...")
: t("AddAttachmentsRequest.placeholder", "Choose files...")
}
</Button>
</Stack>
{parameters.attachments?.length > 0 && (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("AddAttachmentsRequest.selectedFiles", "Selected Files")} ({parameters.attachments.length})
</Text>
<ScrollArea.Autosize mah={300} type="scroll" offsetScrollbars styles={{ viewport: { overflowX: 'hidden' } }}>
<Stack gap="xs">
{parameters.attachments.map((file, index) => (
<Group key={index} justify="space-between" p="xs" style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 'var(--mantine-radius-sm)', alignItems: 'flex-start' }}>
<Group gap="xs" style={{ flex: 1, minWidth: 0, alignItems: 'flex-start' }}>
{/* Filename (two-line clamp, wraps, no icon on the left) */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 'var(--mantine-font-size-sm)',
fontWeight: 400,
lineHeight: 1.2,
display: '-webkit-box',
WebkitLineClamp: 2 as any,
WebkitBoxOrient: 'vertical' as any,
overflow: 'hidden',
whiteSpace: 'normal',
wordBreak: 'break-word',
}}
title={file.name}
>
{file.name}
</div>
</div>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
({(file.size / 1024).toFixed(1)} KB)
</Text>
</Group>
<ActionIcon
size="sm"
variant="subtle"
color="red"
style={{ flexShrink: 0 }}
onClick={() => {
const newAttachments = (parameters.attachments || []).filter((_, i) => i !== index);
onParameterChange('attachments', newAttachments);
}}
disabled={disabled}
>
<LocalIcon icon="close-rounded" width="14" height="14" />
</ActionIcon>
</Group>
))}
</Stack>
</ScrollArea.Autosize>
</Stack>
)}
</Stack>
);
};
export default AddAttachmentsSettings;
@@ -0,0 +1,77 @@
/**
* AddPageNumbersAppearanceSettings - Customize Appearance step
*/
import { Stack, Select, TextInput, NumberInput } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AddPageNumbersParameters } from "./useAddPageNumbersParameters";
import { Tooltip } from "../../shared/Tooltip";
interface AddPageNumbersAppearanceSettingsProps {
parameters: AddPageNumbersParameters;
onParameterChange: <K extends keyof AddPageNumbersParameters>(key: K, value: AddPageNumbersParameters[K]) => void;
disabled?: boolean;
}
const AddPageNumbersAppearanceSettings = ({
parameters,
onParameterChange,
disabled = false
}: AddPageNumbersAppearanceSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="md">
<Tooltip content={t('marginTooltip', 'Distance between the page number and the edge of the page.')}>
<Select
label={t('addPageNumbers.selectText.2', 'Margin')}
value={parameters.customMargin}
onChange={(v) => onParameterChange('customMargin', (v as any) || 'medium')}
data={[
{ value: 'small', label: t('sizes.small', 'Small') },
{ value: 'medium', label: t('sizes.medium', 'Medium') },
{ value: 'large', label: t('sizes.large', 'Large') },
{ value: 'x-large', label: t('sizes.x-large', 'Extra Large') },
]}
disabled={disabled}
/>
</Tooltip>
<Tooltip content={t('fontSizeTooltip', 'Size of the page number text in points. Larger numbers create bigger text.')}>
<NumberInput
label={t('addPageNumbers.fontSize', 'Font Size')}
value={parameters.fontSize}
onChange={(v) => onParameterChange('fontSize', typeof v === 'number' ? v : 12)}
min={1}
disabled={disabled}
/>
</Tooltip>
<Tooltip content={t('fontTypeTooltip', 'Font family for the page numbers. Choose based on your document style.')}>
<Select
label={t('addPageNumbers.fontName', 'Font Type')}
value={parameters.fontType}
onChange={(v) => onParameterChange('fontType', (v as any) || 'Times')}
data={[
{ value: 'Times', label: 'Times Roman' },
{ value: 'Helvetica', label: 'Helvetica' },
{ value: 'Courier', label: 'Courier New' },
]}
disabled={disabled}
/>
</Tooltip>
<Tooltip content={t('customTextTooltip', 'Optional custom format for page numbers. Use {n} as placeholder for the number. Example: "Page {n}" will show "Page 1", "Page 2", etc.')}>
<TextInput
label={t('addPageNumbers.selectText.6', 'Custom Text Format')}
value={parameters.customText || ''}
onChange={(e) => onParameterChange('customText', e.currentTarget.value)}
placeholder={t('addPageNumbers.customNumberDesc', 'e.g., "Page {n}" or leave blank for just numbers')}
disabled={disabled}
/>
</Tooltip>
</Stack>
);
};
export default AddPageNumbersAppearanceSettings;
@@ -0,0 +1,55 @@
/**
* AddPageNumbersAutomationSettings - Used for automation only
*
* Combines both position and appearance settings into a single view
*/
import { Stack, Divider, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AddPageNumbersParameters } from "./useAddPageNumbersParameters";
import AddPageNumbersPositionSettings from "./AddPageNumbersPositionSettings";
import AddPageNumbersAppearanceSettings from "./AddPageNumbersAppearanceSettings";
interface AddPageNumbersAutomationSettingsProps {
parameters: AddPageNumbersParameters;
onParameterChange: <K extends keyof AddPageNumbersParameters>(key: K, value: AddPageNumbersParameters[K]) => void;
disabled?: boolean;
}
const AddPageNumbersAutomationSettings = ({
parameters,
onParameterChange,
disabled = false
}: AddPageNumbersAutomationSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="lg">
{/* Position & Pages Section */}
<Stack gap="md">
<Text size="sm" fw={600}>{t("addPageNumbers.positionAndPages", "Position & Pages")}</Text>
<AddPageNumbersPositionSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
file={null}
showQuickGrid={true}
/>
</Stack>
<Divider />
{/* Appearance Section */}
<Stack gap="md">
<Text size="sm" fw={600}>{t("addPageNumbers.customize", "Customize Appearance")}</Text>
<AddPageNumbersAppearanceSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</Stack>
</Stack>
);
};
export default AddPageNumbersAutomationSettings;
@@ -0,0 +1,70 @@
/**
* AddPageNumbersPositionSettings - Position & Pages step
*/
import { Stack, TextInput, NumberInput, Divider, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AddPageNumbersParameters } from "./useAddPageNumbersParameters";
import { Tooltip } from "../../shared/Tooltip";
import PageNumberPreview from "./PageNumberPreview";
interface AddPageNumbersPositionSettingsProps {
parameters: AddPageNumbersParameters;
onParameterChange: <K extends keyof AddPageNumbersParameters>(key: K, value: AddPageNumbersParameters[K]) => void;
disabled?: boolean;
file?: File | null;
showQuickGrid?: boolean;
}
const AddPageNumbersPositionSettings = ({
parameters,
onParameterChange,
disabled = false,
file = null,
showQuickGrid = true
}: AddPageNumbersPositionSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="lg">
{/* Position Selection */}
<Stack gap="md">
<PageNumberPreview
parameters={parameters}
onParameterChange={onParameterChange}
file={file}
showQuickGrid={showQuickGrid}
/>
</Stack>
<Divider />
{/* Pages & Starting Number Section */}
<Stack gap="md">
<Text size="sm" fw={500} mb="xs">{t('addPageNumbers.pagesAndStarting', 'Pages & Starting Number')}</Text>
<Tooltip content={t('pageSelectionPrompt', 'Specify which pages to add numbers to. Examples: "1,3,5" for specific pages, "1-5" for ranges, "2n" for even pages, or leave blank for all pages.')}>
<TextInput
label={t('addPageNumbers.selectText.5', 'Pages to Number')}
value={parameters.pagesToNumber || ''}
onChange={(e) => onParameterChange('pagesToNumber', e.currentTarget.value)}
placeholder={t('addPageNumbers.numberPagesDesc', 'e.g., 1,3,5-8 or leave blank for all pages')}
disabled={disabled}
/>
</Tooltip>
<Tooltip content={t('startingNumberTooltip', 'The first number to display. Subsequent pages will increment from this number.')}>
<NumberInput
label={t('addPageNumbers.selectText.4', 'Starting Number')}
value={parameters.startingNumber}
onChange={(v) => onParameterChange('startingNumber', typeof v === 'number' ? v : 1)}
min={1}
disabled={disabled}
/>
</Tooltip>
</Stack>
</Stack>
);
};
export default AddPageNumbersPositionSettings;
@@ -200,7 +200,7 @@ export default function PageNumberPreview({ parameters, onParameterChange, file,
<img
src={pageThumbnail}
alt="page preview"
className={styles.pageThumbnail}
className={`${styles.pageThumbnail} ph-no-capture`}
draggable={false}
/>
)}
@@ -238,4 +238,4 @@ export default function PageNumberPreview({ parameters, onParameterChange, file,
</div>
</div>
);
}
}
@@ -0,0 +1,43 @@
/**
* AddStampAutomationSettings - Used for automation only
*
* This component combines all stamp settings into a single step interface
* for use in the automation system. It includes setup and formatting
* settings in one unified component.
*/
import { Stack } from "@mantine/core";
import { AddStampParameters } from "./useAddStampParameters";
import StampSetupSettings from "./StampSetupSettings";
import StampPositionFormattingSettings from "./StampPositionFormattingSettings";
interface AddStampAutomationSettingsProps {
parameters: AddStampParameters;
onParameterChange: <K extends keyof AddStampParameters>(key: K, value: AddStampParameters[K]) => void;
disabled?: boolean;
}
const AddStampAutomationSettings = ({ parameters, onParameterChange, disabled = false }: AddStampAutomationSettingsProps) => {
return (
<Stack gap="lg">
{/* Stamp Setup (Type, Text/Image, Page Selection) */}
<StampSetupSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
{/* Position and Formatting Settings */}
{parameters.stampType && (
<StampPositionFormattingSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
showPositionGrid={true}
/>
)}
</Stack>
);
};
export default AddStampAutomationSettings;
@@ -0,0 +1,201 @@
import { useTranslation } from "react-i18next";
import { Group, Select, Stack, ColorInput, Button, Slider, Text, NumberInput } from "@mantine/core";
import { AddStampParameters } from "./useAddStampParameters";
import LocalIcon from "../../shared/LocalIcon";
import styles from "./StampPreview.module.css";
import { Tooltip } from "../../shared/Tooltip";
interface StampPositionFormattingSettingsProps {
parameters: AddStampParameters;
onParameterChange: <K extends keyof AddStampParameters>(key: K, value: AddStampParameters[K]) => void;
disabled?: boolean;
showPositionGrid?: boolean; // When true, show the 9-position grid for automation
}
const StampPositionFormattingSettings = ({ parameters, onParameterChange, disabled = false, showPositionGrid = false }: StampPositionFormattingSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="md" justify="space-between">
{/* Position Grid - shown in automation settings */}
{showPositionGrid && (
<Stack gap="xs">
<Text size="sm" fw={500}>{t('AddStampRequest.position', 'Stamp Position')}</Text>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '0.5rem',
maxWidth: '200px'
}}>
{Array.from({ length: 9 }).map((_, i) => {
const idx = (i + 1) as 1|2|3|4|5|6|7|8|9;
const selected = parameters.position === idx;
return (
<Button
key={idx}
variant={selected ? 'filled' : 'outline'}
onClick={() => {
onParameterChange('position', idx);
// Ensure we're using grid positioning, not custom overrides
onParameterChange('overrideX', -1 as any);
onParameterChange('overrideY', -1 as any);
}}
disabled={disabled}
styles={{
root: {
height: '50px',
padding: '0',
}
}}
>
{idx}
</Button>
);
})}
</div>
</Stack>
)}
{/* Icon pill buttons row */}
<div className="flex justify-between gap-[0.5rem]">
<Tooltip content={t('AddStampRequest.rotation', 'Rotation')} position="top">
<Button
variant={parameters._activePill === 'rotation' ? 'filled' : 'outline'}
className="flex-1"
onClick={() => onParameterChange('_activePill', 'rotation')}
>
<LocalIcon icon="rotate-right-rounded" width="1.1rem" height="1.1rem" />
</Button>
</Tooltip>
<Tooltip content={t('AddStampRequest.opacity', 'Opacity')} position="top">
<Button
variant={parameters._activePill === 'opacity' ? 'filled' : 'outline'}
className="flex-1"
onClick={() => onParameterChange('_activePill', 'opacity')}
>
<LocalIcon icon="opacity" width="1.1rem" height="1.1rem" />
</Button>
</Tooltip>
<Tooltip content={parameters.stampType === 'image' ? t('AddStampRequest.imageSize', 'Image Size') : t('AddStampRequest.fontSize', 'Font Size')} position="top">
<Button
variant={parameters._activePill === 'fontSize' ? 'filled' : 'outline'}
className="flex-1"
onClick={() => onParameterChange('_activePill', 'fontSize')}
>
<LocalIcon icon="zoom-in-map-rounded" width="1.1rem" height="1.1rem" />
</Button>
</Tooltip>
</div>
{/* Single slider bound to selected pill */}
{parameters._activePill === 'fontSize' && (
<Stack gap="xs">
<Text className={styles.labelText}>
{parameters.stampType === 'image'
? t('AddStampRequest.imageSize', 'Image Size')
: t('AddStampRequest.fontSize', 'Font Size')
}
</Text>
<Group className={styles.sliderGroup} align="center">
<NumberInput
value={parameters.fontSize}
onChange={(v) => onParameterChange('fontSize', typeof v === 'number' ? v : 1)}
min={1}
max={400}
step={1}
size="sm"
className={styles.numberInput}
disabled={disabled}
/>
<Slider
value={parameters.fontSize}
onChange={(v) => onParameterChange('fontSize', v as number)}
min={1}
max={400}
step={1}
className={styles.slider}
/>
</Group>
</Stack>
)}
{parameters._activePill === 'rotation' && (
<Stack gap="xs">
<Text className={styles.labelText}>{t('AddStampRequest.rotation', 'Rotation')}</Text>
<Group className={styles.sliderGroup} align="center">
<NumberInput
value={parameters.rotation}
onChange={(v) => onParameterChange('rotation', typeof v === 'number' ? v : 0)}
min={-180}
max={180}
step={1}
size="sm"
className={styles.numberInput}
hideControls
disabled={disabled}
/>
<Slider
value={parameters.rotation}
onChange={(v) => onParameterChange('rotation', v as number)}
min={-180}
max={180}
step={1}
className={styles.sliderWide}
/>
</Group>
</Stack>
)}
{parameters._activePill === 'opacity' && (
<Stack gap="xs">
<Text className={styles.labelText}>{t('AddStampRequest.opacity', 'Opacity')}</Text>
<Group className={styles.sliderGroup} align="center">
<NumberInput
value={parameters.opacity}
onChange={(v) => onParameterChange('opacity', typeof v === 'number' ? v : 0)}
min={0}
max={100}
step={1}
size="sm"
className={styles.numberInput}
disabled={disabled}
/>
<Slider
value={parameters.opacity}
onChange={(v) => onParameterChange('opacity', v as number)}
min={0}
max={100}
step={1}
className={styles.slider}
/>
</Group>
</Stack>
)}
{parameters.stampType !== 'image' && (
<ColorInput
label={t('AddStampRequest.customColor', 'Custom Text Color')}
value={parameters.customColor}
onChange={(value) => onParameterChange('customColor', value)}
format="hex"
disabled={disabled}
/>
)}
{/* Margin selection for text stamps */}
{parameters.stampType === 'text' && (
<Select
label={t('AddStampRequest.margin', 'Margin')}
value={parameters.customMargin}
onChange={(v) => onParameterChange('customMargin', (v as any) || 'medium')}
data={[
{ value: 'small', label: t('margin.small', 'Small') },
{ value: 'medium', label: t('margin.medium', 'Medium') },
{ value: 'large', label: t('margin.large', 'Large') },
{ value: 'x-large', label: t('margin.xLarge', 'Extra Large') },
]}
disabled={disabled}
/>
)}
</Stack>
);
};
export default StampPositionFormattingSettings;
@@ -0,0 +1,112 @@
import { useTranslation } from "react-i18next";
import { Stack, Textarea, TextInput, Select, Button, Text, Divider } from "@mantine/core";
import { AddStampParameters } from "./useAddStampParameters";
import ButtonSelector from "../../shared/ButtonSelector";
import styles from "./StampPreview.module.css";
import { getDefaultFontSizeForAlphabet } from "./StampPreviewUtils";
interface StampSetupSettingsProps {
parameters: AddStampParameters;
onParameterChange: <K extends keyof AddStampParameters>(key: K, value: AddStampParameters[K]) => void;
disabled?: boolean;
}
const StampSetupSettings = ({ parameters, onParameterChange, disabled = false }: StampSetupSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="md">
<TextInput
label={t('pageSelectionPrompt', 'Page Selection (e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)')}
value={parameters.pageNumbers}
onChange={(e) => onParameterChange('pageNumbers', e.currentTarget.value)}
disabled={disabled}
/>
<Divider/>
<div>
<Text size="sm" fw={500} mb="xs">{t('AddStampRequest.stampType', 'Stamp Type')}</Text>
<ButtonSelector
value={parameters.stampType}
onChange={(v: 'text' | 'image') => onParameterChange('stampType', v)}
options={[
{ value: 'text', label: t('watermark.type.1', 'Text') },
{ value: 'image', label: t('watermark.type.2', 'Image') },
]}
disabled={disabled}
buttonClassName={styles.modeToggleButton}
textClassName={styles.modeToggleButtonText}
/>
</div>
{parameters.stampType === 'text' && (
<>
<Textarea
label={t('AddStampRequest.stampText', 'Stamp Text')}
value={parameters.stampText}
onChange={(e) => onParameterChange('stampText', e.currentTarget.value)}
autosize
minRows={2}
disabled={disabled}
/>
<Select
label={t('AddStampRequest.alphabet', 'Alphabet')}
value={parameters.alphabet}
onChange={(v) => {
const nextAlphabet = (v as any) || 'roman';
onParameterChange('alphabet', nextAlphabet);
const nextDefault = getDefaultFontSizeForAlphabet(nextAlphabet);
onParameterChange('fontSize', nextDefault);
}}
data={[
{ value: 'roman', label: 'Roman' },
{ value: 'arabic', label: 'العربية' },
{ value: 'japanese', label: '日本語' },
{ value: 'korean', label: '한국어' },
{ value: 'chinese', label: '简体中文' },
{ value: 'thai', label: 'ไทย' },
]}
disabled={disabled}
/>
</>
)}
{parameters.stampType === 'image' && (
<Stack gap="xs">
<input
type="file"
accept=".png,.jpg,.jpeg,.gif,.bmp,.tiff,.tif,.webp"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onParameterChange('stampImage', file);
}}
disabled={disabled}
style={{ display: 'none' }}
id="stamp-image-input"
/>
<Button
size="xs"
component="label"
htmlFor="stamp-image-input"
disabled={disabled}
>
{t('chooseFile', 'Choose File')}
</Button>
{parameters.stampImage && (
<Stack gap="xs">
<img
src={URL.createObjectURL(parameters.stampImage)}
alt="Selected stamp image"
className="max-h-24 w-full object-contain border border-gray-200 rounded bg-gray-50"
/>
<Text size="xs" c="dimmed">
{parameters.stampImage.name}
</Text>
</Stack>
)}
</Stack>
)}
</Stack>
);
};
export default StampSetupSettings;
@@ -35,7 +35,7 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
// Get tool info from registry
const toolInfo = toolRegistry[tool.operation as keyof ToolRegistry];
const SettingsComponent = toolInfo?.settingsComponent;
const SettingsComponent = toolInfo?.automationSettings;
// Initialize parameters from tool (which should contain defaults from registry)
useEffect(() => {
@@ -109,7 +109,7 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
{t('automate.config.description', 'Configure the settings for this tool. These settings will be applied when the automation runs.')}
</Text>
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
<div style={{ maxHeight: '60vh', overflowY: 'auto', overflowX: "hidden" }}>
{renderToolSettings()}
</div>
@@ -34,11 +34,14 @@ export default function ToolList({
const handleToolSelect = (index: number, newOperation: string) => {
const defaultParams = getToolDefaultParameters(newOperation);
const toolEntry = toolRegistry[newOperation];
// If tool has no settingsComponent, it's automatically configured
const isConfigured = !toolEntry?.automationSettings;
onToolUpdate(index, {
operation: newOperation,
name: getToolName(newOperation),
configured: false,
configured: isConfigured,
parameters: defaultParams,
});
};
@@ -1,7 +1,7 @@
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Stack, Text, ScrollArea } from '@mantine/core';
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
import { ToolRegistryEntry, getToolSupportsAutomate } from '../../../data/toolsTaxonomy';
import { useToolSections } from '../../../hooks/useToolSections';
import { renderToolButtons } from '../shared/renderToolButtons';
import ToolSearch from '../toolPicker/ToolSearch';
@@ -28,9 +28,11 @@ export default function ToolSelector({
const [shouldAutoFocus, setShouldAutoFocus] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Filter out excluded tools (like 'automate' itself)
// Filter out excluded tools (like 'automate' itself) and tools that don't support automation
const baseFilteredTools = useMemo(() => {
return Object.entries(toolRegistry).filter(([key]) => !excludeTools.includes(key));
return Object.entries(toolRegistry).filter(([key, tool]) =>
!excludeTools.includes(key) && getToolSupportsAutomate(tool)
);
}, [toolRegistry, excludeTools]);
// Apply search filter
@@ -0,0 +1,60 @@
/**
* CertSignAutomationSettings - Used for automation only
*
* This component combines all certificate signing settings into a single step interface
* for use in the automation system. It includes sign mode, certificate format, certificate files,
* and signature appearance settings in one unified component.
*/
import { Stack } from "@mantine/core";
import { CertSignParameters } from "../../../hooks/tools/certSign/useCertSignParameters";
import CertificateTypeSettings from "./CertificateTypeSettings";
import CertificateFormatSettings from "./CertificateFormatSettings";
import CertificateFilesSettings from "./CertificateFilesSettings";
import SignatureAppearanceSettings from "./SignatureAppearanceSettings";
interface CertSignAutomationSettingsProps {
parameters: CertSignParameters;
onParameterChange: <K extends keyof CertSignParameters>(key: K, value: CertSignParameters[K]) => void;
disabled?: boolean;
}
const CertSignAutomationSettings = ({ parameters, onParameterChange, disabled = false }: CertSignAutomationSettingsProps) => {
return (
<Stack gap="lg">
{/* Sign Mode Selection (Manual vs Auto) */}
<CertificateTypeSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
{/* Certificate Format - only show for Manual mode */}
{parameters.signMode === 'MANUAL' && (
<CertificateFormatSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
)}
{/* Certificate Files - only show for Manual mode */}
{parameters.signMode === 'MANUAL' && (
<CertificateFilesSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
)}
{/* Signature Appearance Settings */}
<SignatureAppearanceSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</Stack>
);
};
export default CertSignAutomationSettings;
@@ -0,0 +1,41 @@
/**
* CropAutomationSettings - Used for automation only
*
* Simplified crop settings for automation that doesn't require a file preview.
* Allows users to manually enter crop coordinates and dimensions.
*/
import { Stack } from "@mantine/core";
import { CropParameters } from "../../../hooks/tools/crop/useCropParameters";
import { Rectangle } from "../../../utils/cropCoordinates";
import CropCoordinateInputs from "./CropCoordinateInputs";
interface CropAutomationSettingsProps {
parameters: CropParameters;
onParameterChange: <K extends keyof CropParameters>(key: K, value: CropParameters[K]) => void;
disabled?: boolean;
}
const CropAutomationSettings = ({ parameters, onParameterChange, disabled = false }: CropAutomationSettingsProps) => {
// Handle coordinate changes
const handleCoordinateChange = (field: keyof Rectangle, value: number | string) => {
const numValue = typeof value === 'string' ? parseFloat(value) : value;
if (isNaN(numValue)) return;
const newCropArea = { ...parameters.cropArea, [field]: numValue };
onParameterChange('cropArea', newCropArea);
};
return (
<Stack gap="md">
<CropCoordinateInputs
cropArea={parameters.cropArea}
onCoordinateChange={handleCoordinateChange}
disabled={disabled}
showAutomationInfo={true}
/>
</Stack>
);
};
export default CropAutomationSettings;
@@ -0,0 +1,101 @@
import { Stack, Text, Group, NumberInput, Alert } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Rectangle, PDFBounds } from "../../../utils/cropCoordinates";
interface CropCoordinateInputsProps {
cropArea: Rectangle;
onCoordinateChange: (field: keyof Rectangle, value: number | string) => void;
disabled?: boolean;
pdfBounds?: PDFBounds;
showAutomationInfo?: boolean;
}
const CropCoordinateInputs = ({
cropArea,
onCoordinateChange,
disabled = false,
pdfBounds,
showAutomationInfo = false
}: CropCoordinateInputsProps) => {
const { t } = useTranslation();
return (
<Stack gap="xs">
{showAutomationInfo && (
<Alert color="blue" variant="light">
<Text size="xs">
{t("crop.automation.info", "Enter crop coordinates in PDF points. Origin (0,0) is at bottom-left. These values will be applied to all PDFs processed in this automation.")}
</Text>
</Alert>
)}
<Text size="sm" fw={500}>
{t("crop.coordinates.title", "Position and Size")}
</Text>
<Group grow>
<NumberInput
label={t("crop.coordinates.x", "X Position")}
description={showAutomationInfo ? t("crop.coordinates.x.desc", "Left edge (points)") : undefined}
value={Math.round(cropArea.x * 10) / 10}
onChange={(value) => onCoordinateChange('x', value)}
disabled={disabled}
min={0}
max={pdfBounds?.actualWidth}
step={0.1}
decimalScale={1}
size={showAutomationInfo ? "sm" : "xs"}
/>
<NumberInput
label={t("crop.coordinates.y", "Y Position")}
description={showAutomationInfo ? t("crop.coordinates.y.desc", "Bottom edge (points)") : undefined}
value={Math.round(cropArea.y * 10) / 10}
onChange={(value) => onCoordinateChange('y', value)}
disabled={disabled}
min={0}
max={pdfBounds?.actualHeight}
step={0.1}
decimalScale={1}
size={showAutomationInfo ? "sm" : "xs"}
/>
</Group>
<Group grow>
<NumberInput
label={t("crop.coordinates.width", "Width")}
description={showAutomationInfo ? t("crop.coordinates.width.desc", "Crop width (points)") : undefined}
value={Math.round(cropArea.width * 10) / 10}
onChange={(value) => onCoordinateChange('width', value)}
disabled={disabled}
min={0.1}
max={pdfBounds?.actualWidth}
step={0.1}
decimalScale={1}
size={showAutomationInfo ? "sm" : "xs"}
/>
<NumberInput
label={t("crop.coordinates.height", "Height")}
description={showAutomationInfo ? t("crop.coordinates.height.desc", "Crop height (points)") : undefined}
value={Math.round(cropArea.height * 10) / 10}
onChange={(value) => onCoordinateChange('height', value)}
disabled={disabled}
min={0.1}
max={pdfBounds?.actualHeight}
step={0.1}
decimalScale={1}
size={showAutomationInfo ? "sm" : "xs"}
/>
</Group>
{showAutomationInfo && (
<Alert color="gray" variant="light">
<Text size="xs">
{t("crop.automation.reference", "Reference: A4 page is 595.28 × 841.89 points (210mm × 297mm). 1 inch = 72 points.")}
</Text>
</Alert>
)}
</Stack>
);
};
export default CropCoordinateInputs;
@@ -1,10 +1,11 @@
import { useMemo, useState, useEffect } from "react";
import { Stack, Text, Box, Group, NumberInput, ActionIcon, Center, Alert } from "@mantine/core";
import { Stack, Text, Box, Group, ActionIcon, Center, Alert } from "@mantine/core";
import { useTranslation } from "react-i18next";
import RestartAltIcon from "@mui/icons-material/RestartAlt";
import { CropParametersHook } from "../../../hooks/tools/crop/useCropParameters";
import { useSelectedFiles } from "../../../contexts/file/fileHooks";
import CropAreaSelector from "./CropAreaSelector";
import CropCoordinateInputs from "./CropCoordinateInputs";
import { DEFAULT_CROP_AREA } from "../../../constants/cropConstants";
import { PAGE_SIZES } from "../../../constants/pageSizeConstants";
import {
@@ -190,71 +191,22 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => {
</Stack>
{/* Manual Coordinate Input */}
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("crop.coordinates.title", "Position and Size")}
</Text>
<CropCoordinateInputs
cropArea={cropArea}
onCoordinateChange={handleCoordinateChange}
disabled={disabled}
pdfBounds={pdfBounds}
showAutomationInfo={false}
/>
<Group grow>
<NumberInput
label={t("crop.coordinates.x", "X Position")}
value={Math.round(cropArea.x * 10) / 10}
onChange={(value) => handleCoordinateChange('x', value)}
disabled={disabled}
min={0}
max={pdfBounds.actualWidth}
step={0.1}
decimalScale={1}
size="xs"
/>
<NumberInput
label={t("crop.coordinates.y", "Y Position")}
value={Math.round(cropArea.y * 10) / 10}
onChange={(value) => handleCoordinateChange('y', value)}
disabled={disabled}
min={0}
max={pdfBounds.actualHeight}
step={0.1}
decimalScale={1}
size="xs"
/>
</Group>
<Group grow>
<NumberInput
label={t("crop.coordinates.width", "Width")}
value={Math.round(cropArea.width * 10) / 10}
onChange={(value) => handleCoordinateChange('width', value)}
disabled={disabled}
min={0.1}
max={pdfBounds.actualWidth}
step={0.1}
decimalScale={1}
size="xs"
/>
<NumberInput
label={t("crop.coordinates.height", "Height")}
value={Math.round(cropArea.height * 10) / 10}
onChange={(value) => handleCoordinateChange('height', value)}
disabled={disabled}
min={0.1}
max={pdfBounds.actualHeight}
step={0.1}
decimalScale={1}
size="xs"
/>
</Group>
{/* Validation Alert */}
{!isCropValid && (
<Alert color="red" variant="light">
<Text size="xs">
{t("crop.error.invalidArea", "Crop area extends beyond PDF boundaries")}
</Text>
</Alert>
)}
</Stack>
{/* Validation Alert */}
{!isCropValid && (
<Alert color="red" variant="light">
<Text size="xs">
{t("crop.error.invalidArea", "Crop area extends beyond PDF boundaries")}
</Text>
</Alert>
)}
</Stack>
);
};
@@ -16,16 +16,17 @@ const RemovePagesSettings = ({ parameters, onParameterChange, disabled = false }
// Allow user to type naturally - don't normalize input in real-time
onParameterChange('pageNumbers', value);
};
console.log('Current pageNumbers input:', parameters.pageNumbers, disabled);
// Check if current input is valid
const isValid = validatePageNumbers(parameters.pageNumbers);
const hasValue = parameters.pageNumbers.trim().length > 0;
const isValid = validatePageNumbers(parameters.pageNumbers || '');
const hasValue = (parameters?.pageNumbers?.trim().length ?? 0) > 0;
return (
<Stack gap="md">
<TextInput
label={t('removePages.pageNumbers.label', 'Pages to Remove')}
value={parameters.pageNumbers}
value={parameters.pageNumbers || ''}
onChange={(event) => handlePageNumbersChange(event.currentTarget.value)}
placeholder={t('removePages.pageNumbers.placeholder', 'e.g., 1,3,5-8,10')}
disabled={disabled}
@@ -0,0 +1,43 @@
/**
* RotateAutomationSettings - Used for automation only
*
* Simplified rotation settings for automation that allows selecting
* one of four 90-degree rotation angles.
*/
import { Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { RotateParameters } from "../../../hooks/tools/rotate/useRotateParameters";
import ButtonSelector from "../../shared/ButtonSelector";
interface RotateAutomationSettingsProps {
parameters: RotateParameters;
onParameterChange: <K extends keyof RotateParameters>(key: K, value: RotateParameters[K]) => void;
disabled?: boolean;
}
const RotateAutomationSettings = ({ parameters, onParameterChange, disabled = false }: RotateAutomationSettingsProps) => {
const { t } = useTranslation();
return (
<Stack gap="md">
<Text size="sm" fw={500}>
{t("rotate.selectRotation", "Select Rotation Angle (Clockwise)")}
</Text>
<ButtonSelector
value={parameters.angle}
onChange={(value: number) => onParameterChange('angle', value)}
options={[
{ value: 0, label: "0°" },
{ value: 90, label: "90°" },
{ value: 180, label: "180°" },
{ value: 270, label: "270°" },
]}
disabled={disabled}
/>
</Stack>
);
};
export default RotateAutomationSettings;
@@ -62,6 +62,7 @@ const ResultsPreview = ({
{/* File name at the top */}
<Box mb="sm" style={{ minHeight: '3rem', display: 'flex', alignItems: 'flex-start' }}>
<Text
className='ph-no-capture'
size="sm"
fw={500}
style={{
@@ -0,0 +1,62 @@
/**
* SplitAutomationSettings - Used for automation only
*
* Combines split method selection and method-specific settings
* into a single component for automation workflows.
*/
import { Stack, Text, Select } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { SplitParameters } from "../../../hooks/tools/split/useSplitParameters";
import { METHOD_OPTIONS, SplitMethod } from "../../../constants/splitConstants";
import SplitSettings from "./SplitSettings";
interface SplitAutomationSettingsProps {
parameters: SplitParameters;
onParameterChange: <K extends keyof SplitParameters>(key: K, value: SplitParameters[K]) => void;
disabled?: boolean;
}
const SplitAutomationSettings = ({ parameters, onParameterChange, disabled = false }: SplitAutomationSettingsProps) => {
const { t } = useTranslation();
// Convert METHOD_OPTIONS to Select data format
const methodSelectOptions = METHOD_OPTIONS.map((option) => {
const prefix = t(option.prefixKey, "Split");
const name = t(option.nameKey, "Method");
return {
value: option.value,
label: `${prefix} ${name}`,
};
});
return (
<Stack gap="lg">
{/* Method Selection */}
<Select
label={t("split.steps.chooseMethod", "Choose Method")}
placeholder={t("split.selectMethod", "Select a split method")}
value={parameters.method}
onChange={(value) => onParameterChange('method', value as (SplitMethod | '') || '')}
data={methodSelectOptions}
disabled={disabled}
/>
{/* Method-Specific Settings */}
{parameters.method && (
<>
<Text size="sm" fw={500}>
{t("split.steps.settings", "Settings")}
</Text>
<SplitSettings
parameters={parameters}
onParameterChange={onParameterChange}
disabled={disabled}
/>
</>
)}
</Stack>
);
};
export default SplitAutomationSettings;
@@ -1,10 +1,13 @@
import React from "react";
import { Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { Tooltip } from "../../shared/Tooltip";
import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
import { useToolNavigation } from "../../../hooks/useToolNavigation";
import { handleUnlessSpecialClick } from "../../../utils/clickHandlers";
import FitText from "../../shared/FitText";
import { useHotkeys } from "../../../contexts/HotkeyContext";
import HotkeyDisplay from "../../hotkeys/HotkeyDisplay";
interface ToolButtonProps {
id: string;
@@ -17,8 +20,11 @@ interface ToolButtonProps {
}
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym }) => {
const { t } = useTranslation();
// Special case: read and multiTool are navigational tools that are always available
const isUnavailable = !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
const { hotkeys } = useHotkeys();
const binding = hotkeys[id];
const { getToolNavigation } = useToolNavigation();
const handleClick = (id: string) => {
@@ -37,7 +43,17 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
const tooltipContent = isUnavailable
? (<span><strong>Coming soon:</strong> {tool.description}</span>)
: tool.description;
: (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<span>{tool.description}</span>
{binding && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem' }}>
<span style={{ color: 'var(--mantine-color-dimmed)', fontWeight: 500 }}>{t('settings.hotkeys.shortcut', 'Shortcut')}</span>
<HotkeyDisplay binding={binding} />
</div>
)}
</div>
);
const buttonContent = (
<>
@@ -29,16 +29,19 @@ const EmbedPdfViewerContent = ({
const { colorScheme: _colorScheme } = useMantineColorScheme();
const viewerRef = React.useRef<HTMLDivElement>(null);
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState } = useViewer();
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState, isAnnotationMode, isAnnotationsVisible } = useViewer();
const scrollState = getScrollState();
const zoomState = getZoomState();
const spreadState = getSpreadState();
// Check if we're in signature mode
// Check if we're in signature mode OR viewer annotation mode
const { selectedTool } = useNavigationState();
const isSignatureMode = selectedTool === 'sign';
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
// Get signature context
const { signatureApiRef, historyApiRef } = useSignature();
@@ -186,7 +189,7 @@ const EmbedPdfViewerContent = ({
<LocalEmbedPDF
file={effectiveFile.file}
url={effectiveFile.url}
enableSignature={isSignatureMode}
enableAnnotations={shouldEnableAnnotations}
signatureApiRef={signatureApiRef as React.RefObject<any>}
historyApiRef={historyApiRef as React.RefObject<any>}
onSignatureAdded={() => {
@@ -42,13 +42,13 @@ import { ExportAPIBridge } from './ExportAPIBridge';
interface LocalEmbedPDFProps {
file?: File | Blob;
url?: string | null;
enableSignature?: boolean;
enableAnnotations?: boolean;
onSignatureAdded?: (annotation: any) => void;
signatureApiRef?: React.RefObject<SignatureAPI>;
historyApiRef?: React.RefObject<HistoryAPI>;
}
export function LocalEmbedPDF({ file, url, enableSignature = false, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) {
export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) {
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
@@ -93,10 +93,10 @@ export function LocalEmbedPDF({ file, url, enableSignature = false, onSignatureA
createPluginRegistration(SelectionPluginPackage),
// Register history plugin for undo/redo (recommended for annotations)
...(enableSignature ? [createPluginRegistration(HistoryPluginPackage)] : []),
...(enableAnnotations ? [createPluginRegistration(HistoryPluginPackage)] : []),
// Register annotation plugin (depends on InteractionManager, Selection, History)
...(enableSignature ? [createPluginRegistration(AnnotationPluginPackage, {
...(enableAnnotations ? [createPluginRegistration(AnnotationPluginPackage, {
annotationAuthor: 'Digital Signature',
autoCommit: true,
deactivateToolAfterCreate: false,
@@ -182,19 +182,22 @@ export function LocalEmbedPDF({ file, url, enableSignature = false, onSignatureA
// Wrap your UI with the <EmbedPDF> provider
return (
<div style={{
height: '100%',
width: '100%',
position: 'relative',
overflow: 'hidden',
flex: 1,
minHeight: 0,
minWidth: 0
<div
className='ph-no-capture'
style={{
height: '100%',
width: '100%',
position: 'relative',
overflow: 'hidden',
flex: 1,
minHeight: 0,
minWidth: 0,
}}>
<EmbedPDF
engine={engine}
plugins={plugins}
onInitialized={enableSignature ? async (registry) => {
onInitialized={enableAnnotations ? async (registry) => {
const annotationPlugin = registry.getPlugin('annotation');
if (!annotationPlugin || !annotationPlugin.provides) return;
@@ -265,8 +268,8 @@ export function LocalEmbedPDF({ file, url, enableSignature = false, onSignatureA
<SearchAPIBridge />
<ThumbnailAPIBridge />
<RotateAPIBridge />
{enableSignature && <SignatureAPIBridge ref={signatureApiRef} />}
{enableSignature && <HistoryAPIBridge ref={historyApiRef} />}
{enableAnnotations && <SignatureAPIBridge ref={signatureApiRef} />}
{enableAnnotations && <HistoryAPIBridge ref={historyApiRef} />}
<ExportAPIBridge />
<GlobalPointerProvider>
<Viewport
@@ -310,9 +313,9 @@ export function LocalEmbedPDF({ file, url, enableSignature = false, onSignatureA
<CustomSearchLayer pageIndex={pageIndex} scale={scale} />
{/* Selection layer for text interaction */}
<SelectionLayer pageIndex={pageIndex} scale={scale} />
<SelectionLayer pageIndex={pageIndex} scale={scale} />
{/* Annotation layer for signatures (only when enabled) */}
{enableSignature && (
{enableAnnotations && (
<AnnotationLayer
pageIndex={pageIndex}
scale={scale}
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { createPluginRegistration } from '@embedpdf/core';
import { EmbedPDF } from '@embedpdf/core/react';
import { usePdfiumEngine } from '@embedpdf/engines/react';
@@ -312,4 +312,4 @@ export function LocalEmbedPDFWithAnnotations({
</EmbedPDF>
</div>
);
}
}
@@ -3,6 +3,7 @@ import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
import { PdfAnnotationSubtype, PdfStandardFont, PdfTextAlignment, PdfVerticalAlignment, uuidV4 } from '@embedpdf/models';
import { SignParameters } from '../../hooks/tools/sign/useSignParameters';
import { useSignature } from '../../contexts/SignatureContext';
import { useViewer } from '../../contexts/ViewerContext';
export interface SignatureAPI {
addImageSignature: (signatureData: string, x: number, y: number, width: number, height: number, pageIndex: number) => void;
@@ -20,11 +21,12 @@ export interface SignatureAPI {
export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPIBridge(_, ref) {
const { provides: annotationApi } = useAnnotationCapability();
const { signatureConfig, storeImageData, isPlacementMode } = useSignature();
const { isAnnotationMode } = useViewer();
// Enable keyboard deletion of selected annotations - only when in signature placement mode
// Enable keyboard deletion of selected annotations - when in signature placement mode or viewer annotation mode
useEffect(() => {
if (!annotationApi || !isPlacementMode) return;
if (!annotationApi || (!isPlacementMode && !isAnnotationMode)) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Delete' || event.key === 'Backspace') {
@@ -65,7 +67,7 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [annotationApi, storeImageData, isPlacementMode]);
}, [annotationApi, storeImageData, isPlacementMode, isAnnotationMode]);
useImperativeHandle(ref, () => ({
addImageSignature: (signatureData: string, x: number, y: number, width: number, height: number, pageIndex: number) => {
@@ -134,6 +134,7 @@ export function ThumbnailSidebar({ visible, onToggle: _onToggle }: ThumbnailSide
{/* Thumbnail Image */}
{thumbnails[pageIndex] && thumbnails[pageIndex] !== 'error' ? (
<img
className='ph-no-capture'
src={thumbnails[pageIndex]}
alt={`Page ${pageIndex + 1} thumbnail`}
style={{
@@ -15,7 +15,19 @@ export function ZoomAPIBridge() {
if (zoom && !hasSetInitialZoom.current) {
hasSetInitialZoom.current = true;
setTimeout(() => {
zoom.requestZoom(1.4);
try {
zoom.requestZoom(1.4);
} catch (error) {
console.log('Zoom initialization delayed, viewport not ready:', error);
// Retry after a longer delay
setTimeout(() => {
try {
zoom.requestZoom(1.4);
} catch (retryError) {
console.log('Zoom initialization failed:', retryError);
}
}, 200);
}
}, 50);
}
}, [zoom]);
@@ -18,6 +18,7 @@ interface FileManagerContextValue {
expandedFileIds: Set<FileId>;
fileGroups: Map<FileId, StirlingFileStub[]>;
loadedHistoryFiles: Map<FileId, StirlingFileStub[]>;
isLoading: boolean;
// Handlers
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
@@ -58,6 +59,7 @@ interface FileManagerProviderProps {
onFileRemove: (index: number) => void;
modalHeight: string;
refreshRecentFiles: () => Promise<void>;
isLoading: boolean;
}
export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
@@ -71,6 +73,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
onFileRemove,
modalHeight,
refreshRecentFiles,
isLoading,
}) => {
const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent');
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>([]);
@@ -574,6 +577,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
expandedFileIds,
fileGroups,
loadedHistoryFiles,
isLoading,
// Handlers
onSourceChange: handleSourceChange,
@@ -607,6 +611,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
expandedFileIds,
fileGroups,
loadedHistoryFiles,
isLoading,
handleSourceChange,
handleLocalFileClick,
handleFileSelect,
+211
View File
@@ -0,0 +1,211 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { HotkeyBinding, bindingEquals, bindingMatchesEvent, deserializeBindings, getDisplayParts, isMacLike, normalizeBinding, serializeBindings } from '../utils/hotkeys';
import { useToolWorkflow } from './ToolWorkflowContext';
import { ToolId } from '../types/toolId';
interface HotkeyContextValue {
hotkeys: Record<string, HotkeyBinding>;
defaults: Record<string, HotkeyBinding>;
isMac: boolean;
updateHotkey: (toolId: string, binding: HotkeyBinding) => void;
resetHotkey: (toolId: string) => void;
isBindingAvailable: (binding: HotkeyBinding, excludeToolId?: string) => boolean;
pauseHotkeys: () => void;
resumeHotkeys: () => void;
areHotkeysPaused: boolean;
getDisplayParts: (binding: HotkeyBinding | null | undefined) => string[];
}
const HotkeyContext = createContext<HotkeyContextValue | undefined>(undefined);
const STORAGE_KEY = 'stirlingpdf.hotkeys';
const KEY_ORDER: string[] = [
'Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6', 'Digit7', 'Digit8', 'Digit9', 'Digit0',
'KeyQ', 'KeyW', 'KeyE', 'KeyR', 'KeyT', 'KeyY', 'KeyU', 'KeyI', 'KeyO', 'KeyP',
'KeyA', 'KeyS', 'KeyD', 'KeyF', 'KeyG', 'KeyH', 'KeyJ', 'KeyK', 'KeyL',
'KeyZ', 'KeyX', 'KeyC', 'KeyV', 'KeyB', 'KeyN', 'KeyM',
'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12',
];
const generateDefaultHotkeys = (toolIds: string[], macLike: boolean): Record<string, HotkeyBinding> => {
const defaults: Record<string, HotkeyBinding> = {};
let index = 0;
let useShift = false;
const nextBinding = (): HotkeyBinding => {
if (index >= KEY_ORDER.length) {
index = 0;
if (!useShift) {
useShift = true;
} else {
// If we somehow run out of combinations, wrap back around (unlikely given tool count)
useShift = false;
}
}
const code = KEY_ORDER[index];
index += 1;
return {
code,
alt: true,
shift: useShift,
meta: macLike,
ctrl: !macLike,
};
};
toolIds.forEach(toolId => {
defaults[toolId] = nextBinding();
});
return defaults;
};
const shouldIgnoreTarget = (target: EventTarget | null): boolean => {
if (!target || !(target instanceof HTMLElement)) {
return false;
}
const editable = target.closest('input, textarea, [contenteditable="true"], [role="textbox"]');
return Boolean(editable);
};
export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { toolRegistry, handleToolSelect } = useToolWorkflow();
const isMac = useMemo(() => isMacLike(), []);
const [customBindings, setCustomBindings] = useState<Record<string, HotkeyBinding>>(() => {
if (typeof window === 'undefined') {
return {};
}
return deserializeBindings(window.localStorage?.getItem(STORAGE_KEY));
});
const [areHotkeysPaused, setHotkeysPaused] = useState(false);
const toolIds = useMemo(() => Object.keys(toolRegistry), [toolRegistry]);
const defaults = useMemo(() => generateDefaultHotkeys(toolIds, isMac), [toolIds, isMac]);
// Remove bindings for tools that are no longer present
useEffect(() => {
setCustomBindings(prev => {
const next: Record<string, HotkeyBinding> = {};
let changed = false;
Object.entries(prev).forEach(([toolId, binding]) => {
if (toolRegistry[toolId]) {
next[toolId] = binding;
} else {
changed = true;
}
});
return changed ? next : prev;
});
}, [toolRegistry]);
const resolved = useMemo(() => {
const merged: Record<string, HotkeyBinding> = {};
toolIds.forEach(toolId => {
const custom = customBindings[toolId];
merged[toolId] = custom ? normalizeBinding(custom) : defaults[toolId];
});
return merged;
}, [customBindings, defaults, toolIds]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(STORAGE_KEY, serializeBindings(customBindings));
}, [customBindings]);
const isBindingAvailable = useCallback((binding: HotkeyBinding, excludeToolId?: string) => {
const normalized = normalizeBinding(binding);
return Object.entries(resolved).every(([toolId, existing]) => {
if (toolId === excludeToolId) {
return true;
}
return !bindingEquals(existing, normalized);
});
}, [resolved]);
const updateHotkey = useCallback((toolId: string, binding: HotkeyBinding) => {
setCustomBindings(prev => {
const normalized = normalizeBinding(binding);
const defaultsForTool = defaults[toolId];
const next = { ...prev };
if (defaultsForTool && bindingEquals(defaultsForTool, normalized)) {
delete next[toolId];
} else {
next[toolId] = normalized;
}
return next;
});
}, [defaults]);
const resetHotkey = useCallback((toolId: string) => {
setCustomBindings(prev => {
if (!(toolId in prev)) {
return prev;
}
const next = { ...prev };
delete next[toolId];
return next;
});
}, []);
const pauseHotkeys = useCallback(() => setHotkeysPaused(true), []);
const resumeHotkeys = useCallback(() => setHotkeysPaused(false), []);
useEffect(() => {
if (areHotkeysPaused) {
return;
}
const handler = (event: KeyboardEvent) => {
if (event.repeat) return;
if (shouldIgnoreTarget(event.target)) return;
const entries = Object.entries(resolved) as Array<[string, HotkeyBinding]>;
for (const [toolId, binding] of entries) {
if (bindingMatchesEvent(binding, event)) {
event.preventDefault();
event.stopPropagation();
handleToolSelect(toolId as ToolId);
break;
}
}
};
window.addEventListener('keydown', handler, true);
return () => {
window.removeEventListener('keydown', handler, true);
};
}, [resolved, areHotkeysPaused, handleToolSelect]);
const contextValue = useMemo<HotkeyContextValue>(() => ({
hotkeys: resolved,
defaults,
isMac,
updateHotkey,
resetHotkey,
isBindingAvailable,
pauseHotkeys,
resumeHotkeys,
areHotkeysPaused,
getDisplayParts: (binding) => getDisplayParts(binding ?? null, isMac),
}), [resolved, defaults, isMac, updateHotkey, resetHotkey, isBindingAvailable, pauseHotkeys, resumeHotkeys, areHotkeysPaused]);
return (
<HotkeyContext.Provider value={contextValue}>
{children}
</HotkeyContext.Provider>
);
};
export const useHotkeys = (): HotkeyContextValue => {
const context = useContext(HotkeyContext);
if (!context) {
throw new Error('useHotkeys must be used within a HotkeyProvider');
}
return context;
};
@@ -75,7 +75,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
// Tool management (from hook)
selectedToolKey: string | null;
selectedTool: ToolRegistryEntry | null;
toolRegistry: any; // From useToolManagement
toolRegistry: Record<string, ToolRegistryEntry>;
getSelectedTool: (toolId: string | null) => ToolRegistryEntry | null;
// UI Actions
@@ -231,7 +231,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
// Filter tools based on search query with fuzzy matching (name, description, id, synonyms)
const filteredTools = useMemo(() => {
if (!toolRegistry) return [];
return filterToolRegistryByQuery(toolRegistry as Record<string, ToolRegistryEntry>, state.searchQuery);
return filterToolRegistryByQuery(toolRegistry as ToolRegistry, state.searchQuery);
}, [toolRegistry, state.searchQuery]);
const isPanelVisible = useMemo(() =>
+30
View File
@@ -123,6 +123,15 @@ interface ViewerContextType {
isThumbnailSidebarVisible: boolean;
toggleThumbnailSidebar: () => void;
// Annotation visibility toggle
isAnnotationsVisible: boolean;
toggleAnnotationsVisibility: () => void;
// Annotation/drawing mode for viewer
isAnnotationMode: boolean;
setAnnotationMode: (enabled: boolean) => void;
toggleAnnotationMode: () => void;
// State getters - read current state from bridges
getScrollState: () => ScrollState;
getZoomState: () => ZoomState;
@@ -208,6 +217,8 @@ interface ViewerProviderProps {
export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
// UI state - only state directly managed by this context
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true);
const [isAnnotationMode, setIsAnnotationModeState] = useState(false);
// Get current navigation state to check if we're in sign mode
useNavigation();
@@ -268,6 +279,18 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
setIsThumbnailSidebarVisible(prev => !prev);
};
const toggleAnnotationsVisibility = () => {
setIsAnnotationsVisible(prev => !prev);
};
const setAnnotationMode = (enabled: boolean) => {
setIsAnnotationModeState(enabled);
};
const toggleAnnotationMode = () => {
setIsAnnotationModeState(prev => !prev);
};
// State getters - read from bridge refs
const getScrollState = (): ScrollState => {
return bridgeRefs.current.scroll?.state || { currentPage: 1, totalPages: 0 };
@@ -547,6 +570,13 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
isThumbnailSidebarVisible,
toggleThumbnailSidebar,
// Annotation controls
isAnnotationsVisible,
toggleAnnotationsVisibility,
isAnnotationMode,
setAnnotationMode,
toggleAnnotationMode,
// State getters
getScrollState,
getZoomState,
@@ -476,7 +476,6 @@ export async function addStirlingFileStubs(
await addFilesMutex.lock();
try {
if (DEBUG) console.log(`📄 addStirlingFileStubs: Adding ${stirlingFileStubs.length} StirlingFileStubs preserving metadata`);
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
const validStubs: StirlingFileStub[] = [];
@@ -515,14 +514,12 @@ export async function addStirlingFileStubs(
record.processedFile.totalPages !== record.processedFile.pages.length;
if (needsProcessing) {
if (DEBUG) console.log(`📄 addStirlingFileStubs: Regenerating processedFile for ${record.name}`);
// Use centralized metadata generation function
const processedFileMetadata = await generateProcessedFileMetadata(stirlingFile);
if (processedFileMetadata) {
record.processedFile = processedFileMetadata;
record.thumbnailUrl = processedFileMetadata.thumbnailUrl; // Update thumbnail if needed
if (DEBUG) console.log(`📄 addStirlingFileStubs: Regenerated processedFile for ${record.name} with ${processedFileMetadata.totalPages} pages`);
} else {
// Fallback for files that couldn't be processed
if (DEBUG) console.warn(`📄 addStirlingFileStubs: Failed to regenerate processedFile for ${record.name}`);
@@ -541,7 +538,6 @@ export async function addStirlingFileStubs(
// Dispatch ADD_FILES action if we have new files
if (validStubs.length > 0) {
dispatch({ type: 'ADD_FILES', payload: { stirlingFileStubs: validStubs } });
if (DEBUG) console.log(`📄 addStirlingFileStubs: Successfully added ${validStubs.length} files with preserved metadata`);
}
return loadedFiles;
+12 -5
View File
@@ -37,14 +37,14 @@ export type ToolRegistryEntry = {
endpoints?: string[];
link?: string;
type?: string;
// URL path for routing (e.g., '/split-pdfs', '/compress-pdf')
urlPath?: string;
// Workbench type for navigation
workbench?: WorkbenchType;
// Operation configuration for automation
operationConfig?: ToolOperationConfig<any>;
// Settings component for automation configuration
settingsComponent?: React.ComponentType<any>;
automationSettings: React.ComponentType<any> | null;
// Whether this tool supports automation (defaults to true)
supportsAutomate?: boolean;
// Synonyms for search (optional)
synonyms?: string[];
}
@@ -130,8 +130,8 @@ export const getToolWorkbench = (tool: ToolRegistryEntry): WorkbenchType => {
/**
* Get URL path for a tool
*/
export const getToolUrlPath = (toolId: string, tool: ToolRegistryEntry): string => {
return tool.urlPath || `/${toolId.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
export const getToolUrlPath = (toolId: string): string => {
return `/${toolId.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
};
/**
@@ -140,3 +140,10 @@ export const getToolUrlPath = (toolId: string, tool: ToolRegistryEntry): string
export const isValidToolId = (toolId: string, registry: ToolRegistry): boolean => {
return toolId in registry;
};
/**
* Check if a tool supports automation (defaults to true)
*/
export const getToolSupportsAutomate = (tool: ToolRegistryEntry): boolean => {
return tool.supportsAutomate !== false;
};
+86 -44
View File
@@ -61,22 +61,19 @@ import { cropOperationConfig } from "../hooks/tools/crop/useCropOperation";
import { removeAnnotationsOperationConfig } from "../hooks/tools/removeAnnotations/useRemoveAnnotationsOperation";
import { extractImagesOperationConfig } from "../hooks/tools/extractImages/useExtractImagesOperation";
import { replaceColorOperationConfig } from "../hooks/tools/replaceColor/useReplaceColorOperation";
import { removePagesOperationConfig } from "../hooks/tools/removePages/useRemovePagesOperation";
import { removeBlanksOperationConfig } from "../hooks/tools/removeBlanks/useRemoveBlanksOperation";
import CompressSettings from "../components/tools/compress/CompressSettings";
import SplitSettings from "../components/tools/split/SplitSettings";
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
import RemovePasswordSettings from "../components/tools/removePassword/RemovePasswordSettings";
import SanitizeSettings from "../components/tools/sanitize/SanitizeSettings";
import RepairSettings from "../components/tools/repair/RepairSettings";
import UnlockPdfFormsSettings from "../components/tools/unlockPdfForms/UnlockPdfFormsSettings";
import AddWatermarkSingleStepSettings from "../components/tools/addWatermark/AddWatermarkSingleStepSettings";
import OCRSettings from "../components/tools/ocr/OCRSettings";
import ConvertSettings from "../components/tools/convert/ConvertSettings";
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
import CertificateTypeSettings from "../components/tools/certSign/CertificateTypeSettings";
import BookletImpositionSettings from "../components/tools/bookletImposition/BookletImpositionSettings";
import FlattenSettings from "../components/tools/flatten/FlattenSettings";
import RedactSingleStepSettings from "../components/tools/redact/RedactSingleStepSettings";
import RotateSettings from "../components/tools/rotate/RotateSettings";
import Redact from "../tools/Redact";
import AdjustPageScale from "../tools/AdjustPageScale";
import ReplaceColor from "../tools/ReplaceColor";
@@ -89,15 +86,22 @@ import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustP
import ScannerImageSplitSettings from "../components/tools/scannerImageSplit/ScannerImageSplitSettings";
import ChangeMetadataSingleStep from "../components/tools/changeMetadata/ChangeMetadataSingleStep";
import SignSettings from "../components/tools/sign/SignSettings";
import CropSettings from "../components/tools/crop/CropSettings";
import AddPageNumbers from "../tools/AddPageNumbers";
import { addPageNumbersOperationConfig } from "../components/tools/addPageNumbers/useAddPageNumbersOperation";
import RemoveAnnotations from "../tools/RemoveAnnotations";
import RemoveAnnotationsSettings from "../components/tools/removeAnnotations/RemoveAnnotationsSettings";
import PageLayoutSettings from "../components/tools/pageLayout/PageLayoutSettings";
import ExtractImages from "../tools/ExtractImages";
import ExtractImagesSettings from "../components/tools/extractImages/ExtractImagesSettings";
import ReplaceColorSettings from "../components/tools/replaceColor/ReplaceColorSettings";
import AddStampAutomationSettings from "../components/tools/addStamp/AddStampAutomationSettings";
import CertSignAutomationSettings from "../components/tools/certSign/CertSignAutomationSettings";
import CropAutomationSettings from "../components/tools/crop/CropAutomationSettings";
import RotateAutomationSettings from "../components/tools/rotate/RotateAutomationSettings";
import SplitAutomationSettings from "../components/tools/split/SplitAutomationSettings";
import AddAttachmentsSettings from "../components/tools/addAttachments/AddAttachmentsSettings";
import RemovePagesSettings from "../components/tools/removePages/RemovePagesSettings";
import RemoveBlanksSettings from "../components/tools/removeBlanks/RemoveBlanksSettings";
import AddPageNumbersAutomationSettings from "../components/tools/addPageNumbers/AddPageNumbersAutomationSettings";
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
@@ -199,6 +203,8 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
synonyms: getSynonyms(t, "multiTool"),
supportsAutomate: false,
automationSettings: null
},
merge: {
icon: <LocalIcon icon="library-add-rounded" width="1.5rem" height="1.5rem" />,
@@ -210,7 +216,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["merge-pdfs"],
operationConfig: mergeOperationConfig,
settingsComponent: MergeSettings,
automationSettings: MergeSettings,
synonyms: getSynonyms(t, "merge")
},
// Signing
@@ -225,7 +231,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["cert-sign"],
operationConfig: certSignOperationConfig,
settingsComponent: CertificateTypeSettings,
automationSettings: CertSignAutomationSettings,
},
sign: {
icon: <LocalIcon icon="signature-rounded" width="1.5rem" height="1.5rem" />,
@@ -235,8 +241,9 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.SIGNING,
operationConfig: signOperationConfig,
settingsComponent: SignSettings,
synonyms: getSynonyms(t, "sign")
automationSettings: SignSettings, // TODO:: not all settings shown, suggested next tools shown
synonyms: getSynonyms(t, "sign"),
supportsAutomate: false, //TODO make support Sign
},
// Document Security
@@ -251,7 +258,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["add-password"],
operationConfig: addPasswordOperationConfig,
settingsComponent: AddPasswordSettings,
automationSettings: AddPasswordSettings,
synonyms: getSynonyms(t, "addPassword")
},
watermark: {
@@ -264,7 +271,7 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
endpoints: ["add-watermark"],
operationConfig: addWatermarkOperationConfig,
settingsComponent: AddWatermarkSingleStepSettings,
automationSettings: AddWatermarkSingleStepSettings,
synonyms: getSynonyms(t, "watermark")
},
addStamp: {
@@ -278,6 +285,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["add-stamp"],
operationConfig: addStampOperationConfig,
automationSettings: AddStampAutomationSettings,
},
sanitize: {
icon: <LocalIcon icon="cleaning-services-outline-rounded" width="1.5rem" height="1.5rem" />,
@@ -289,7 +297,7 @@ export function useFlatToolRegistry(): ToolRegistry {
description: t("home.sanitize.desc", "Remove potentially harmful elements from PDF files"),
endpoints: ["sanitize-pdf"],
operationConfig: sanitizeOperationConfig,
settingsComponent: SanitizeSettings,
automationSettings: SanitizeSettings,
synonyms: getSynonyms(t, "sanitize")
},
flatten: {
@@ -302,7 +310,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["flatten"],
operationConfig: flattenOperationConfig,
settingsComponent: FlattenSettings,
automationSettings: FlattenSettings,
synonyms: getSynonyms(t, "flatten")
},
unlockPDFForms: {
@@ -315,8 +323,8 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["unlock-pdf-forms"],
operationConfig: unlockPdfFormsOperationConfig,
settingsComponent: UnlockPdfFormsSettings,
synonyms: getSynonyms(t, "unlockPDFForms"),
automationSettings: null
},
changePermissions: {
icon: <LocalIcon icon="lock-outline" width="1.5rem" height="1.5rem" />,
@@ -328,7 +336,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["add-password"],
operationConfig: changePermissionsOperationConfig,
settingsComponent: ChangePermissionsSettings,
automationSettings: ChangePermissionsSettings,
synonyms: getSynonyms(t, "changePermissions"),
},
getPdfInfo: {
@@ -339,6 +347,8 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.VERIFICATION,
synonyms: getSynonyms(t, "getPdfInfo"),
supportsAutomate: false,
automationSettings: null
},
validateSignature: {
icon: <LocalIcon icon="verified-rounded" width="1.5rem" height="1.5rem" />,
@@ -348,6 +358,7 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.VERIFICATION,
synonyms: getSynonyms(t, "validateSignature"),
automationSettings: null
},
// Document Review
@@ -363,7 +374,9 @@ export function useFlatToolRegistry(): ToolRegistry {
),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.DOCUMENT_REVIEW,
synonyms: getSynonyms(t, "read")
synonyms: getSynonyms(t, "read"),
supportsAutomate: false,
automationSettings: null
},
changeMetadata: {
icon: <LocalIcon icon="assignment-rounded" width="1.5rem" height="1.5rem" />,
@@ -375,7 +388,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["update-metadata"],
operationConfig: changeMetadataOperationConfig,
settingsComponent: ChangeMetadataSingleStep,
automationSettings: ChangeMetadataSingleStep,
synonyms: getSynonyms(t, "changeMetadata")
},
// Page Formatting
@@ -390,7 +403,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["crop"],
operationConfig: cropOperationConfig,
settingsComponent: CropSettings,
automationSettings: CropAutomationSettings,
},
rotate: {
icon: <LocalIcon icon="rotate-right-rounded" width="1.5rem" height="1.5rem" />,
@@ -402,7 +415,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["rotate-pdf"],
operationConfig: rotateOperationConfig,
settingsComponent: RotateSettings,
automationSettings: RotateAutomationSettings,
synonyms: getSynonyms(t, "rotate")
},
split: {
@@ -413,7 +426,7 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
operationConfig: splitOperationConfig,
settingsComponent: SplitSettings,
automationSettings: SplitAutomationSettings,
synonyms: getSynonyms(t, "split")
},
reorganizePages: {
@@ -428,7 +441,9 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.PAGE_FORMATTING,
endpoints: ["rearrange-pages"],
operationConfig: reorganizePagesOperationConfig,
synonyms: getSynonyms(t, "reorganizePages")
synonyms: getSynonyms(t, "reorganizePages"),
automationSettings: null
},
scalePages: {
icon: <LocalIcon icon="crop-free-rounded" width="1.5rem" height="1.5rem" />,
@@ -440,7 +455,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["scale-pages"],
operationConfig: adjustPageScaleOperationConfig,
settingsComponent: AdjustPageScaleSettings,
automationSettings: AdjustPageScaleSettings,
synonyms: getSynonyms(t, "scalePages")
},
addPageNumbers: {
@@ -450,6 +465,7 @@ export function useFlatToolRegistry(): ToolRegistry {
description: t("home.addPageNumbers.desc", "Add Page numbers throughout a document in a set location"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
automationSettings: AddPageNumbersAutomationSettings,
maxFiles: -1,
endpoints: ["add-page-numbers"],
operationConfig: addPageNumbersOperationConfig,
@@ -464,7 +480,7 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.PAGE_FORMATTING,
maxFiles: -1,
endpoints: ["multi-page-layout"],
settingsComponent: PageLayoutSettings,
automationSettings: PageLayoutSettings,
synonyms: getSynonyms(t, "pageLayout")
},
bookletImposition: {
@@ -472,7 +488,7 @@ export function useFlatToolRegistry(): ToolRegistry {
name: t("home.bookletImposition.title", "Booklet Imposition"),
component: BookletImposition,
operationConfig: bookletImpositionOperationConfig,
settingsComponent: BookletImpositionSettings,
automationSettings: BookletImpositionSettings,
description: t("home.bookletImposition.desc", "Create booklets with proper page ordering and multi-page layout for printing and binding"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
@@ -487,10 +503,10 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
maxFiles: -1,
urlPath: '/pdf-to-single-page',
endpoints: ["pdf-to-single-page"],
operationConfig: singleLargePageOperationConfig,
synonyms: getSynonyms(t, "pdfToSinglePage")
synonyms: getSynonyms(t, "pdfToSinglePage"),
automationSettings: null,
},
addAttachments: {
icon: <LocalIcon icon="attachment-rounded" width="1.5rem" height="1.5rem" />,
@@ -503,6 +519,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: 1,
endpoints: ["add-attachments"],
operationConfig: addAttachmentsOperationConfig,
automationSettings: AddAttachmentsSettings,
},
// Extraction
@@ -514,7 +531,8 @@ export function useFlatToolRegistry(): ToolRegistry {
description: t("home.extractPages.desc", "Extract specific pages from a PDF document"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.EXTRACTION,
synonyms: getSynonyms(t, "extractPages")
synonyms: getSynonyms(t, "extractPages"),
automationSettings: null,
},
extractImages: {
icon: <LocalIcon icon="photo-library-rounded" width="1.5rem" height="1.5rem" />,
@@ -526,7 +544,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["extract-images"],
operationConfig: extractImagesOperationConfig,
settingsComponent: ExtractImagesSettings,
automationSettings: ExtractImagesSettings,
synonyms: getSynonyms(t, "extractImages")
},
@@ -541,7 +559,9 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.REMOVAL,
maxFiles: 1,
endpoints: ["remove-pages"],
synonyms: getSynonyms(t, "removePages")
synonyms: getSynonyms(t, "removePages"),
operationConfig: removePagesOperationConfig,
automationSettings: RemovePagesSettings,
},
removeBlanks: {
icon: <LocalIcon icon="scan-delete-rounded" width="1.5rem" height="1.5rem" />,
@@ -552,7 +572,9 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.REMOVAL,
maxFiles: 1,
endpoints: ["remove-blanks"],
synonyms: getSynonyms(t, "removeBlanks")
synonyms: getSynonyms(t, "removeBlanks"),
operationConfig: removeBlanksOperationConfig,
automationSettings: RemoveBlanksSettings,
},
removeAnnotations: {
icon: <LocalIcon icon="thread-unread-rounded" width="1.5rem" height="1.5rem" />,
@@ -563,7 +585,7 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.REMOVAL,
maxFiles: -1,
operationConfig: removeAnnotationsOperationConfig,
settingsComponent: RemoveAnnotationsSettings,
automationSettings: null,
synonyms: getSynonyms(t, "removeAnnotations")
},
removeImage: {
@@ -577,6 +599,7 @@ export function useFlatToolRegistry(): ToolRegistry {
endpoints: ["remove-image-pdf"],
operationConfig: undefined,
synonyms: getSynonyms(t, "removeImage"),
automationSettings: null,
},
removePassword: {
icon: <LocalIcon icon="lock-open-right-outline-rounded" width="1.5rem" height="1.5rem" />,
@@ -588,7 +611,7 @@ export function useFlatToolRegistry(): ToolRegistry {
endpoints: ["remove-password"],
maxFiles: -1,
operationConfig: removePasswordOperationConfig,
settingsComponent: RemovePasswordSettings,
automationSettings: RemovePasswordSettings,
synonyms: getSynonyms(t, "removePassword")
},
removeCertSign: {
@@ -602,6 +625,7 @@ export function useFlatToolRegistry(): ToolRegistry {
endpoints: ["remove-certificate-sign"],
operationConfig: removeCertificateSignOperationConfig,
synonyms: getSynonyms(t, "removeCertSign"),
automationSettings: null,
},
// Automation
@@ -620,6 +644,7 @@ export function useFlatToolRegistry(): ToolRegistry {
supportedFormats: CONVERT_SUPPORTED_FORMATS,
endpoints: ["handleData"],
synonyms: getSynonyms(t, "automate"),
automationSettings: null,
},
autoRename: {
icon: <LocalIcon icon="match-word-rounded" width="1.5rem" height="1.5rem" />,
@@ -632,6 +657,7 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.AUTOMATION,
synonyms: getSynonyms(t, "autoRename"),
automationSettings: null,
},
// Advanced Formatting
@@ -644,6 +670,7 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
synonyms: getSynonyms(t, "adjustContrast"),
automationSettings: null,
},
repair: {
icon: <LocalIcon icon="build-outline-rounded" width="1.5rem" height="1.5rem" />,
@@ -655,8 +682,8 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["repair"],
operationConfig: repairOperationConfig,
settingsComponent: RepairSettings,
synonyms: getSynonyms(t, "repair")
synonyms: getSynonyms(t, "repair"),
automationSettings: null
},
scannerImageSplit: {
icon: <LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />,
@@ -668,7 +695,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["extract-image-scans"],
operationConfig: scannerImageSplitOperationConfig,
settingsComponent: ScannerImageSplitSettings,
automationSettings: ScannerImageSplitSettings,
synonyms: getSynonyms(t, "ScannerImageSplit"),
},
overlayPdfs: {
@@ -679,6 +706,7 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
synonyms: getSynonyms(t, "overlayPdfs"),
automationSettings: null
},
replaceColor: {
icon: <LocalIcon icon="format-color-fill-rounded" width="1.5rem" height="1.5rem" />,
@@ -690,7 +718,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["replace-invert-pdf"],
operationConfig: replaceColorOperationConfig,
settingsComponent: ReplaceColorSettings,
automationSettings: ReplaceColorSettings,
synonyms: getSynonyms(t, "replaceColor"),
},
addImage: {
@@ -701,6 +729,7 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
synonyms: getSynonyms(t, "addImage"),
automationSettings: null
},
editTableOfContents: {
icon: <LocalIcon icon="bookmark-add-rounded" width="1.5rem" height="1.5rem" />,
@@ -710,6 +739,7 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
synonyms: getSynonyms(t, "editTableOfContents"),
automationSettings: null
},
scannerEffect: {
icon: <LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />,
@@ -719,6 +749,7 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
synonyms: getSynonyms(t, "scannerEffect"),
automationSettings: null
},
// Developer Tools
@@ -731,6 +762,8 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
synonyms: getSynonyms(t, "showJS"),
supportsAutomate: false,
automationSettings: null
},
devApi: {
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: "#2F7BF6" }} />,
@@ -741,6 +774,8 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
link: "https://stirlingpdf.io/swagger-ui/5.21.0/index.html",
synonyms: getSynonyms(t, "devApi"),
supportsAutomate: false,
automationSettings: null
},
devFolderScanning: {
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: "#2F7BF6" }} />,
@@ -751,6 +786,8 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Folder%20Scanning/",
synonyms: getSynonyms(t, "devFolderScanning"),
supportsAutomate: false,
automationSettings: null
},
devSsoGuide: {
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: "#2F7BF6" }} />,
@@ -761,6 +798,8 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Single%20Sign-On%20Configuration",
synonyms: getSynonyms(t, "devSsoGuide"),
supportsAutomate: false,
automationSettings: null
},
devAirgapped: {
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: "#2F7BF6" }} />,
@@ -771,6 +810,8 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
link: "https://docs.stirlingpdf.com/Pro/#activation",
synonyms: getSynonyms(t, "devAirgapped"),
supportsAutomate: false,
automationSettings: null
},
// Recommended Tools
@@ -781,7 +822,9 @@ export function useFlatToolRegistry(): ToolRegistry {
description: t("home.compare.desc", "Compare two PDF documents and highlight differences"),
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
synonyms: getSynonyms(t, "compare")
synonyms: getSynonyms(t, "compare"),
supportsAutomate: false,
automationSettings: null
},
compress: {
icon: <LocalIcon icon="zoom-in-map-rounded" width="1.5rem" height="1.5rem" />,
@@ -792,7 +835,7 @@ export function useFlatToolRegistry(): ToolRegistry {
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
operationConfig: compressOperationConfig,
settingsComponent: CompressSettings,
automationSettings: CompressSettings,
synonyms: getSynonyms(t, "compress")
},
convert: {
@@ -822,7 +865,7 @@ export function useFlatToolRegistry(): ToolRegistry {
],
operationConfig: convertOperationConfig,
settingsComponent: ConvertSettings,
automationSettings: ConvertSettings,
synonyms: getSynonyms(t, "convert")
},
@@ -834,9 +877,8 @@ export function useFlatToolRegistry(): ToolRegistry {
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
urlPath: '/ocr-pdf',
operationConfig: ocrOperationConfig,
settingsComponent: OCRSettings,
automationSettings: OCRSettings,
synonyms: getSynonyms(t, "ocr")
},
redact: {
@@ -849,7 +891,7 @@ export function useFlatToolRegistry(): ToolRegistry {
maxFiles: -1,
endpoints: ["auto-redact"],
operationConfig: redactOperationConfig,
settingsComponent: RedactSingleStepSettings,
automationSettings: RedactSingleStepSettings,
synonyms: getSynonyms(t, "redact")
},
};
@@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next';
import { AutomationTool, AutomationConfig, AutomationMode } from '../../../types/automation';
import { AUTOMATION_CONSTANTS } from '../../../constants/automation';
import { ToolRegistry } from '../../../data/toolsTaxonomy';
import { ToolId } from 'src/types/toolId';
interface UseAutomationFormProps {
mode: AutomationMode;
@@ -41,11 +43,15 @@ export function useAutomationForm({ mode, existingAutomation, toolRegistry }: Us
const operations = existingAutomation.operations || [];
const tools = operations.map((op, index) => {
const operation = typeof op === 'string' ? op : op.operation;
const toolEntry = toolRegistry[operation as ToolId];
// If tool has no settingsComponent, it's automatically configured
const isConfigured = mode === AutomationMode.EDIT ? true : !toolEntry?.automationSettings;
return {
id: `${operation}-${Date.now()}-${index}`,
operation: operation,
name: getToolName(operation),
configured: mode === AutomationMode.EDIT ? true : false,
configured: isConfigured,
parameters: typeof op === 'object' ? op.parameters || {} : {}
};
});
@@ -65,11 +71,15 @@ export function useAutomationForm({ mode, existingAutomation, toolRegistry }: Us
}, [mode, existingAutomation, t, getToolName]);
const addTool = (operation: string) => {
const toolEntry = toolRegistry[operation as ToolId];
// If tool has no settingsComponent, it's automatically configured
const isConfigured = !toolEntry?.automationSettings;
const newTool: AutomationTool = {
id: `${operation}-${Date.now()}`,
operation,
name: getToolName(operation),
configured: false,
configured: isConfigured,
parameters: getToolDefaultParameters(operation)
};
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import React from 'react';
import LocalIcon from '../../../components/shared/LocalIcon';
import { SuggestedAutomation } from '../../../types/automation';
import { SPLIT_METHODS } from '../../../constants/splitConstants';
// Create icon components
const CompressIcon = () => React.createElement(LocalIcon, { icon: 'compress', width: '1.5rem', height: '1.5rem' });
@@ -83,18 +84,18 @@ export function useSuggestedAutomations(): SuggestedAutomation[] {
}
},
{
operation: "splitPdf",
operation: "split",
parameters: {
mode: 'bySizeOrCount',
method: SPLIT_METHODS.BY_SIZE,
pages: '',
hDiv: '1',
vDiv: '1',
merge: false,
splitType: 'size',
splitValue: '20MB',
bookmarkLevel: '1',
includeMetadata: false,
allowDuplicates: false,
duplexMode: false,
}
},
{
+1 -3
View File
@@ -70,7 +70,6 @@ async function processRequestQueue() {
const pageNumbers = requests.map(req => req.pageNumber);
const arrayBuffer = await file.arrayBuffer();
console.log(`📸 Batch generating ${requests.length} thumbnails for pages: ${pageNumbers.slice(0, 5).join(', ')}${pageNumbers.length > 5 ? '...' : ''}`);
// Use quickKey for PDF document caching (same metadata, consistent format)
const fileId = createQuickKey(file) as FileId;
@@ -80,9 +79,8 @@ async function processRequestQueue() {
arrayBuffer,
pageNumbers,
{ scale: 1.0, quality: 0.8, batchSize: BATCH_SIZE },
(progress) => {
(_progress) => {
// Optional: Could emit progress events here for UI feedback
console.log(`📸 Batch progress: ${progress.completed}/${progress.total} thumbnails generated`);
}
);
+1 -1
View File
@@ -22,7 +22,7 @@ export function useToolNavigation(): {
const getToolNavigation = useCallback((toolId: string, tool: ToolRegistryEntry): ToolNavigationProps => {
// Generate SSR-safe relative path
const path = getToolUrlPath(toolId, tool);
const path = getToolUrlPath(toolId);
const href = path; // Relative path, no window.location needed
// Click handler that maintains SPA behavior
+14
View File
@@ -15,6 +15,7 @@ import RightRail from "../components/shared/RightRail";
import FileManager from "../components/FileManager";
import LocalIcon from "../components/shared/LocalIcon";
import { useFilesModalContext } from "../contexts/FilesModalContext";
import AppConfigModal from "../components/shared/AppConfigModal";
import "./HomePage.css";
@@ -37,6 +38,7 @@ export default function HomePage() {
const sliderRef = useRef<HTMLDivElement | null>(null);
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
const isProgrammaticScroll = useRef(false);
const [configModalOpen, setConfigModalOpen] = useState(false);
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const brandIconSrc = `${BASE_PATH}/branding/StirlingPDFLogoNoText${
@@ -207,8 +209,20 @@ export default function HomePage() {
<LocalIcon icon="folder-rounded" width="1.5rem" height="1.5rem" />
<span className="mobile-bottom-button-label">{t('quickAccess.files', 'Files')}</span>
</button>
<button
className="mobile-bottom-button"
aria-label={t('quickAccess.config', 'Config')}
onClick={() => setConfigModalOpen(true)}
>
<LocalIcon icon="settings-rounded" width="1.5rem" height="1.5rem" />
<span className="mobile-bottom-button-label">{t('quickAccess.config', 'Config')}</span>
</button>
</div>
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
<AppConfigModal
opened={configModalOpen}
onClose={() => setConfigModalOpen(false)}
/>
</div>
) : (
<Group
+18
View File
@@ -224,6 +224,15 @@
--bulk-card-hover-border: #d1d5db; /* slightly darker on hover */
--unsupported-bar-bg: #5a616e;
--unsupported-bar-border: #6B7280;
/* Config Modal colors (light mode) */
--modal-nav-bg: #F5F6F8;
--modal-nav-section-title: #6B7280;
--modal-nav-item: #374151;
--modal-nav-item-active: #0A8BFF;
--modal-nav-item-active-bg: rgba(10, 139, 255, 0.08);
--modal-content-bg: #ffffff;
--modal-header-border: rgba(0, 0, 0, 0.06);
}
[data-mantine-color-scheme="dark"] {
@@ -414,6 +423,15 @@
--bulk-card-hover-border: var(--border-strong); /* stronger border on hover */
--unsupported-bar-bg: #1F2329;
--unsupported-bar-border: #4B525A;
/* Config Modal colors (dark mode) */
--modal-nav-bg: #1F2329;
--modal-nav-section-title: #9CA3AF;
--modal-nav-item: #D0D6DC;
--modal-nav-item-active: #0A8BFF;
--modal-nav-item-active-bg: rgba(10, 139, 255, 0.15);
--modal-content-bg: #2A2F36;
--modal-header-border: rgba(255, 255, 255, 0.08);
}
/* Dropzone drop state styling */
+6 -96
View File
@@ -6,10 +6,8 @@ import { BaseToolProps, ToolComponent } from "../types/tool";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useAddAttachmentsParameters } from "../hooks/tools/addAttachments/useAddAttachmentsParameters";
import { useAddAttachmentsOperation } from "../hooks/tools/addAttachments/useAddAttachmentsOperation";
import { Stack, Text, Group, ActionIcon, Alert, ScrollArea, Button } from "@mantine/core";
import LocalIcon from "../components/shared/LocalIcon";
import { useAccordionSteps } from "../hooks/tools/shared/useAccordionSteps";
// Removed FitText for two-line wrapping with clamping
import AddAttachmentsSettings from "../components/tools/addAttachments/AddAttachmentsSettings";
const AddAttachments = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
@@ -67,99 +65,11 @@ const AddAttachments = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
onCollapsedClick: () => accordion.handleStepToggle(AddAttachmentsStep.ATTACHMENTS),
isVisible: true,
content: (
<Stack gap="md">
<Alert color="blue" variant="light">
<Text size="sm">
{t("AddAttachmentsRequest.info", "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.")}
</Text>
</Alert>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("AddAttachmentsRequest.selectFiles", "Select Files to Attach")}
</Text>
<input
type="file"
multiple
onChange={(e) => {
const files = Array.from(e.target.files || []);
// Append to existing attachments instead of replacing
const newAttachments = [...params.parameters.attachments, ...files];
params.updateParameter('attachments', newAttachments);
// Reset the input so the same file can be selected again
e.target.value = '';
}}
disabled={endpointLoading}
style={{ display: 'none' }}
id="attachments-input"
/>
<Button
size="xs"
color="blue"
component="label"
htmlFor="attachments-input"
disabled={endpointLoading}
leftSection={<LocalIcon icon="plus" width="14" height="14" />}
>
{params.parameters.attachments.length > 0
? t("AddAttachmentsRequest.addMoreFiles", "Add more files...")
: t("AddAttachmentsRequest.placeholder", "Choose files...")
}
</Button>
</Stack>
{params.parameters.attachments && params.parameters.attachments.length > 0 && (
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("AddAttachmentsRequest.selectedFiles", "Selected Files")} ({params.parameters.attachments.length})
</Text>
<ScrollArea.Autosize mah={300} type="scroll" offsetScrollbars styles={{ viewport: { overflowX: 'hidden' } }}>
<Stack gap="xs">
{params.parameters.attachments.map((file, index) => (
<Group key={index} justify="space-between" p="xs" style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 'var(--mantine-radius-sm)', alignItems: 'flex-start' }}>
<Group gap="xs" style={{ flex: 1, minWidth: 0, alignItems: 'flex-start' }}>
{/* Filename (two-line clamp, wraps, no icon on the left) */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 'var(--mantine-font-size-sm)',
fontWeight: 400,
lineHeight: 1.2,
display: '-webkit-box',
WebkitLineClamp: 2 as any,
WebkitBoxOrient: 'vertical' as any,
overflow: 'hidden',
whiteSpace: 'normal',
wordBreak: 'break-word',
}}
title={file.name}
>
{file.name}
</div>
</div>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
({(file.size / 1024).toFixed(1)} KB)
</Text>
</Group>
<ActionIcon
size="sm"
variant="subtle"
color="red"
style={{ flexShrink: 0 }}
onClick={() => {
const newAttachments = params.parameters.attachments.filter((_, i) => i !== index);
params.updateParameter('attachments', newAttachments);
}}
>
<LocalIcon icon="close-rounded" width="14" height="14" />
</ActionIcon>
</Group>
))}
</Stack>
</ScrollArea.Autosize>
</Stack>
)}
</Stack>
<AddAttachmentsSettings
parameters={params.parameters}
onParameterChange={params.updateParameter}
disabled={endpointLoading}
/>
),
});
+14 -91
View File
@@ -6,10 +6,9 @@ import { BaseToolProps, ToolComponent } from "../types/tool";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useAddPageNumbersParameters } from "../components/tools/addPageNumbers/useAddPageNumbersParameters";
import { useAddPageNumbersOperation } from "../components/tools/addPageNumbers/useAddPageNumbersOperation";
import { Select, Stack, TextInput, NumberInput, Divider, Text } from "@mantine/core";
import { Tooltip } from "../components/shared/Tooltip";
import PageNumberPreview from "../components/tools/addPageNumbers/PageNumberPreview";
import { useAccordionSteps } from "../hooks/tools/shared/useAccordionSteps";
import AddPageNumbersPositionSettings from "../components/tools/addPageNumbers/AddPageNumbersPositionSettings";
import AddPageNumbersAppearanceSettings from "../components/tools/addPageNumbers/AddPageNumbersAppearanceSettings";
const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
@@ -68,44 +67,13 @@ const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
onCollapsedClick: () => accordion.handleStepToggle(AddPageNumbersStep.POSITION_AND_PAGES),
isVisible: hasFiles || hasResults,
content: (
<Stack gap="lg">
{/* Position Selection */}
<Stack gap="md">
<PageNumberPreview
parameters={params.parameters}
onParameterChange={params.updateParameter}
file={selectedFiles[0] || null}
showQuickGrid={true}
/>
</Stack>
<Divider />
{/* Pages & Starting Number Section */}
<Stack gap="md">
<Text size="sm" fw={500} mb="xs">{t('addPageNumbers.pagesAndStarting', 'Pages & Starting Number')}</Text>
<Tooltip content={t('pageSelectionPrompt', 'Specify which pages to add numbers to. Examples: "1,3,5" for specific pages, "1-5" for ranges, "2n" for even pages, or leave blank for all pages.')}>
<TextInput
label={t('addPageNumbers.selectText.5', 'Pages to Number')}
value={params.parameters.pagesToNumber}
onChange={(e) => params.updateParameter('pagesToNumber', e.currentTarget.value)}
placeholder={t('addPageNumbers.numberPagesDesc', 'e.g., 1,3,5-8 or leave blank for all pages')}
disabled={endpointLoading}
/>
</Tooltip>
<Tooltip content={t('startingNumberTooltip', 'The first number to display. Subsequent pages will increment from this number.')}>
<NumberInput
label={t('addPageNumbers.selectText.4', 'Starting Number')}
value={params.parameters.startingNumber}
onChange={(v) => params.updateParameter('startingNumber', typeof v === 'number' ? v : 1)}
min={1}
disabled={endpointLoading}
/>
</Tooltip>
</Stack>
</Stack>
<AddPageNumbersPositionSettings
parameters={params.parameters}
onParameterChange={params.updateParameter}
disabled={endpointLoading}
file={selectedFiles[0] || null}
showQuickGrid={true}
/>
),
});
@@ -116,56 +84,11 @@ const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
onCollapsedClick: () => accordion.handleStepToggle(AddPageNumbersStep.CUSTOMIZE),
isVisible: hasFiles || hasResults,
content: (
<Stack gap="md">
<Tooltip content={t('marginTooltip', 'Distance between the page number and the edge of the page.')}>
<Select
label={t('addPageNumbers.selectText.2', 'Margin')}
value={params.parameters.customMargin}
onChange={(v) => params.updateParameter('customMargin', (v as any) || 'medium')}
data={[
{ value: 'small', label: t('sizes.small', 'Small') },
{ value: 'medium', label: t('sizes.medium', 'Medium') },
{ value: 'large', label: t('sizes.large', 'Large') },
{ value: 'x-large', label: t('sizes.x-large', 'Extra Large') },
]}
disabled={endpointLoading}
/>
</Tooltip>
<Tooltip content={t('fontSizeTooltip', 'Size of the page number text in points. Larger numbers create bigger text.')}>
<NumberInput
label={t('addPageNumbers.fontSize', 'Font Size')}
value={params.parameters.fontSize}
onChange={(v) => params.updateParameter('fontSize', typeof v === 'number' ? v : 12)}
min={1}
disabled={endpointLoading}
/>
</Tooltip>
<Tooltip content={t('fontTypeTooltip', 'Font family for the page numbers. Choose based on your document style.')}>
<Select
label={t('addPageNumbers.fontName', 'Font Type')}
value={params.parameters.fontType}
onChange={(v) => params.updateParameter('fontType', (v as any) || 'Times')}
data={[
{ value: 'Times', label: 'Times Roman' },
{ value: 'Helvetica', label: 'Helvetica' },
{ value: 'Courier', label: 'Courier New' },
]}
disabled={endpointLoading}
/>
</Tooltip>
<Tooltip content={t('customTextTooltip', 'Optional custom format for page numbers. Use {n} as placeholder for the number. Example: "Page {n}" will show "Page 1", "Page 2", etc.')}>
<TextInput
label={t('addPageNumbers.selectText.6', 'Custom Text Format')}
value={params.parameters.customText}
onChange={(e) => params.updateParameter('customText', e.currentTarget.value)}
placeholder={t('addPageNumbers.customNumberDesc', 'e.g., "Page {n}" or leave blank for just numbers')}
disabled={endpointLoading}
/>
</Tooltip>
</Stack>
<AddPageNumbersAppearanceSettings
parameters={params.parameters}
onParameterChange={params.updateParameter}
disabled={endpointLoading}
/>
),
});
+16 -241
View File
@@ -6,15 +6,14 @@ import { BaseToolProps, ToolComponent } from "../types/tool";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useAddStampParameters } from "../components/tools/addStamp/useAddStampParameters";
import { useAddStampOperation } from "../components/tools/addStamp/useAddStampOperation";
import { Group, Select, Stack, Textarea, TextInput, ColorInput, Button, Slider, Text, NumberInput, Divider } from "@mantine/core";
import { Stack, Text } from "@mantine/core";
import StampPreview from "../components/tools/addStamp/StampPreview";
import LocalIcon from "../components/shared/LocalIcon";
import styles from "../components/tools/addStamp/StampPreview.module.css";
import { Tooltip } from "../components/shared/Tooltip";
import ButtonSelector from "../components/shared/ButtonSelector";
import { useAccordionSteps } from "../hooks/tools/shared/useAccordionSteps";
import ObscuredOverlay from "../components/shared/ObscuredOverlay";
import { getDefaultFontSizeForAlphabet } from "../components/tools/addStamp/StampPreviewUtils";
import StampSetupSettings from "../components/tools/addStamp/StampSetupSettings";
import StampPositionFormattingSettings from "../components/tools/addStamp/StampPositionFormattingSettings";
const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
@@ -70,108 +69,22 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const getSteps = () => {
const steps: any[] = [];
// Step 1: Stamp Setup
// Step 1: Stamp Setup
steps.push({
title: t("AddStampRequest.stampSetup", "Stamp Setup"),
isCollapsed: accordion.getCollapsedState(AddStampStep.STAMP_SETUP),
onCollapsedClick: () => accordion.handleStepToggle(AddStampStep.STAMP_SETUP),
isVisible: hasFiles || hasResults,
content: (
<Stack gap="md">
<TextInput
label={t('pageSelectionPrompt', 'Page Selection (e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)')}
value={params.parameters.pageNumbers}
onChange={(e) => params.updateParameter('pageNumbers', e.currentTarget.value)}
disabled={endpointLoading}
/>
<Divider/>
<div>
<Text size="sm" fw={500} mb="xs">{t('AddStampRequest.stampType', 'Stamp Type')}</Text>
<ButtonSelector
value={params.parameters.stampType}
onChange={(v: 'text' | 'image') => params.updateParameter('stampType', v)}
options={[
{ value: 'text', label: t('watermark.type.1', 'Text') },
{ value: 'image', label: t('watermark.type.2', 'Image') },
]}
disabled={endpointLoading}
buttonClassName={styles.modeToggleButton}
textClassName={styles.modeToggleButtonText}
/>
</div>
{params.parameters.stampType === 'text' && (
<>
<Textarea
label={t('AddStampRequest.stampText', 'Stamp Text')}
value={params.parameters.stampText}
onChange={(e) => params.updateParameter('stampText', e.currentTarget.value)}
autosize
minRows={2}
disabled={endpointLoading}
/>
<Select
label={t('AddStampRequest.alphabet', 'Alphabet')}
value={params.parameters.alphabet}
onChange={(v) => {
const nextAlphabet = (v as any) || 'roman';
params.updateParameter('alphabet', nextAlphabet);
const nextDefault = getDefaultFontSizeForAlphabet(nextAlphabet);
params.updateParameter('fontSize', nextDefault);
}}
data={[
{ value: 'roman', label: 'Roman' },
{ value: 'arabic', label: 'العربية' },
{ value: 'japanese', label: '日本語' },
{ value: 'korean', label: '한국어' },
{ value: 'chinese', label: '简体中文' },
{ value: 'thai', label: 'ไทย' },
]}
disabled={endpointLoading}
/>
</>
)}
{params.parameters.stampType === 'image' && (
<Stack gap="xs">
<input
type="file"
accept=".png,.jpg,.jpeg,.gif,.bmp,.tiff,.tif,.webp"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) params.updateParameter('stampImage', file);
}}
disabled={endpointLoading}
style={{ display: 'none' }}
id="stamp-image-input"
/>
<Button
size="xs"
component="label"
htmlFor="stamp-image-input"
disabled={endpointLoading}
>
{t('chooseFile', 'Choose File')}
</Button>
{params.parameters.stampImage && (
<Stack gap="xs">
<img
src={URL.createObjectURL(params.parameters.stampImage)}
alt="Selected stamp image"
className="max-h-24 w-full object-contain border border-gray-200 rounded bg-gray-50"
/>
<Text size="xs" c="dimmed">
{params.parameters.stampImage.name}
</Text>
</Stack>
)}
</Stack>
)}
</Stack>
<StampSetupSettings
parameters={params.parameters}
onParameterChange={params.updateParameter}
disabled={endpointLoading}
/>
),
});
// Step 3: Formatting & Position
// Step 2: Formatting & Position
steps.push({
title: t("AddStampRequest.positionAndFormatting", "Position & Formatting"),
isCollapsed: accordion.getCollapsedState(AddStampStep.POSITION_FORMATTING),
@@ -209,151 +122,13 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
</div>
)}
<StampPositionFormattingSettings
parameters={params.parameters}
onParameterChange={params.updateParameter}
disabled={endpointLoading}
/>
{/* Icon pill buttons row */}
<div className="flex justify-between gap-[0.5rem]">
<Tooltip content={t('AddStampRequest.rotation', 'Rotation')} position="top">
<Button
variant={params.parameters._activePill === 'rotation' ? 'filled' : 'outline'}
className="flex-1"
onClick={() => params.updateParameter('_activePill', 'rotation')}
>
<LocalIcon icon="rotate-right-rounded" width="1.1rem" height="1.1rem" />
</Button>
</Tooltip>
<Tooltip content={t('AddStampRequest.opacity', 'Opacity')} position="top">
<Button
variant={params.parameters._activePill === 'opacity' ? 'filled' : 'outline'}
className="flex-1"
onClick={() => params.updateParameter('_activePill', 'opacity')}
>
<LocalIcon icon="opacity" width="1.1rem" height="1.1rem" />
</Button>
</Tooltip>
<Tooltip content={params.parameters.stampType === 'image' ? t('AddStampRequest.imageSize', 'Image Size') : t('AddStampRequest.fontSize', 'Font Size')} position="top">
<Button
variant={params.parameters._activePill === 'fontSize' ? 'filled' : 'outline'}
className="flex-1"
onClick={() => params.updateParameter('_activePill', 'fontSize')}
>
<LocalIcon icon="zoom-in-map-rounded" width="1.1rem" height="1.1rem" />
</Button>
</Tooltip>
</div>
{/* Single slider bound to selected pill */}
{params.parameters._activePill === 'fontSize' && (
<Stack gap="xs">
<Text className={styles.labelText}>
{params.parameters.stampType === 'image'
? t('AddStampRequest.imageSize', 'Image Size')
: t('AddStampRequest.fontSize', 'Font Size')
}
</Text>
<Group className={styles.sliderGroup} align="center">
<NumberInput
value={params.parameters.fontSize}
onChange={(v) => params.updateParameter('fontSize', typeof v === 'number' ? v : 1)}
min={1}
max={400}
step={1}
size="sm"
className={styles.numberInput}
disabled={endpointLoading}
/>
<Slider
value={params.parameters.fontSize}
onChange={(v) => params.updateParameter('fontSize', v as number)}
min={1}
max={400}
step={1}
className={styles.slider}
/>
</Group>
</Stack>
)}
{params.parameters._activePill === 'rotation' && (
<Stack gap="xs">
<Text className={styles.labelText}>{t('AddStampRequest.rotation', 'Rotation')}</Text>
<Group className={styles.sliderGroup} align="center">
<NumberInput
value={params.parameters.rotation}
onChange={(v) => params.updateParameter('rotation', typeof v === 'number' ? v : 0)}
min={-180}
max={180}
step={1}
size="sm"
className={styles.numberInput}
hideControls
disabled={endpointLoading}
/>
<Slider
value={params.parameters.rotation}
onChange={(v) => params.updateParameter('rotation', v as number)}
min={-180}
max={180}
step={1}
className={styles.sliderWide}
/>
</Group>
</Stack>
)}
{params.parameters._activePill === 'opacity' && (
<Stack gap="xs">
<Text className={styles.labelText}>{t('AddStampRequest.opacity', 'Opacity')}</Text>
<Group className={styles.sliderGroup} align="center">
<NumberInput
value={params.parameters.opacity}
onChange={(v) => params.updateParameter('opacity', typeof v === 'number' ? v : 0)}
min={0}
max={100}
step={1}
size="sm"
className={styles.numberInput}
disabled={endpointLoading}
/>
<Slider
value={params.parameters.opacity}
onChange={(v) => params.updateParameter('opacity', v as number)}
min={0}
max={100}
step={1}
className={styles.slider}
/>
</Group>
</Stack>
)}
{params.parameters.stampType !== 'image' && (
<ColorInput
label={t('AddStampRequest.customColor', 'Custom Text Color')}
value={params.parameters.customColor}
onChange={(value) => params.updateParameter('customColor', value)}
format="hex"
disabled={endpointLoading}
/>
)}
{/* Margin selection appears when using quick grid (and for text stamps) */}
{(params.parameters.stampType === 'text' || (params.parameters.stampType === 'image' && quickPositionModeSelected)) && (
<Select
label={t('AddStampRequest.margin', 'Margin')}
value={params.parameters.customMargin}
onChange={(v) => params.updateParameter('customMargin', (v as any) || 'medium')}
data={[
{ value: 'small', label: t('margin.small', 'Small') },
{ value: 'medium', label: t('margin.medium', 'Medium') },
{ value: 'large', label: t('margin.large', 'Large') },
{ value: 'x-large', label: t('margin.xLarge', 'Extra Large') },
]}
disabled={endpointLoading}
/>
)}
{/* Unified preview wrapped with obscured overlay if no stamp selected in step 4 */}
{/* Unified preview wrapped with obscured overlay if no stamp selected */}
<ObscuredOverlay
obscured={
accordion.currentStep === AddStampStep.POSITION_FORMATTING &&
+130 -103
View File
@@ -5,6 +5,127 @@ import { AutomationFileProcessor } from './automationFileProcessor';
import { ToolType } from '../hooks/tools/shared/useToolOperation';
import { processResponse } from './toolResponseProcessor';
/**
* Process multi-file tool response (handles ZIP or single PDF responses)
*/
const processMultiFileResponse = async (
responseData: Blob,
responseHeaders: any,
files: File[],
filePrefix: string,
preserveBackendFilename?: boolean
): Promise<File[]> => {
// Multi-file responses are typically ZIP files, but may be single files (e.g. split with merge=true)
if (responseData.type === 'application/pdf' ||
(responseHeaders && responseHeaders['content-type'] === 'application/pdf')) {
// Single PDF response - use processResponse to respect preserveBackendFilename
const processedFiles = await processResponse(
responseData,
files,
filePrefix,
undefined,
preserveBackendFilename ? responseHeaders : undefined
);
return processedFiles;
} else {
// ZIP response
const result = await AutomationFileProcessor.extractAutomationZipFiles(responseData);
if (result.errors.length > 0) {
console.warn(`⚠️ File processing warnings:`, result.errors);
}
// Apply prefix to files, replacing any existing prefix
const processedFiles = filePrefix && !preserveBackendFilename
? result.files.map(file => {
const nameWithoutPrefix = file.name.replace(/^[^_]*_/, '');
return new File([file], `${filePrefix}${nameWithoutPrefix}`, { type: file.type });
})
: result.files;
return processedFiles;
}
};
/**
* Core execution function for API requests
*/
const executeApiRequest = async (
endpoint: string,
formData: FormData,
files: File[],
filePrefix: string,
preserveBackendFilename?: boolean
): Promise<File[]> => {
const response = await axios.post(endpoint, formData, {
responseType: 'blob',
timeout: AUTOMATION_CONSTANTS.OPERATION_TIMEOUT
});
return await processMultiFileResponse(
response.data,
response.headers,
files,
filePrefix,
preserveBackendFilename
);
};
/**
* Execute single-file tool operation (processes files one at a time)
*/
const executeSingleFileOperation = async (
config: any,
parameters: any,
files: File[],
filePrefix: string
): Promise<File[]> => {
const resultFiles: File[] = [];
for (const file of files) {
const endpoint = typeof config.endpoint === 'function'
? config.endpoint(parameters)
: config.endpoint;
const formData = (config.buildFormData as (params: any, file: File) => FormData)(parameters, file);
const processedFiles = await executeApiRequest(
endpoint,
formData,
[file],
filePrefix,
config.preserveBackendFilename
);
resultFiles.push(...processedFiles);
}
return resultFiles;
};
/**
* Execute multi-file tool operation (processes all files in one request)
*/
const executeMultiFileOperation = async (
config: any,
parameters: any,
files: File[],
filePrefix: string
): Promise<File[]> => {
const endpoint = typeof config.endpoint === 'function'
? config.endpoint(parameters)
: config.endpoint;
const formData = (config.buildFormData as (params: any, files: File[]) => FormData)(parameters, files);
return await executeApiRequest(
endpoint,
formData,
files,
filePrefix,
config.preserveBackendFilename
);
};
/**
* Execute a tool operation directly without using React hooks
@@ -28,119 +149,27 @@ export const executeToolOperationWithPrefix = async (
toolRegistry: ToolRegistry,
filePrefix: string = AUTOMATION_CONSTANTS.FILE_PREFIX
): Promise<File[]> => {
console.log(`🔧 Executing tool: ${operationName}`, { parameters, fileCount: files.length });
const config = toolRegistry[operationName as keyof ToolRegistry]?.operationConfig;
if (!config) {
console.error(`❌ Tool operation not supported: ${operationName}`);
throw new Error(`Tool operation not supported: ${operationName}`);
}
console.log(`📋 Using config:`, config);
try {
// Check if tool uses custom processor (like Convert tool)
if (config.customProcessor) {
console.log(`🎯 Using custom processor for ${config.operationType}`);
const resultFiles = await config.customProcessor(parameters, files);
console.log(`✅ Custom processor returned ${resultFiles.length} files`);
return resultFiles;
}
// Execute based on tool type
if (config.toolType === ToolType.multiFile) {
// Multi-file processing - single API call with all files
const endpoint = typeof config.endpoint === 'function'
? config.endpoint(parameters)
: config.endpoint;
console.log(`🌐 Making multi-file request to: ${endpoint}`);
const formData = (config.buildFormData as (params: any, files: File[]) => FormData)(parameters, files);
console.log(`📤 FormData entries:`, Array.from(formData.entries()));
const response = await axios.post(endpoint, formData, {
responseType: 'blob',
timeout: AUTOMATION_CONSTANTS.OPERATION_TIMEOUT
});
console.log(`📥 Response status: ${response.status}, size: ${response.data.size} bytes`);
// Multi-file responses are typically ZIP files, but may be single files (e.g. split with merge=true)
let result;
if (response.data.type === 'application/pdf' ||
(response.headers && response.headers['content-type'] === 'application/pdf')) {
// Single PDF response (e.g. split with merge option) - use processResponse to respect preserveBackendFilename
const processedFiles = await processResponse(
response.data,
files,
filePrefix,
undefined,
config.preserveBackendFilename ? response.headers : undefined
);
result = {
success: true,
files: processedFiles,
errors: []
};
} else {
// ZIP response
result = await AutomationFileProcessor.extractAutomationZipFiles(response.data);
}
if (result.errors.length > 0) {
console.warn(`⚠️ File processing warnings:`, result.errors);
}
// Apply prefix to files, replacing any existing prefix
// Skip prefixing if preserveBackendFilename is true and backend provided a filename
const processedFiles = filePrefix && !config.preserveBackendFilename
? result.files.map(file => {
const nameWithoutPrefix = file.name.replace(/^[^_]*_/, '');
return new File([file], `${filePrefix}${nameWithoutPrefix}`, { type: file.type });
})
: result.files;
console.log(`📁 Processed ${processedFiles.length} files from response`);
return processedFiles;
return await executeMultiFileOperation(config, parameters, files, filePrefix);
} else {
// Single-file processing - separate API call per file
console.log(`🔄 Processing ${files.length} files individually`);
const resultFiles: File[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const endpoint = typeof config.endpoint === 'function'
? config.endpoint(parameters)
: config.endpoint;
console.log(`🌐 Making single-file request ${i+1}/${files.length} to: ${endpoint} for file: ${file.name}`);
const formData = (config.buildFormData as (params: any, file: File) => FormData)(parameters, file);
console.log(`📤 FormData entries:`, Array.from(formData.entries()));
const response = await axios.post(endpoint, formData, {
responseType: 'blob',
timeout: AUTOMATION_CONSTANTS.OPERATION_TIMEOUT
});
console.log(`📥 Response ${i+1} status: ${response.status}, size: ${response.data.size} bytes`);
// Create result file using processResponse to respect preserveBackendFilename setting
const processedFiles = await processResponse(
response.data,
[file],
filePrefix,
undefined,
config.preserveBackendFilename ? response.headers : undefined
);
resultFiles.push(...processedFiles);
console.log(`✅ Created result file(s): ${processedFiles.map(f => f.name).join(', ')}`);
}
console.log(`🎉 Single-file processing complete: ${resultFiles.length} files`);
return resultFiles;
return await executeSingleFileOperation(config, parameters, files, filePrefix);
}
} catch (error: any) {
console.error(`Tool operation ${operationName} failed:`, error);
console.error(`❌ ${operationName} failed:`, error);
throw new Error(`${operationName} operation failed: ${error.response?.data || error.message}`);
}
};
@@ -156,9 +185,8 @@ export const executeAutomationSequence = async (
onStepComplete?: (stepIndex: number, resultFiles: File[]) => void,
onStepError?: (stepIndex: number, error: string) => void
): Promise<File[]> => {
console.log(`🚀 Starting automation sequence: ${automation.name || 'Unnamed'}`);
console.log(`📁 Initial files: ${initialFiles.length}`);
console.log(`🔧 Operations: ${automation.operations?.length || 0}`);
console.log(`🚀 Starting automation: ${automation.name || 'Unnamed'}`);
console.log(`📁 Input: ${initialFiles.length} file(s)`);
if (!automation?.operations || automation.operations.length === 0) {
throw new Error('No operations in automation');
@@ -170,9 +198,8 @@ export const executeAutomationSequence = async (
for (let i = 0; i < automation.operations.length; i++) {
const operation = automation.operations[i];
console.log(`📋 Step ${i + 1}/${automation.operations.length}: ${operation.operation}`);
console.log(`📄 Input files: ${currentFiles.length}`);
console.log(`⚙️ Parameters:`, operation.parameters || {});
console.log(`\n📋 Step ${i + 1}/${automation.operations.length}: ${operation.operation}`);
console.log(` Input: ${currentFiles.length} file(s)`);
try {
onStepStart?.(i, operation.operation);
@@ -196,6 +223,6 @@ export const executeAutomationSequence = async (
}
}
console.log(`🎉 Automation sequence completed: ${currentFiles.length} final files`);
console.log(`\n🎉 Automation complete: ${currentFiles.length} file(s)`);
return currentFiles;
};
+191
View File
@@ -0,0 +1,191 @@
import { KeyboardEvent as ReactKeyboardEvent } from 'react';
export interface HotkeyBinding {
code: string;
alt?: boolean;
ctrl?: boolean;
meta?: boolean;
shift?: boolean;
}
const MODIFIER_CODES = new Set([
'ShiftLeft',
'ShiftRight',
'ControlLeft',
'ControlRight',
'AltLeft',
'AltRight',
'MetaLeft',
'MetaRight',
]);
const CODE_LABEL_MAP: Record<string, string> = {
Minus: '-',
Equal: '=',
Backquote: '`',
BracketLeft: '[',
BracketRight: ']',
Backslash: '\\',
IntlBackslash: '\\',
Semicolon: ';',
Quote: '\'',
Comma: ',',
Period: '.',
Slash: '/',
Space: 'Space',
Tab: 'Tab',
Escape: 'Esc',
Enter: 'Enter',
NumpadEnter: 'Num Enter',
NumpadAdd: 'Num +',
NumpadSubtract: 'Num -',
NumpadMultiply: 'Num *',
NumpadDivide: 'Num /',
NumpadDecimal: 'Num .',
NumpadComma: 'Num ,',
NumpadEqual: 'Num =',
};
export const isMacLike = (): boolean => {
if (typeof navigator === 'undefined') {
return false;
}
const platform = navigator.platform?.toLowerCase() ?? '';
const userAgent = navigator.userAgent?.toLowerCase() ?? '';
return /mac|iphone|ipad|ipod/.test(platform) || /mac|iphone|ipad|ipod/.test(userAgent);
};
export const isModifierCode = (code: string): boolean => MODIFIER_CODES.has(code);
const isFunctionKey = (code: string): boolean => /^F\d{1,2}$/.test(code);
export const bindingEquals = (a?: HotkeyBinding | null, b?: HotkeyBinding | null): boolean => {
if (!a && !b) return true;
if (!a || !b) return false;
return (
a.code === b.code &&
Boolean(a.alt) === Boolean(b.alt) &&
Boolean(a.ctrl) === Boolean(b.ctrl) &&
Boolean(a.meta) === Boolean(b.meta) &&
Boolean(a.shift) === Boolean(b.shift)
);
};
export const bindingMatchesEvent = (binding: HotkeyBinding, event: KeyboardEvent): boolean => {
return (
event.code === binding.code &&
event.altKey === Boolean(binding.alt) &&
event.ctrlKey === Boolean(binding.ctrl) &&
event.metaKey === Boolean(binding.meta) &&
event.shiftKey === Boolean(binding.shift)
);
};
export const eventToBinding = (event: KeyboardEvent | ReactKeyboardEvent): HotkeyBinding | null => {
const code = event.code;
if (!code || isModifierCode(code)) {
return null;
}
const binding: HotkeyBinding = {
code,
alt: event.altKey,
ctrl: event.ctrlKey,
meta: event.metaKey,
shift: event.shiftKey,
};
// Require at least one modifier to avoid clashing with text input
if (!binding.alt && !binding.ctrl && !binding.meta) {
return null;
}
return binding;
};
const getKeyLabel = (code: string): string => {
if (CODE_LABEL_MAP[code]) {
return CODE_LABEL_MAP[code];
}
if (code.startsWith('Key')) {
return code.slice(3);
}
if (code.startsWith('Digit')) {
return code.slice(5);
}
if (code.startsWith('Numpad')) {
const remainder = code.slice(6);
if (/^[0-9]$/.test(remainder)) {
return `Num ${remainder}`;
}
return `Num ${remainder}`;
}
// Match function keys (F1-F12)
if (isFunctionKey(code)) {
return code;
}
switch (code) {
case 'ArrowUp':
return '↑';
case 'ArrowDown':
return '↓';
case 'ArrowLeft':
return '←';
case 'ArrowRight':
return '→';
default:
return code;
}
};
export const getDisplayParts = (binding: HotkeyBinding | null | undefined, macLike: boolean): string[] => {
if (!binding) return [];
const parts: string[] = [];
if (binding.meta) {
parts.push(macLike ? '⌘' : 'Win');
}
if (binding.ctrl) {
parts.push(macLike ? '⌃' : 'Ctrl');
}
if (binding.alt) {
parts.push(macLike ? '⌥' : 'Alt');
}
if (binding.shift) {
parts.push(macLike ? '⇧' : 'Shift');
}
parts.push(getKeyLabel(binding.code));
return parts;
};
export const serializeBindings = (bindings: Record<string, HotkeyBinding>): string => {
return JSON.stringify(bindings);
};
export const deserializeBindings = (value: string | null | undefined): Record<string, HotkeyBinding> => {
if (!value) {
return {};
}
try {
const parsed = JSON.parse(value) as Record<string, HotkeyBinding>;
if (typeof parsed !== 'object' || parsed === null) {
return {};
}
return parsed;
} catch (error) {
console.warn('Failed to parse stored hotkey bindings', error);
return {};
}
};
export const normalizeBinding = (binding: HotkeyBinding): HotkeyBinding => ({
code: binding.code,
alt: Boolean(binding.alt),
ctrl: Boolean(binding.ctrl),
meta: Boolean(binding.meta),
shift: Boolean(binding.shift),
});
+2 -18
View File
@@ -33,7 +33,7 @@ export function parseToolRoute(registry: ToolRegistry): ToolRoute {
// Fallback: Try to find tool by primary URL path in registry
for (const [toolId, tool] of Object.entries(registry)) {
const toolUrlPath = getToolUrlPath(toolId, tool);
const toolUrlPath = getToolUrlPath(toolId);
if (path === toolUrlPath && isValidToolId(toolId)) {
return {
workbench: getToolWorkbench(tool),
@@ -88,7 +88,7 @@ export function updateToolRoute(toolId: ToolId, registry: ToolRegistry, replace:
return;
}
const toolPath = getToolUrlPath(toolId, tool);
const toolPath = getToolUrlPath(toolId);
const newPath = withBasePath(toolPath);
const searchParams = new URLSearchParams(window.location.search);
@@ -116,19 +116,3 @@ export function getToolDisplayName(toolId: ToolId, registry: ToolRegistry): stri
return tool ? tool.name : toolId;
}
/**
* Generate shareable URL for current tool state using registry
*/
export function generateShareableUrl(toolId: ToolId | null, registry: ToolRegistry): string {
const baseUrl = window.location.origin;
if (!toolId || !registry[toolId]) {
return `${baseUrl}${BASE_PATH || ''}`;
}
const tool = registry[toolId];
const toolPath = getToolUrlPath(toolId, tool);
const fullPath = withBasePath(toolPath);
return `${baseUrl}${fullPath}`;
}
-2
View File
@@ -192,7 +192,6 @@ ignore = [
'AddStampRequest.alphabet',
'AddStampRequest.position',
'PDFToBook.selectText.1',
'addPageNumbers.selectText.3',
'adminUserSettings.team',
'alphabet',
'audit.dashboard.modal.id',
@@ -325,7 +324,6 @@ ignore = [
'AddStampRequest.position',
'AddStampRequest.rotation',
'PDFToBook.selectText.1',
'addPageNumbers.selectText.3',
'adminUserSettings.actions',
'alphabet',
'compare.document.1',
+177 -1
View File
@@ -1,9 +1,96 @@
# Translation Management Scripts
This directory contains Python scripts for managing frontend translations in Stirling PDF. These tools help analyze, merge, and manage translations against the en-GB golden truth file.
This directory contains Python scripts for managing frontend translations in Stirling PDF. These tools help analyze, merge, validate, and manage translations against the en-GB golden truth file.
## Scripts Overview
### 0. Validation Scripts (Run First!)
#### `json_validator.py`
Validates JSON syntax in translation files with detailed error reporting.
**Usage:**
```bash
# Validate single file
python scripts/translations/json_validator.py ar_AR_batch_1_of_3.json
# Validate all batches for a language
python scripts/translations/json_validator.py --all-batches ar_AR
# Validate pattern with wildcards
python scripts/translations/json_validator.py "ar_AR_batch_*.json"
# Brief output (no context)
python scripts/translations/json_validator.py --all-batches ar_AR --brief
# Only show files with errors
python scripts/translations/json_validator.py --all-batches ar_AR --quiet
```
**Features:**
- Validates JSON syntax with detailed error messages
- Shows exact line, column, and character position of errors
- Displays context around errors for easy fixing
- Suggests common fixes based on error type
- Detects unescaped quotes and backslashes
- Reports entry counts for valid files
- Exit code 1 if any files invalid (good for CI/CD)
**Common Issues Detected:**
- Unescaped quotes inside strings: `"text with "quotes""` → `"text with \"quotes\""`
- Invalid backslash escapes: `\d{4}` → `\\d{4}`
- Missing commas between entries
- Trailing commas before closing braces
#### `validate_placeholders.py`
Validates that translation files have correct placeholders matching en-GB (source of truth).
**Usage:**
```bash
# Validate all languages
python scripts/translations/validate_placeholders.py
# Validate specific language
python scripts/translations/validate_placeholders.py --language es-ES
# Show detailed text samples
python scripts/translations/validate_placeholders.py --verbose
# Output as JSON
python scripts/translations/validate_placeholders.py --json
```
**Features:**
- Detects missing placeholders (e.g., {n}, {total}, {filename})
- Detects extra placeholders not in en-GB
- Shows exact keys and text where issues occur
- Exit code 1 if issues found (good for CI/CD)
#### `validate_json_structure.py`
Validates JSON structure and key consistency with en-GB.
**Usage:**
```bash
# Validate all languages
python scripts/translations/validate_json_structure.py
# Validate specific language
python scripts/translations/validate_json_structure.py --language de-DE
# Show all missing/extra keys
python scripts/translations/validate_json_structure.py --verbose
# Output as JSON
python scripts/translations/validate_json_structure.py --json
```
**Features:**
- Validates JSON syntax
- Detects missing keys (not translated yet)
- Detects extra keys (not in en-GB, should be removed)
- Reports key counts and structure differences
- Exit code 1 if issues found (good for CI/CD)
### 1. `translation_analyzer.py`
Analyzes translation files to find missing translations, untranslated entries, and provides completion statistics.
@@ -142,7 +229,20 @@ python scripts/translations/translation_analyzer.py --language it-IT --summary
#### Step 2: Extract Untranslated Entries
```bash
# For small files (< 1200 entries)
python scripts/translations/compact_translator.py it-IT --output to_translate.json
# For large files, split into batches
python scripts/translations/compact_translator.py it-IT --output it_IT_batch --batch-size 400
# Creates: it_IT_batch_1_of_N.json, it_IT_batch_2_of_N.json, etc.
```
#### Step 2.5: Validate JSON (if using batches)
```bash
# After AI translates the batches, validate them before merging
python scripts/translations/json_validator.py --all-batches it_IT
# Fix any errors reported (common issues: unescaped quotes, backslashes)
```
**Output format**: Compact JSON with minimal whitespace
@@ -309,6 +409,34 @@ ignore = [
### Common Issues and Solutions
#### JSON Syntax Errors in AI Translations
**Problem**: AI-translated batch files have JSON syntax errors
**Symptoms**:
- `JSONDecodeError: Expecting ',' delimiter`
- `JSONDecodeError: Invalid \escape`
**Solution**:
```bash
# 1. Validate all batches to find errors
python scripts/translations/json_validator.py --all-batches ar_AR
# 2. Check detailed error with context
python scripts/translations/json_validator.py ar_AR_batch_2_of_3.json
# 3. Fix the reported issues:
# - Unescaped quotes: "text with "quotes"" → "text with \"quotes\""
# - Backslashes in regex: "\d{4}" → "\\d{4}"
# - Missing commas between entries
# 4. Validate again until all pass
python scripts/translations/json_validator.py --all-batches ar_AR
```
**Common fixes:**
- Arabic/RTL text with embedded quotes: Always escape with backslash
- Regex patterns: Double all backslashes (`\d` → `\\d`)
- Check for missing/extra commas at line reported in error
#### [UNTRANSLATED] Pollution
**Problem**: Hundreds of [UNTRANSLATED] markers from incomplete translation attempts
**Solution**:
@@ -326,6 +454,54 @@ ignore = [
## Real-World Examples
### Complete Arabic Translation with Validation (Batch Method)
```bash
# Check status
python scripts/translations/translation_analyzer.py --language ar-AR --summary
# Result: 50% complete, 1088 missing
# Extract in batches due to AI token limits
python scripts/translations/compact_translator.py ar-AR --output ar_AR_batch --batch-size 400
# Created: ar_AR_batch_1_of_3.json (400 entries)
# ar_AR_batch_2_of_3.json (400 entries)
# ar_AR_batch_3_of_3.json (288 entries)
# [Send each batch to AI for translation]
# Validate translated batches before merging
python scripts/translations/json_validator.py --all-batches ar_AR
# Found errors in batch 1 and 2:
# - Line 263: Unescaped quotes in "انقر "إضافة ملفات""
# - Line 132: Unescaped quotes in "أو "and""
# - Line 213: Invalid escape "\d{4}"
# Fix errors manually or with sed, then validate again
python scripts/translations/json_validator.py --all-batches ar_AR
# All valid!
# Merge all batches
python3 << 'EOF'
import json
merged = {}
for i in range(1, 4):
with open(f'ar_AR_batch_{i}_of_3.json', 'r', encoding='utf-8') as f:
merged.update(json.load(f))
with open('ar_AR_merged.json', 'w', encoding='utf-8') as f:
json.dump(merged, f, ensure_ascii=False, indent=2)
EOF
# Apply merged translations
python scripts/translations/translation_merger.py ar-AR apply-translations --translations-file ar_AR_merged.json
# Result: Applied 1088 translations
# Beautify to match en-GB structure
python scripts/translations/json_beautifier.py --language ar-AR
# Check final progress
python scripts/translations/translation_analyzer.py --language ar-AR --summary
# Result: 98.7% complete, 9 missing, 20 untranslated
```
### Complete Italian Translation (Compact Method)
```bash
# Check status
+259
View File
@@ -0,0 +1,259 @@
#!/usr/bin/env python3
"""
JSON Validator for Translation Files
Validates JSON syntax in translation files and reports detailed error information.
Useful for validating batch translation files before merging.
Usage:
python3 json_validator.py <file_or_pattern>
python3 json_validator.py ar_AR_batch_*.json
python3 json_validator.py ar_AR_batch_1_of_3.json
python3 json_validator.py --all-batches ar_AR
"""
import json
import sys
import argparse
import glob
from pathlib import Path
def get_line_context(file_path, line_num, context_lines=3):
"""Get lines around the error for context"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
start = max(0, line_num - context_lines - 1)
end = min(len(lines), line_num + context_lines)
context = []
for i in range(start, end):
marker = ">>> " if i == line_num - 1 else " "
context.append(f"{marker}{i+1:4d}: {lines[i].rstrip()}")
return "\n".join(context)
except Exception as e:
return f"Could not read context: {e}"
def get_character_context(file_path, char_pos, context_chars=100):
"""Get characters around the error position"""
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
start = max(0, char_pos - context_chars)
end = min(len(content), char_pos + context_chars)
before = content[start:char_pos]
error_char = content[char_pos] if char_pos < len(content) else "EOF"
after = content[char_pos+1:end]
return {
'before': before,
'error_char': error_char,
'after': after,
'display': f"{before}[{error_char}]{after}"
}
except Exception as e:
return None
def validate_json_file(file_path):
"""Validate a single JSON file and return detailed error info"""
result = {
'file': str(file_path),
'valid': False,
'error': None,
'line': None,
'column': None,
'position': None,
'context': None,
'char_context': None,
'entry_count': 0
}
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
result['valid'] = True
result['entry_count'] = len(data) if isinstance(data, dict) else 0
except json.JSONDecodeError as e:
result['error'] = e.msg
result['line'] = e.lineno
result['column'] = e.colno
result['position'] = e.pos
result['context'] = get_line_context(file_path, e.lineno)
result['char_context'] = get_character_context(file_path, e.pos)
except FileNotFoundError:
result['error'] = "File not found"
except Exception as e:
result['error'] = str(e)
return result
def print_validation_result(result, verbose=True):
"""Print validation result in a formatted way"""
file_name = Path(result['file']).name
if result['valid']:
print(f"✓ {file_name}: Valid JSON ({result['entry_count']} entries)")
else:
print(f"✗ {file_name}: Invalid JSON")
print(f" Error: {result['error']}")
if result['line']:
print(f" Location: Line {result['line']}, Column {result['column']} (character {result['position']})")
if verbose and result['context']:
print(f"\n Context:")
for line in result['context'].split('\n'):
print(f" {line}")
if verbose and result['char_context']:
print(f"\n Character context:")
print(f" ...{result['char_context']['display'][-150:]}...")
print(f" Error character: {repr(result['char_context']['error_char'])}")
print()
def get_common_fixes(error_msg):
"""Suggest common fixes based on error message"""
fixes = []
if "Expecting ',' delimiter" in error_msg:
fixes.append("Missing comma between JSON entries")
fixes.append("Check for unescaped quotes inside string values")
if "Invalid \\escape" in error_msg or "Invalid escape" in error_msg:
fixes.append("Unescaped backslash in string (use \\\\ for literal backslash)")
fixes.append("Common in regex patterns: \\d should be \\\\d")
if "Expecting property name" in error_msg:
fixes.append("Missing or extra comma")
fixes.append("Trailing comma before closing brace")
if "Expecting value" in error_msg:
fixes.append("Missing value after colon")
fixes.append("Extra comma")
return fixes
def main():
parser = argparse.ArgumentParser(
description='Validate JSON syntax in translation files',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
Validate single file:
python3 json_validator.py ar_AR_batch_1_of_3.json
Validate all batches for a language:
python3 json_validator.py --all-batches ar_AR
Validate pattern:
python3 json_validator.py "ar_AR_batch_*.json"
Validate multiple files:
python3 json_validator.py file1.json file2.json file3.json
"""
)
parser.add_argument(
'files',
nargs='*',
help='JSON file(s) to validate (supports wildcards)'
)
parser.add_argument(
'--all-batches',
metavar='LANGUAGE',
help='Validate all batch files for a language (e.g., ar_AR)'
)
parser.add_argument(
'--quiet',
action='store_true',
help='Only show files with errors'
)
parser.add_argument(
'--brief',
action='store_true',
help='Brief output without context'
)
args = parser.parse_args()
# Determine which files to validate
files_to_validate = []
if args.all_batches:
pattern = f"{args.all_batches}_batch_*.json"
files_to_validate = glob.glob(pattern)
if not files_to_validate:
print(f"No batch files found matching: {pattern}")
return 1
elif args.files:
for file_pattern in args.files:
if '*' in file_pattern or '?' in file_pattern:
files_to_validate.extend(glob.glob(file_pattern))
else:
files_to_validate.append(file_pattern)
else:
parser.print_help()
return 1
if not files_to_validate:
print("No files to validate")
return 1
# Sort files for consistent output
files_to_validate.sort()
print(f"Validating {len(files_to_validate)} file(s)...\n")
# Validate each file
results = []
for file_path in files_to_validate:
result = validate_json_file(file_path)
results.append(result)
if not args.quiet or not result['valid']:
print_validation_result(result, verbose=not args.brief)
# Summary
valid_count = sum(1 for r in results if r['valid'])
invalid_count = len(results) - valid_count
print("=" * 60)
print(f"Summary: {valid_count} valid, {invalid_count} invalid")
# Show common fixes for errors
if invalid_count > 0:
all_errors = [r['error'] for r in results if r['error']]
unique_error_types = set(all_errors)
print("\nCommon fixes:")
fixes_shown = set()
for error in unique_error_types:
fixes = get_common_fixes(error)
for fix in fixes:
if fix not in fixes_shown:
print(f" • {fix}")
fixes_shown.add(fix)
return 0 if invalid_count == 0 else 1
if __name__ == '__main__':
sys.exit(main())
@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""
Validate JSON structure and formatting of translation files.
Checks for:
- Valid JSON syntax
- Consistent key structure with en-GB
- Missing keys
- Extra keys not in en-GB
- Malformed entries
Usage:
python scripts/translations/validate_json_structure.py [--language LANG]
"""
import json
import sys
from pathlib import Path
from typing import Dict, List, Set
import argparse
def get_all_keys(d: dict, parent_key: str = '', sep: str = '.') -> Set[str]:
"""Get all keys from nested dict as dot-notation paths."""
keys = set()
for k, v in d.items():
new_key = f"{parent_key}{sep}{k}" if parent_key else k
keys.add(new_key)
if isinstance(v, dict):
keys.update(get_all_keys(v, new_key, sep=sep))
return keys
def validate_json_file(file_path: Path) -> tuple[bool, str]:
"""Validate that a file contains valid JSON."""
try:
with open(file_path, 'r', encoding='utf-8') as f:
json.load(f)
return True, "Valid JSON"
except json.JSONDecodeError as e:
return False, f"Invalid JSON at line {e.lineno}, column {e.colno}: {e.msg}"
except Exception as e:
return False, f"Error reading file: {str(e)}"
def validate_structure(
en_gb_keys: Set[str],
lang_keys: Set[str],
lang_code: str
) -> Dict:
"""Compare structure between en-GB and target language."""
missing_keys = en_gb_keys - lang_keys
extra_keys = lang_keys - en_gb_keys
return {
'language': lang_code,
'missing_keys': sorted(missing_keys),
'extra_keys': sorted(extra_keys),
'total_keys': len(lang_keys),
'expected_keys': len(en_gb_keys),
'missing_count': len(missing_keys),
'extra_count': len(extra_keys)
}
def print_validation_result(result: Dict, verbose: bool = False):
"""Print validation results in readable format."""
lang = result['language']
print(f"\n{'='*100}")
print(f"Language: {lang}")
print(f"{'='*100}")
print(f" Total keys: {result['total_keys']}")
print(f" Expected keys (en-GB): {result['expected_keys']}")
print(f" Missing keys: {result['missing_count']}")
print(f" Extra keys: {result['extra_count']}")
if result['missing_count'] == 0 and result['extra_count'] == 0:
print(f" ✅ Structure matches en-GB perfectly!")
else:
if result['missing_count'] > 0:
print(f"\n ⚠️ Missing {result['missing_count']} key(s):")
if verbose or result['missing_count'] <= 20:
for key in result['missing_keys'][:50]:
print(f" - {key}")
if result['missing_count'] > 50:
print(f" ... and {result['missing_count'] - 50} more")
else:
print(f" (use --verbose to see all)")
if result['extra_count'] > 0:
print(f"\n ⚠️ Extra {result['extra_count']} key(s) not in en-GB:")
if verbose or result['extra_count'] <= 20:
for key in result['extra_keys'][:50]:
print(f" - {key}")
if result['extra_count'] > 50:
print(f" ... and {result['extra_count'] - 50} more")
else:
print(f" (use --verbose to see all)")
print("-" * 100)
def main():
parser = argparse.ArgumentParser(
description='Validate translation JSON structure'
)
parser.add_argument(
'--language',
help='Specific language code to validate (e.g., es-ES)',
default=None
)
parser.add_argument(
'--verbose', '-v',
action='store_true',
help='Show all missing/extra keys'
)
parser.add_argument(
'--json',
action='store_true',
help='Output results as JSON'
)
args = parser.parse_args()
# Define paths
locales_dir = Path('frontend/public/locales')
en_gb_path = locales_dir / 'en-GB' / 'translation.json'
if not en_gb_path.exists():
print(f"❌ Error: en-GB translation file not found at {en_gb_path}")
sys.exit(1)
# Validate en-GB itself
is_valid, message = validate_json_file(en_gb_path)
if not is_valid:
print(f"❌ Error in en-GB file: {message}")
sys.exit(1)
# Load en-GB structure
with open(en_gb_path, 'r', encoding='utf-8') as f:
en_gb = json.load(f)
en_gb_keys = get_all_keys(en_gb)
# Get list of languages to validate
if args.language:
languages = [args.language]
else:
languages = [
d.name for d in locales_dir.iterdir()
if d.is_dir() and d.name != 'en-GB' and (d / 'translation.json').exists()
]
results = []
json_errors = []
# Validate each language
for lang_code in sorted(languages):
lang_path = locales_dir / lang_code / 'translation.json'
if not lang_path.exists():
print(f"⚠️ Warning: {lang_code}/translation.json not found, skipping")
continue
# First check if JSON is valid
is_valid, message = validate_json_file(lang_path)
if not is_valid:
json_errors.append({
'language': lang_code,
'file': str(lang_path),
'error': message
})
continue
# Load and compare structure
with open(lang_path, 'r', encoding='utf-8') as f:
lang_data = json.load(f)
lang_keys = get_all_keys(lang_data)
result = validate_structure(en_gb_keys, lang_keys, lang_code)
results.append(result)
# Output results
if args.json:
output = {
'json_errors': json_errors,
'structure_validation': results
}
print(json.dumps(output, indent=2, ensure_ascii=False))
else:
# Print JSON errors first
if json_errors:
print("\n❌ JSON Syntax Errors:")
print("=" * 100)
for error in json_errors:
print(f"\nLanguage: {error['language']}")
print(f"File: {error['file']}")
print(f"Error: {error['error']}")
print("\n")
# Print structure validation results
if results:
print("\n📊 Structure Validation Summary:")
print(f" Languages validated: {len(results)}")
perfect = sum(1 for r in results if r['missing_count'] == 0 and r['extra_count'] == 0)
print(f" Perfect matches: {perfect}/{len(results)}")
total_missing = sum(r['missing_count'] for r in results)
total_extra = sum(r['extra_count'] for r in results)
print(f" Total missing keys: {total_missing}")
print(f" Total extra keys: {total_extra}")
for result in results:
print_validation_result(result, verbose=args.verbose)
if not json_errors and perfect == len(results):
print("\n✅ All translations have perfect structure!")
# Exit with error code if issues found
has_issues = len(json_errors) > 0 or any(
r['missing_count'] > 0 or r['extra_count'] > 0 for r in results
)
sys.exit(1 if has_issues else 0)
if __name__ == '__main__':
main()
@@ -0,0 +1,189 @@
#!/usr/bin/env python3
"""
Validate that translation files have the same placeholders as en-GB (source of truth).
Usage:
python scripts/translations/validate_placeholders.py [--language LANG] [--fix]
--language: Validate specific language (e.g., es-ES, de-DE)
--fix: Automatically remove extra placeholders (use with caution)
"""
import json
import re
import sys
from pathlib import Path
from typing import Dict, List, Set, Tuple
import argparse
def find_placeholders(text: str) -> Set[str]:
"""Find all placeholders in text like {n}, {{var}}, {0}, etc."""
if not isinstance(text, str):
return set()
return set(re.findall(r'\{\{?[^}]+\}\}?', text))
def flatten_dict(d: dict, parent_key: str = '', sep: str = '.') -> Dict[str, str]:
"""Flatten nested dict to dot-notation keys."""
items = []
for k, v in d.items():
new_key = f"{parent_key}{sep}{k}" if parent_key else k
if isinstance(v, dict):
items.extend(flatten_dict(v, new_key, sep=sep).items())
else:
items.append((new_key, v))
return dict(items)
def validate_language(
en_gb_flat: Dict[str, str],
lang_flat: Dict[str, str],
lang_code: str
) -> List[Dict]:
"""Validate placeholders for a language against en-GB."""
issues = []
for key in en_gb_flat:
if key not in lang_flat:
continue
en_placeholders = find_placeholders(en_gb_flat[key])
lang_placeholders = find_placeholders(lang_flat[key])
if en_placeholders != lang_placeholders:
missing = en_placeholders - lang_placeholders
extra = lang_placeholders - en_placeholders
issue = {
'language': lang_code,
'key': key,
'missing': missing,
'extra': extra,
'en_text': en_gb_flat[key],
'lang_text': lang_flat[key]
}
issues.append(issue)
return issues
def print_issues(issues: List[Dict], verbose: bool = False):
"""Print validation issues in a readable format."""
if not issues:
print("✅ No placeholder validation issues found!")
return
print(f"❌ Found {len(issues)} placeholder validation issue(s):\n")
print("=" * 100)
for i, issue in enumerate(issues, 1):
print(f"\n{i}. Language: {issue['language']}")
print(f" Key: {issue['key']}")
if issue['missing']:
print(f" ⚠️ MISSING placeholders: {issue['missing']}")
if issue['extra']:
print(f" ⚠️ EXTRA placeholders: {issue['extra']}")
if verbose:
print(f" EN-GB: {issue['en_text'][:150]}")
print(f" {issue['language']}: {issue['lang_text'][:150]}")
print("-" * 100)
def main():
parser = argparse.ArgumentParser(
description='Validate translation placeholder consistency'
)
parser.add_argument(
'--language',
help='Specific language code to validate (e.g., es-ES)',
default=None
)
parser.add_argument(
'--verbose', '-v',
action='store_true',
help='Show full text samples for each issue'
)
parser.add_argument(
'--json',
action='store_true',
help='Output results as JSON'
)
args = parser.parse_args()
# Define paths
locales_dir = Path('frontend/public/locales')
en_gb_path = locales_dir / 'en-GB' / 'translation.json'
if not en_gb_path.exists():
print(f"❌ Error: en-GB translation file not found at {en_gb_path}")
sys.exit(1)
# Load en-GB (source of truth)
with open(en_gb_path, 'r', encoding='utf-8') as f:
en_gb = json.load(f)
en_gb_flat = flatten_dict(en_gb)
# Get list of languages to validate
if args.language:
languages = [args.language]
else:
# Validate all languages except en-GB
languages = [
d.name for d in locales_dir.iterdir()
if d.is_dir() and d.name != 'en-GB' and (d / 'translation.json').exists()
]
all_issues = []
# Validate each language
for lang_code in sorted(languages):
lang_path = locales_dir / lang_code / 'translation.json'
if not lang_path.exists():
print(f"⚠️ Warning: {lang_code}/translation.json not found, skipping")
continue
with open(lang_path, 'r', encoding='utf-8') as f:
lang_data = json.load(f)
lang_flat = flatten_dict(lang_data)
issues = validate_language(en_gb_flat, lang_flat, lang_code)
all_issues.extend(issues)
# Output results
if args.json:
print(json.dumps(all_issues, indent=2, ensure_ascii=False))
else:
if all_issues:
# Group by language
by_language = {}
for issue in all_issues:
lang = issue['language']
if lang not in by_language:
by_language[lang] = []
by_language[lang].append(issue)
print(f"📊 Validation Summary:")
print(f" Total issues: {len(all_issues)}")
print(f" Languages with issues: {len(by_language)}\n")
for lang in sorted(by_language.keys()):
print(f"\n{'='*100}")
print(f"Language: {lang} ({len(by_language[lang])} issue(s))")
print(f"{'='*100}")
print_issues(by_language[lang], verbose=args.verbose)
else:
print("✅ All translations have correct placeholders!")
# Exit with error code if issues found
sys.exit(1 if all_issues else 0)
if __name__ == '__main__':
main()