Compare commits

..
58 Commits
Author SHA1 Message Date
Anthony Stirling e32a0c04c4 docs 2025-10-09 18:20:22 +01:00
b695e3900e Feature/v2/improve sign (#4627)
# 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>
Co-authored-by: Claude <noreply@anthropic.com>
2025-10-09 13:35:42 +01:00
2158ee4db6 Feature/v2/googleDrive (#4592)
Google drive oss. Shouldn't have any effect on pr deployment. 
Mainly the removal of the old integration via backend.
I have added the picker service and lazy loading of the required google
dependency scripts when the necessary environment variables have been
implemented.

---------

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2025-10-09 10:22:17 +01:00
James BruntonandGitHub 3090a85726 Assign shortcuts by default to only the quick access items (#4622)
# Description of Changes
Change shortcuts to just be a limited set for Quick Access tools rather
than for everything to avoid breaking browser key commands by default.

Also fixes a bunch of types of variables that were representing
`ToolId`s (I stopped at `automate` because there's loads in there so
I've just introduced some `any` casts for now 😭)
2025-10-08 17:18:05 +01:00
James BruntonandGitHub d714a1617f V2 File Editor Shortcuts (#4619)
# Description of Changes
Add shortcut icons for Pin and Download, and rename Delete to Close for
consistency with the rest of the tool.
2025-10-07 11:48:42 +01:00
James BruntonandGitHub 2a29bda34f Enable ESLint no-undef rule (#4346)
# Description of Changes
Enable ESLint [no-undef
rule](https://eslint.org/docs/latest/rules/no-undef)
2025-10-06 12:10:00 +00:00
ab6edd3196 Feature/v2/toggle_for_auto_unzip (#4584)
## default 
<img width="1012" height="627"
alt="{BF57458D-50A6-4057-94F1-D6AB4628EFD8}"
src="https://github.com/user-attachments/assets/85e550ab-0aed-4341-be95-d5d3bc7146db"
/>

## disabled
<img width="1141" height="620"
alt="{140DB87B-05CF-4E0E-A14A-ED15075BD2EE}"
src="https://github.com/user-attachments/assets/e0f56e84-fb9d-4787-b5cb-ba7c5a54b1e1"
/>

## unzip options
<img width="530" height="255"
alt="{482CE185-73D5-4D90-91BB-B9305C711391}"
src="https://github.com/user-attachments/assets/609b18ee-4eae-4cee-afc1-5db01f9d1088"
/>
<img width="579" height="473"
alt="{4DFCA96D-792D-4370-8C62-4BA42C9F1A5F}"
src="https://github.com/user-attachments/assets/c67fa4af-04ef-41df-9420-65ce4247e25b"
/>

## pop up and maintains version metadata
<img width="1071" height="1220"
alt="{7F2A785C-5717-4A79-9D45-74BDA46DF273}"
src="https://github.com/user-attachments/assets/9374cd2a-b7e5-46c4-a722-e141ab42f0de"
/>

---------

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
2025-10-06 11:29:38 +00:00
be7e79be55 fix(viewer): make initial zoom setup robust and clear timers in ZoomAPIBridge (#4588)
# Description of Changes

**What was changed**
- Reworked the initial zoom `useEffect`:
- Early return when `zoom` is unavailable or initial zoom was already
applied.
  - Extracted an `attemptInitialZoom` function with a single retry path.
- Introduced proper timeout handles (`timer`, `retryTimer`) and added a
cleanup function to clear them on unmount/re-render.
- Expanded the effect dependency array to include `zoomState` to avoid
stale state issues.
- Switched to nullish coalescing for safer defaulting of
`currentZoomLevel` (`zoomState.currentZoomLevel ?? 1.4`).
- Minor logging adjustments to clarify delayed/failed initialization
paths.

**Why the change was made**
- The previous implementation risked leaving dangling timers and could
re-attempt zoom unnecessarily, causing flicker or inconsistent initial
zoom when the viewport wasn’t ready.
- Including `zoomState` in dependencies ensures the component reacts to
state changes correctly and avoids stale reads.
- Cleanup of timers prevents memory leaks and race conditions during
rapid mounts/unmounts or navigation.

---

## 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: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
2025-10-06 12:24:12 +01:00
stirlingbot[bot]GitHubstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
c9e1b8eec5 🌐 [V2] Sync Translations + Update README Progress Table (#4578)
### 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-04 10:53:58 +01:00
eba93a3b6c Frontend V2 Ui Tweaks (#4590)
* Top Controls only show when files > 0
* Moved content down so top controls don't obscure
* Viewer background set to match workbench and shadow around pages added
so that page boundaries are visible
* unsaved-changes modal rework

---------

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
2025-10-03 17:02:05 +01:00
Reece BrowneandGitHub 03e81a0f16 Bug/v2/mime (#4582)
# 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.
2025-10-03 09:19:44 +01:00
Reece BrowneandGitHub f9ac1bd62e Feature/v2/navigate save prompt (#4586)
# 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.
2025-10-02 20:14:35 +01:00
8aa6aff53a Config becomes account when enableLogin (#4585)
<img width="1023" height="1257"
alt="{36B7A86A-4B9A-433F-9784-B9E923FF4872}"
src="https://github.com/user-attachments/assets/b4cf4f1b-d161-457b-a8ef-642e67988cb1"
/>

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
2025-10-02 17:49:10 +01:00
EthanHealy01andGitHub 458bb641b5 Feature/adjust colors contrast tool (#4544)
# Description of Changes

- Addition of the "Adjust Colors/Contrast" tool

---

## 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.
2025-10-02 17:48:39 +01:00
247f82b5a7 Created shared component (#4580)
## New one on the left
<img width="1184" height="351"
alt="{A4B797C1-E52E-4F90-8EAA-C53CDD0BBB95}"
src="https://github.com/user-attachments/assets/d6cfbc9f-350d-48b9-8ae3-def723b72ad7"
/>
<img width="1144" height="1268"
alt="{4EE3680E-EFF2-4C7E-A12F-1050CA96D687}"
src="https://github.com/user-attachments/assets/a7f4c0bc-67c8-4400-bcad-be68108809e1"
/>
<img width="1114" height="784"
alt="{2811741D-9CEB-47A4-8E7D-CB8CE50B8088}"
src="https://github.com/user-attachments/assets/982dca0f-8505-4f04-b699-7332b1ee81da"
/>

---------

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
2025-10-02 13:10:13 +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
3dd4a33595 Add page numbers (#4500)
# Description of Changes

Idk why i added a brightness detector for white/black text but hey its
been done now


## 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: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2025-10-01 12:18:31 +01:00
stirlingbot[bot]GitHubstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
fe2bfd8739 🌐 [V2] Sync Translations + Update README Progress Table (#4560)
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-10-01 00:04:16 +01:00
Anthony StirlingandGitHub a15b0e33d5 test (#4559) 2025-10-01 00:03:26 +01:00
stirlingbot[bot]GitHubstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
0a242f6a57 🌐 [V2] Sync Translations + Update README Progress Table (#4558)
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-09-30 15:53:50 +01:00
Anthony StirlingandGitHub 3c46010155 Lang translation (#4556)
# 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.
2025-09-30 14:36:44 +01:00
02189a67bd refactor(frontend): remove unused React default imports (#4529)
## Description of Changes

- Removed unused `React` default imports across multiple frontend
components.
- Updated imports to only include required React hooks and types (e.g.,
`useState`, `useEffect`, `Suspense`, `createContext`).
- Ensured consistency with React 17+ JSX transform, where default
`React` import is no longer required.
- This cleanup reduces bundle size slightly and aligns code with modern
React best practices.

---

## 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: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
2025-09-29 13:01:09 +01:00
d4985f57d4 style(frontend): standardize semicolons across TS/JS configs and components (#4525)
# Description of Changes

- **What was changed**
- Added missing trailing semicolons across React components, utilities,
tests, and build/test configs to ensure consistent formatting.
- Normalized arrow-function assignments to end with semicolons (e.g.,
`const fn = () => { ... };`).
- Harmonized imports/exports and object literals in configuration files
to terminate statements with semicolons.
  - Updated test setup files and mocks to consistently use semicolons.

- **Why the change was made**
- Aligns the codebase with ESLint/Prettier conventions to prevent
auto-format churn and avoid ASI (automatic semicolon insertion) edge
cases.
- Improves readability and produces cleaner diffs in future
contributions.

---

## 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: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
2025-09-29 12:55:53 +01:00
4ab66fdf14 feat(frontend): refactor ToolStep props handling and children usage (#4524)
# Description of Changes

- Replaced `children` being passed as a prop to `React.createElement`
with proper usage as additional arguments (fixes
`react/no-children-prop` warning).
- Added stricter handling of `isVisible` and `_excludeFromCount` props
with improved variable naming (`stepProps`) for clarity.
- Refactored JSX structure for collapsed/expanded rendering logic to
improve readability.

This change was made to clean up prop handling, remove ESLint warnings,
and make the component more consistent with React best practices.

---

## 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: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
2025-09-29 12:51:42 +01:00
c19abe0da7 refactor(frontend): add display names for forwardRef components (#4523)
# Description of Changes

- Added `displayName` properties to `QuickAccessBar` and `TextInput`
components.
- This improves debugging and React DevTools readability by ensuring
components have clear, identifiable names instead of anonymous
`ForwardRef`.
- Minor formatting cleanup in `QuickAccessBar` for consistency.

---

## 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: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
2025-09-29 12:48:35 +01:00
dd6b7968db refactor(types): deduplicate AutomateParameters definition in automation types (#4522)
# Description of Changes

- Removed duplicate `AutomateParameters` interface from
`frontend/src/types/automation.ts`
- The interface was already defined earlier in the same file, leading to
redundancy
- Keeps type definitions consistent and avoids confusion

---

## 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: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
2025-09-29 12:45:37 +01:00
LudyandGitHub 2228ae7197 ci(frontend): update licenses workflow dependencies and Node.js version (#4520)
# Description of Changes

- Added the workflow file itself
(`.github/workflows/frontend-licenses-update.yml`) to the trigger paths.
- Updated `step-security/harden-runner` from **v2.12.2** → **v2.13.1**.
- Bumped `actions/checkout` from **v4.2.2** → **v5.0.0**.  
- Upgraded `actions/setup-node` from **v4.1.0** (Node.js 18) →
**v5.0.0** (Node.js 22).
- Updated `actions/github-script` from **v7.0.1** → **v8.0.0**.  

These changes modernize the workflow, ensure compatibility with newer
Node.js versions, and keep GitHub Actions up to date.

---

## 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.
2025-09-29 12:21:48 +01:00
Reece BrowneandGitHub 30987dcad2 Dockerfile package (#4517) 2025-09-26 20:56:26 +01:00
Reece BrowneandGitHub 43beadbdcb update embedpdf (#4516) 2025-09-26 19:46:31 +01:00
416d79aed3 Feature/v2/sign (#4485)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: James Brunton <james@stirlingpdf.com>
2025-09-26 19:11:03 +01:00
abc0988fdf Feature/v2/reader-and-multitool-navigation (#4514)
Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
2025-09-26 16:29:58 +01:00
c7e0ea5b5b Add React-based remove annotations tool (#4504)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-26 15:45:51 +01:00
Anthony StirlingandGitHub b35447934e remove tools (#4513) 2025-09-26 15:42:23 +01:00
d1e82eb8f1 add multi page layout tool (#4507)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2025-09-26 15:41:39 +01:00
7d44cc1a40 Bugfix/V2/remove-timeout-on-fetch (#4510)
Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
2025-09-26 15:36:41 +01:00
EthanHealy01andGitHub 0bdc6466ca add the reorganize pages tool (#4506) 2025-09-26 12:49:18 +01:00
EthanHealy01andGitHub f2a6e95fcf Feature/remove images (#4503) 2025-09-26 12:46:02 +01:00
EthanHealy01andGitHub 0c08764669 add attatchments tool (#4502) 2025-09-26 12:45:31 +01:00
Anthony StirlingandGitHub 9758e871d4 feat: Add React-based extract-images tool (#4501) 2025-09-26 12:45:15 +01:00
Anthony StirlingandGitHub 18fa16f08e Invert colors (#4498) 2025-09-26 12:44:25 +01:00
233b710b78 Convert extract-image-scans to React component (#4505)
Co-authored-by: Claude <noreply@anthropic.com>
2025-09-26 12:38:10 +01:00
Reece BrowneandGitHub d613a4659e Feature/v2/exportpdf (#4487)
# 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.
2025-09-26 10:28:09 +01:00
Reece BrowneandGitHub 03f484e0c0 Fix (#4495)
# 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.
2025-09-26 10:03:35 +01:00
EthanHealy01andGitHub fd52dc0226 Feature/toasts and error handling (#4496)
# Description of Changes

- Added error handling and toast notifications

---

## 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.
2025-09-25 21:03:53 +01:00
EthanHealy01andGitHub 21b1428ab5 Feature/v2/fuzzy tool search (#4482)
# 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

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [x] 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)
- [x] I have performed a self-review of my own code
- [x] 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.
2025-09-25 16:32:30 +01:00
Anthony StirlingandGitHub 1219cebd07 Language stuff (#4490)
# 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.
2025-09-25 12:50:19 +01:00
Anthony StirlingandGitHub 166f6d2d98 path (#4488)
# 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.
2025-09-24 20:37:51 +01:00
Anthony StirlingandGitHub 6441dc1d6f fixes V2 (#4483)
# 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.
2025-09-24 15:01:18 +01:00
25bedf064f Update PDF.js integration and dependencies (#4360)
Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
2025-09-24 13:07:27 +01:00
EthanHealy01andGitHub 13eff6b333 Addition of the Add Stamp to PDF tool (#4440) 2025-09-23 14:01:42 +01:00
Anthony StirlingGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>a <a>ConnorYohReece Browne
46a4a978fc Booklet and server sign (#4371)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: a <a>
Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
2025-09-23 11:24:48 +01:00
325 changed files with 42180 additions and 69743 deletions
+345
View File
@@ -0,0 +1,345 @@
"""
Author: Ludy87
Description: This script processes JSON translation files for localization checks. It compares translation files in a branch with
a reference file to ensure consistency. The script performs two main checks:
1. Verifies that the number of translation keys in the translation files matches the reference file.
2. Ensures that all keys in the translation files are present in the reference file and vice versa.
The script also provides functionality to update the translation files to match the reference file by adding missing keys and
adjusting the format.
Usage:
python check_language_json.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
"""
# Sample for Windows:
# python .github/scripts/check_language_json.py --reference-file frontend/public/locales/en-GB/translation.json --branch "" --files frontend/public/locales/de-DE/translation.json frontend/public/locales/fr-FR/translation.json
import copy
import glob
import os
import argparse
import re
import json
def find_duplicate_keys(file_path, keys=None, prefix=""):
"""
Identifies duplicate keys in a JSON file (including nested keys).
:param file_path: Path to the JSON file.
:param keys: Dictionary to track keys (used for recursion).
:param prefix: Prefix for nested keys.
:return: List of tuples (key, first_occurrence_path, duplicate_path).
"""
if keys is None:
keys = {}
duplicates = []
with open(file_path, "r", encoding="utf-8") as file:
data = json.load(file)
def process_dict(obj, current_prefix=""):
for key, value in obj.items():
full_key = f"{current_prefix}.{key}" if current_prefix else key
if isinstance(value, dict):
process_dict(value, full_key)
else:
if full_key in keys:
duplicates.append((full_key, keys[full_key], full_key))
else:
keys[full_key] = full_key
process_dict(data, prefix)
return duplicates
# Maximum size for JSON files (e.g., 500 KB)
MAX_FILE_SIZE = 500 * 1024
def parse_json_file(file_path):
"""
Parses a JSON translation file and returns a flat dictionary of all keys.
:param file_path: Path to the JSON file.
:return: Dictionary with flattened keys.
"""
with open(file_path, "r", encoding="utf-8") as file:
data = json.load(file)
def flatten_dict(d, parent_key="", sep="."):
items = {}
for k, v in d.items():
new_key = f"{parent_key}{sep}{k}" if parent_key else k
if isinstance(v, dict):
items.update(flatten_dict(v, new_key, sep=sep))
else:
items[new_key] = v
return items
return flatten_dict(data)
def unflatten_dict(d, sep="."):
"""
Converts a flat dictionary with dot notation keys back to nested dict.
:param d: Flattened dictionary.
:param sep: Separator used in keys.
:return: Nested dictionary.
"""
result = {}
for key, value in d.items():
parts = key.split(sep)
current = result
for part in parts[:-1]:
if part not in current:
current[part] = {}
current = current[part]
current[parts[-1]] = value
return result
def write_json_file(file_path, updated_properties):
"""
Writes updated properties back to the JSON file.
:param file_path: Path to the JSON file.
:param updated_properties: Dictionary of updated properties to write.
"""
nested_data = unflatten_dict(updated_properties)
with open(file_path, "w", encoding="utf-8", newline="\n") as file:
json.dump(nested_data, file, ensure_ascii=False, indent=2)
file.write("\n") # Add trailing newline
def update_missing_keys(reference_file, file_list, branch=""):
"""
Updates missing keys in the translation files based on the reference file.
:param reference_file: Path to the reference JSON file.
:param file_list: List of translation files to update.
:param branch: Branch where the files are located.
"""
reference_properties = parse_json_file(reference_file)
for file_path in file_list:
basename_current_file = os.path.basename(os.path.join(branch, file_path))
if (
basename_current_file == os.path.basename(reference_file)
or not file_path.endswith(".json")
or not os.path.dirname(file_path).endswith("locales")
):
continue
current_properties = parse_json_file(os.path.join(branch, file_path))
updated_properties = {}
for ref_key, ref_value in reference_properties.items():
if ref_key in current_properties:
# Keep the current translation
updated_properties[ref_key] = current_properties[ref_key]
else:
# Add missing key with reference value
updated_properties[ref_key] = ref_value
write_json_file(os.path.join(branch, file_path), updated_properties)
def check_for_missing_keys(reference_file, file_list, branch):
update_missing_keys(reference_file, file_list, branch)
def read_json_keys(file_path):
if os.path.isfile(file_path) and os.path.exists(file_path):
return parse_json_file(file_path)
return {}
def check_for_differences(reference_file, file_list, branch, actor):
reference_branch = branch
basename_reference_file = os.path.basename(reference_file)
report = []
report.append(f"#### 🔄 Reference Branch: `{reference_branch}`")
reference_keys = read_json_keys(reference_file)
has_differences = False
only_reference_file = True
file_arr = file_list
if len(file_list) == 1:
file_arr = file_list[0].split()
base_dir = os.path.abspath(
os.path.join(os.getcwd(), "frontend", "public", "locales")
)
for file_path in file_arr:
file_normpath = os.path.normpath(file_path)
absolute_path = os.path.abspath(file_normpath)
# Verify that file is within the expected directory
if not absolute_path.startswith(base_dir):
raise ValueError(f"Unsafe file found: {file_normpath}")
# Verify file size before processing
if os.path.getsize(os.path.join(branch, file_normpath)) > MAX_FILE_SIZE:
raise ValueError(
f"The file {file_normpath} is too large and could pose a security risk."
)
basename_current_file = os.path.basename(os.path.join(branch, file_normpath))
locale_dir = os.path.basename(os.path.dirname(file_normpath))
if (
basename_current_file == basename_reference_file
and locale_dir == "en-GB"
):
continue
if not file_normpath.endswith(".json") or basename_current_file != "translation.json":
continue
only_reference_file = False
report.append(f"#### 📃 **File Check:** `{locale_dir}/{basename_current_file}`")
current_keys = read_json_keys(os.path.join(branch, file_path))
reference_key_count = len(reference_keys)
current_key_count = len(current_keys)
if reference_key_count != current_key_count:
report.append("")
report.append("1. **Test Status:** ❌ **_Failed_**")
report.append(" - **Issue:**")
has_differences = True
if reference_key_count > current_key_count:
report.append(
f" - **_Mismatched key count_**: {reference_key_count} (reference) vs {current_key_count} (current). Translation keys are missing."
)
elif reference_key_count < current_key_count:
report.append(
f" - **_Too many keys_**: {reference_key_count} (reference) vs {current_key_count} (current). Please verify if there are additional keys that need to be removed."
)
else:
report.append("1. **Test Status:** ✅ **_Passed_**")
# Check for missing or extra keys
current_keys_set = set(current_keys.keys())
reference_keys_set = set(reference_keys.keys())
missing_keys = current_keys_set.difference(reference_keys_set)
extra_keys = reference_keys_set.difference(current_keys_set)
missing_keys_list = list(missing_keys)
extra_keys_list = list(extra_keys)
if missing_keys_list or extra_keys_list:
has_differences = True
missing_keys_str = "`, `".join(missing_keys_list)
extra_keys_str = "`, `".join(extra_keys_list)
report.append("2. **Test Status:** ❌ **_Failed_**")
report.append(" - **Issue:**")
if missing_keys_list:
report.append(
f" - **_Extra keys in `{locale_dir}/{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**."
)
if extra_keys_list:
report.append(
f" - **_Missing keys in `{locale_dir}/{basename_current_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_reference_file}`_**."
)
else:
report.append("2. **Test Status:** ✅ **_Passed_**")
if find_duplicate_keys(os.path.join(branch, file_normpath)):
has_differences = True
output = "\n".join(
[
f" - `{key}`: first at {first}, duplicate at `{duplicate}`"
for key, first, duplicate in find_duplicate_keys(
os.path.join(branch, file_normpath)
)
]
)
report.append("3. **Test Status:** ❌ **_Failed_**")
report.append(" - **Issue:**")
report.append(" - duplicate entries were found:")
report.append(output)
else:
report.append("3. **Test Status:** ✅ **_Passed_**")
report.append("")
report.append("---")
report.append("")
if has_differences:
report.append("## ❌ Overall Check Status: **_Failed_**")
report.append("")
report.append(
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.json](https://github.com/Stirling-Tools/Stirling-PDF/blob/V2/frontend/public/locales/en-GB/translation.json)"
)
else:
report.append("## ✅ Overall Check Status: **_Success_**")
report.append("")
report.append(
f"Thanks @{actor} for your help in keeping the translations up to date."
)
if not only_reference_file:
print("\n".join(report))
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Find missing keys")
parser.add_argument(
"--actor",
required=False,
help="Actor from PR.",
)
parser.add_argument(
"--reference-file",
required=True,
help="Path to the reference file.",
)
parser.add_argument(
"--branch",
type=str,
required=True,
help="Branch name.",
)
parser.add_argument(
"--check-file",
type=str,
required=False,
help="List of changed files, separated by spaces.",
)
parser.add_argument(
"--files",
nargs="+",
required=False,
help="List of changed files, separated by spaces.",
)
args = parser.parse_args()
# Sanitize --actor input to avoid injection attacks
if args.actor:
args.actor = re.sub(r"[^a-zA-Z0-9_\\-]", "", args.actor)
# Sanitize --branch input to avoid injection attacks
if args.branch:
args.branch = re.sub(r"[^a-zA-Z0-9\\-]", "", args.branch)
file_list = args.files
if file_list is None:
if args.check_file:
file_list = [args.check_file]
else:
file_list = glob.glob(
os.path.join(
os.getcwd(),
"frontend",
"public",
"locales",
"*",
"translation.json",
)
)
update_missing_keys(args.reference_file, file_list)
else:
check_for_differences(args.reference_file, file_list, args.branch, args.actor)
+85 -89
View File
@@ -3,7 +3,15 @@ name: Auto PR V2 Deployment
on:
pull_request:
types: [opened, synchronize, reopened, closed]
workflow_dispatch:
inputs:
pr:
description: "PR number to deploy"
required: true
allow_fork:
description: "Allow deploying fork PR?"
required: false
default: "false"
permissions:
contents: read
@@ -12,102 +20,93 @@ permissions:
jobs:
check-pr:
if: github.event.action != 'closed'
if: (github.event_name == 'pull_request' && github.event.action != 'closed') || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
outputs:
should_deploy: ${{ steps.check-conditions.outputs.should_deploy }}
pr_number: ${{ github.event.number }}
pr_repository: ${{ steps.get-pr-info.outputs.repository }}
pr_ref: ${{ steps.get-pr-info.outputs.ref }}
should_deploy: ${{ steps.decide.outputs.should_deploy }}
is_fork: ${{ steps.resolve.outputs.is_fork }}
allow_fork: ${{ steps.decide.outputs.allow_fork }}
pr_number: ${{ steps.resolve.outputs.pr_number }}
pr_repository: ${{ steps.resolve.outputs.repository }}
pr_ref: ${{ steps.resolve.outputs.ref }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
- name: Check deployment conditions
id: check-conditions
- name: Resolve PR info
id: resolve
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const { owner, repo } = context.repo;
let prNumber = context.eventName === 'workflow_dispatch'
? parseInt(process.env.INPUT_PR, 10)
: context.payload.number;
if (!Number.isInteger(prNumber)) { core.setFailed('Invalid PR number'); return; }
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
core.setOutput('pr_number', String(prNumber));
core.setOutput('repository', pr.head.repo.full_name);
core.setOutput('ref', pr.head.ref);
core.setOutput('is_fork', String(pr.head.repo.fork));
core.setOutput('base_ref', pr.base.ref);
core.setOutput('author', pr.user.login);
core.setOutput('state', pr.state);
- name: Decide deploy
id: decide
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
STATE: ${{ steps.resolve.outputs.state }}
IS_FORK: ${{ steps.resolve.outputs.is_fork }}
# nur bei workflow_dispatch gesetzt:
ALLOW_FORK_INPUT: ${{ inputs.allow_fork }}
# für Auto-PR-Logik:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
PR_BRANCH: ${{ github.event.pull_request.head.ref }}
PR_BASE: ${{ steps.resolve.outputs.base_ref }}
PR_AUTHOR: ${{ steps.resolve.outputs.author }}
run: |
echo "PR Title: $PR_TITLE"
echo "PR Author: $PR_AUTHOR"
echo "PR Branch: $PR_BRANCH"
echo "PR Base Branch: ${{ github.event.pull_request.base.ref }}"
# Define authorized users
authorized_users=(
"Frooodle"
"sf298"
"Ludy87"
"LaserKaspar"
"sbplat"
"reecebrowne"
"DarioGii"
"ConnorYoh"
"EthanHealy01"
"jbrunton96"
)
# Check if author is in the authorized list
is_authorized=false
for user in "${authorized_users[@]}"; do
if [[ "$PR_AUTHOR" == "$user" ]]; then
is_authorized=true
break
set -e
# Standard: nichts deployen
should=false
allow_fork="$(echo "${ALLOW_FORK_INPUT:-false}" | tr '[:upper:]' '[:lower:]')"
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
if [ "$STATE" != "open" ]; then
echo "PR not open -> skip"
else
if [ "$IS_FORK" = "true" ] && [ "$allow_fork" != "true" ]; then
echo "Fork PR and allow_fork=false -> skip"
else
should=true
fi
fi
done
# If PR is targeting V2 and user is authorized, deploy unconditionally
PR_BASE_BRANCH="${{ github.event.pull_request.base.ref }}"
if [[ "$PR_BASE_BRANCH" == "V2" && "$is_authorized" == "true" ]]; then
echo "✅ Deployment forced: PR targets V2 and author is authorized."
echo "should_deploy=true" >> $GITHUB_OUTPUT
exit 0
fi
# Otherwise, continue with original keyword checks
has_v2_keyword=false
if [[ "$PR_TITLE" =~ [Vv]2 ]] || [[ "$PR_TITLE" =~ [Vv]ersion.?2 ]] || [[ "$PR_TITLE" =~ [Vv]ersion.?[Tt]wo ]]; then
has_v2_keyword=true
fi
has_branch_keyword=false
if [[ "$PR_BRANCH" =~ [Vv]2 ]] || [[ "$PR_BRANCH" =~ [Rr]eact ]]; then
has_branch_keyword=true
fi
if [[ "$is_authorized" == "true" && ( "$has_v2_keyword" == "true" || "$has_branch_keyword" == "true" ) ]]; then
echo "✅ Deployment conditions met"
echo "should_deploy=true" >> $GITHUB_OUTPUT
else
echo "❌ Deployment conditions not met"
echo " - Authorized user: $is_authorized"
echo " - Has V2 keyword in title: $has_v2_keyword"
echo " - Has V2/React keyword in branch: $has_branch_keyword"
echo "should_deploy=false" >> $GITHUB_OUTPUT
auth_users=("Frooodle" "sf298" "Ludy87" "LaserKaspar" "sbplat" "reecebrowne" "DarioGii" "ConnorYoh" "EthanHealy01" "jbrunton96")
is_auth=false; for u in "${auth_users[@]}"; do [ "$u" = "$PR_AUTHOR" ] && is_auth=true && break; done
if [ "$PR_BASE" = "V2" ] && [ "$is_auth" = true ]; then
should=true
else
title_has_v2=false; echo "$PR_TITLE" | grep -qiE 'v2|version.?2|version.?two' && title_has_v2=true
branch_has_kw=false; echo "$PR_BRANCH" | grep -qiE 'v2|react' && branch_has_kw=true
if [ "$is_auth" = true ] && { [ "$title_has_v2" = true ] || [ "$branch_has_kw" = true ]; }; then
should=true
fi
fi
fi
- name: Get PR repository and ref
id: get-pr-info
if: steps.check-conditions.outputs.should_deploy == 'true'
run: |
# For forks, use the full repository name, for internal PRs use the current repo
if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then
repository="${{ github.event.pull_request.head.repo.full_name }}"
else
repository="${{ github.repository }}"
fi
echo "repository=$repository" >> $GITHUB_OUTPUT
echo "ref=${{ github.event.pull_request.head.ref }}" >> $GITHUB_OUTPUT
echo "should_deploy=$should" >> $GITHUB_OUTPUT
echo "allow_fork=${allow_fork:-false}" >> $GITHUB_OUTPUT
deploy-v2-pr:
needs: check-pr
runs-on: ubuntu-latest
if: needs.check-pr.outputs.should_deploy == 'true'
if: needs.check-pr.outputs.should_deploy == 'true' && (needs.check-pr.outputs.is_fork == 'false' || needs.check-pr.outputs.allow_fork == 'true')
# Concurrency control - only one deployment per PR at a time
concurrency:
group: v2-deploy-pr-${{ needs.check-pr.outputs.pr_number }}
@@ -119,7 +118,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
with:
egress-policy: audit
@@ -154,13 +153,13 @@ jobs:
issue_number: prNumber,
per_page: 100
});
const v2Comments = comments.filter(comment =>
comment.body.includes('🚀 **Auto-deploying V2 version**') ||
comment.body.includes('## 🚀 V2 Auto-Deployment Complete!') ||
comment.body.includes('❌ **V2 Auto-deployment failed**')
);
for (const comment of v2Comments) {
console.log(`Deleting old V2 comment: ${comment.id}`);
await github.rest.issues.deleteComment({
@@ -177,7 +176,6 @@ jobs:
issue_number: prNumber,
body: `🚀 **Auto-deploying V2 version** for PR #${prNumber}...\n\n_This is an automated deployment triggered by V2/version2 keywords in the PR title or V2/React keywords in the branch name._\n\n⚠️ **Note:** If new commits are pushed during deployment, this build will be cancelled and replaced with the latest version.`
});
return newComment.id;
- name: Checkout PR
@@ -188,7 +186,6 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 # Fetch full history for commit hash detection
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
@@ -212,7 +209,7 @@ jobs:
if [ -z "$FRONTEND_HASH" ]; then
FRONTEND_HASH="no-frontend-changes"
fi
# Get last commit that touched backend code, docker/backend, or docker/compose
BACKEND_HASH=$(git log -1 --format="%H" -- app/ docker/backend/ docker/compose/ 2>/dev/null || echo "")
if [ -z "$BACKEND_HASH" ]; then
@@ -321,7 +318,7 @@ jobs:
SWAGGER_SERVER_URL: "https://${V2_PORT}.ssl.stirlingpdf.cloud"
baseUrl: "https://${V2_PORT}.ssl.stirlingpdf.cloud"
restart: on-failure:5
stirling-pdf-v2-frontend:
container_name: stirling-pdf-v2-frontend-pr-${{ needs.check-pr.outputs.pr_number }}
image: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-frontend-${{ steps.commit-hashes.outputs.frontend_short }}
@@ -354,7 +351,7 @@ jobs:
# Clean up unused Docker resources to save space
docker system prune -af --volumes || true
# Clean up old backend/frontend images (older than 2 weeks)
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
ENDSSH
@@ -492,7 +489,7 @@ jobs:
# Clean up old unused images (older than 2 weeks) but keep recent ones for reuse
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
# Note: We don't remove the commit-based images since they can be reused across PRs
# Only remove PR-specific containers and directories
ENDSSH
@@ -501,5 +498,4 @@ jobs:
if: always()
run: |
rm -f ../private.key
continue-on-error: true
continue-on-error: true
+77 -12
View File
@@ -12,6 +12,7 @@ on:
branches:
- V2
paths:
- ".github/workflows/frontend-licenses-update.yml"
- "frontend/package.json"
- "frontend/package-lock.json"
- "frontend/scripts/generate-licenses.js"
@@ -28,37 +29,81 @@ jobs:
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- name: Check out code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Checkout PR head (default)
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Setup GitHub App Bot
if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false)
id: setup-bot
uses: ./.github/actions/setup-bot
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Node.js
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
- name: Checkout BASE branch (safe script)
if: github.event_name == 'pull_request'
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
node-version: '18'
ref: ${{ github.event.pull_request.base.sha }}
path: base
fetch-depth: 1
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
env:
NPM_CONFIG_IGNORE_SCRIPTS: "true"
run: npm ci --ignore-scripts --audit=false --fund=false
- name: Generate frontend license report
- name: Generate frontend license report (internal PR)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
working-directory: frontend
env:
PR_IS_FORK: "false"
run: npm run generate-licenses
- name: Generate frontend license report (fork PRs, pinned)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
env:
NPM_CONFIG_IGNORE_SCRIPTS: "true"
working-directory: frontend
run: |
mkdir -p src/assets
npx --yes license-report --only=prod --output=json > src/assets/3rdPartyLicenses.json
- name: Postprocess with project script (BASE version)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
env:
PR_IS_FORK: "true"
run: |
node base/frontend/scripts/generate-licenses.js \
--input frontend/src/assets/3rdPartyLicenses.json
- name: Copy postprocessed artifacts back (fork PRs)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
run: |
mkdir -p frontend/src/assets
if [ -f "base/frontend/src/assets/3rdPartyLicenses.json" ]; then
cp base/frontend/src/assets/3rdPartyLicenses.json frontend/src/assets/3rdPartyLicenses.json
fi
if [ -f "base/frontend/src/assets/license-warnings.json" ]; then
cp base/frontend/src/assets/license-warnings.json frontend/src/assets/license-warnings.json
fi
- name: Check for license warnings
run: |
if [ -f "frontend/src/assets/license-warnings.json" ]; then
@@ -69,8 +114,8 @@ jobs:
# PR Event: Check licenses and comment on PR
- name: Delete previous license check comments
if: github.event_name == 'pull_request'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
@@ -101,9 +146,29 @@ jobs:
});
}
- name: Summarize results (fork PRs)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true
run: |
{
echo "## Frontend License Check"
echo ""
if [ "${LICENSE_WARNINGS_EXIST}" = "true" ]; then
echo "❌ **Failed** incompatible or unknown licenses found."
if [ -f "frontend/src/assets/license-warnings.json" ]; then
echo ""
echo "### Warnings"
jq -r '.warnings[] | "- \(.message)"' frontend/src/assets/license-warnings.json || true
fi
else
echo "✅ **Passed** no license warnings detected."
fi
echo ""
echo "_Note: This is a fork PR. PR comments are disabled; use this summary._"
} >> "$GITHUB_STEP_SUMMARY"
- name: Comment on PR - License Check Results
if: github.event_name == 'pull_request'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.setup-bot.outputs.token }}
script: |
+118
View File
@@ -0,0 +1,118 @@
name: Sync Files V2
on:
workflow_dispatch:
push:
branches:
- V2
- syncLangTest
paths:
- "build.gradle"
- "README.md"
- "frontend/public/locales/*/translation.json"
- "app/core/src/main/resources/static/3rdPartyLicenses.json"
- "scripts/ignore_translation.toml"
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# or a pull request is updated.
# It helps to save resources and time by ensuring that only the latest commit is built and tested
# This is particularly useful for long-running jobs that may take a while to complete.
# The `group` is set to a combination of the workflow name, event name, and branch name.
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
sync-files:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
- name: Setup GitHub App Bot
id: setup-bot
uses: ./.github/actions/setup-bot
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
cache: "pip" # caching pip dependencies
- name: Sync translation JSON files
run: |
python .github/scripts/check_language_json.py --reference-file "frontend/public/locales/en-GB/translation.json" --branch V2
- name: Commit translation files
run: |
git add frontend/public/locales/*/translation.json
git diff --staged --quiet || git commit -m ":memo: Sync translation files" || echo "No changes detected"
- name: Install dependencies
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
- name: Sync README.md
run: |
python scripts/counter_translation_v2.py
- name: Run git add
run: |
git add README.md scripts/ignore_translation.toml
git diff --staged --quiet || git commit -m ":memo: Sync README.md & scripts/ignore_translation.toml" || echo "No changes detected"
- name: Create Pull Request
if: always()
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: Update files
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: sync_readme_v2
base: V2
title: ":globe_with_meridians: [V2] Sync Translations + Update README Progress Table"
body: |
### 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
draft: false
delete-branch: true
labels: github-actions
sign-commits: true
add-paths: |
README.md
frontend/public/locales/*/translation.json
+7
View File
@@ -203,3 +203,10 @@ id_ed25519.pub
# node_modules
node_modules/
# Translation temp files
*_compact.json
*compact*.json
test_batch.json
*.backup.*.json
frontend/public/locales/*/translation.backup*.json
+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
+37 -38
View File
@@ -97,7 +97,6 @@ All documentation available at [https://docs.stirlingpdf.com/](https://docs.stir
# 📖 Get Started
Visit our comprehensive documentation at [docs.stirlingpdf.com](https://docs.stirlingpdf.com) for:
@@ -116,46 +115,46 @@ Stirling-PDF currently supports 40 languages!
| Language | Progress |
| -------------------------------------------- | -------------------------------------- |
| Arabic (العربية) (ar_AR) | ![61%](https://geps.dev/progress/61) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![62%](https://geps.dev/progress/62) |
| Basque (Euskara) (eu_ES) | ![36%](https://geps.dev/progress/36) |
| Bulgarian (Български) (bg_BG) | ![68%](https://geps.dev/progress/68) |
| Catalan (Català) (ca_CA) | ![68%](https://geps.dev/progress/68) |
| Croatian (Hrvatski) (hr_HR) | ![60%](https://geps.dev/progress/60) |
| Czech (Česky) (cs_CZ) | ![70%](https://geps.dev/progress/70) |
| Danish (Dansk) (da_DK) | ![61%](https://geps.dev/progress/61) |
| Dutch (Nederlands) (nl_NL) | ![60%](https://geps.dev/progress/60) |
| 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) | ![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) | ![34%](https://geps.dev/progress/34) |
| 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) | ![89%](https://geps.dev/progress/89) |
| German (Deutsch) (de_DE) | ![98%](https://geps.dev/progress/98) |
| Greek (Ελληνικά) (el_GR) | ![67%](https://geps.dev/progress/67) |
| Hindi (हिंदी) (hi_IN) | ![67%](https://geps.dev/progress/67) |
| Hungarian (Magyar) (hu_HU) | ![99%](https://geps.dev/progress/99) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![62%](https://geps.dev/progress/62) |
| Irish (Gaeilge) (ga_IE) | ![68%](https://geps.dev/progress/68) |
| Italian (Italiano) (it_IT) | ![98%](https://geps.dev/progress/98) |
| Japanese (日本語) (ja_JP) | ![93%](https://geps.dev/progress/93) |
| Korean (한국어) (ko_KR) | ![67%](https://geps.dev/progress/67) |
| Norwegian (Norsk) (no_NB) | ![66%](https://geps.dev/progress/66) |
| Persian (فارسی) (fa_IR) | ![64%](https://geps.dev/progress/64) |
| Polish (Polski) (pl_PL) | ![72%](https://geps.dev/progress/72) |
| Portuguese (Português) (pt_PT) | ![69%](https://geps.dev/progress/69) |
| Portuguese Brazilian (Português) (pt_BR) | ![76%](https://geps.dev/progress/76) |
| Romanian (Română) (ro_RO) | ![57%](https://geps.dev/progress/57) |
| Russian (Русский) (ru_RU) | ![88%](https://geps.dev/progress/88) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![95%](https://geps.dev/progress/95) |
| Simplified Chinese (简体中文) (zh_CN) | ![93%](https://geps.dev/progress/93) |
| Slovakian (Slovensky) (sk_SK) | ![51%](https://geps.dev/progress/51) |
| Slovenian (Slovenščina) (sl_SI) | ![71%](https://geps.dev/progress/71) |
| Spanish (Español) (es_ES) | ![74%](https://geps.dev/progress/74) |
| Swedish (Svenska) (sv_SE) | ![65%](https://geps.dev/progress/65) |
| Thai (ไทย) (th_TH) | ![59%](https://geps.dev/progress/59) |
| French (Français) (fr_FR) | ![95%](https://geps.dev/progress/95) |
| German (Deutsch) (de_DE) | ![97%](https://geps.dev/progress/97) |
| Greek (Ελληνικά) (el_GR) | ![39%](https://geps.dev/progress/39) |
| Hindi (हिंदी) (hi_IN) | ![39%](https://geps.dev/progress/39) |
| Hungarian (Magyar) (hu_HU) | ![43%](https://geps.dev/progress/43) |
| 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) | ![36%](https://geps.dev/progress/36) |
| Persian (فارسی) (fa_IR) | ![38%](https://geps.dev/progress/38) |
| Polish (Polski) (pl_PL) | ![41%](https://geps.dev/progress/41) |
| 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) | ![96%](https://geps.dev/progress/96) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![42%](https://geps.dev/progress/42) |
| 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) | ![97%](https://geps.dev/progress/97) |
| Turkish (Türkçe) (tr_TR) | ![80%](https://geps.dev/progress/80) |
| Ukrainian (Українська) (uk_UA) | ![71%](https://geps.dev/progress/71) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![57%](https://geps.dev/progress/57) |
| Traditional Chinese (繁體中文) (zh_TW) | ![43%](https://geps.dev/progress/43) |
| 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) |
## Stirling PDF Enterprise
@@ -258,12 +258,6 @@ public class AppConfig {
return false;
}
@Bean(name = "GoogleDriveEnabled")
@Profile("default")
public boolean googleDriveEnabled() {
return false;
}
@Bean(name = "license")
@Profile("default")
public String licenseType() {
@@ -530,7 +530,6 @@ public class ApplicationProperties {
private boolean ssoAutoLogin;
private boolean database;
private CustomMetadata customMetadata = new CustomMetadata();
private GoogleDrive googleDrive = new GoogleDrive();
@Data
public static class CustomMetadata {
@@ -549,26 +548,6 @@ public class ApplicationProperties {
: producer;
}
}
@Data
public static class GoogleDrive {
private boolean enabled;
private String clientId;
private String apiKey;
private String appId;
public String getClientId() {
return clientId == null || clientId.trim().isEmpty() ? "" : clientId;
}
public String getApiKey() {
return apiKey == null || apiKey.trim().isEmpty() ? "" : apiKey;
}
public String getAppId() {
return appId == null || appId.trim().isEmpty() ? "" : appId;
}
}
}
@Data
@@ -109,22 +109,6 @@ class ApplicationPropertiesLogicTest {
assertTrue(ex.getMessage().toLowerCase().contains("not supported"));
}
@Test
void premium_google_drive_getters_return_empty_string_on_null_or_blank() {
Premium.ProFeatures.GoogleDrive gd = new Premium.ProFeatures.GoogleDrive();
assertEquals("", gd.getClientId());
assertEquals("", gd.getApiKey());
assertEquals("", gd.getAppId());
gd.setClientId(" id ");
gd.setApiKey(" key ");
gd.setAppId(" app ");
assertEquals(" id ", gd.getClientId());
assertEquals(" key ", gd.getApiKey());
assertEquals(" app ", gd.getAppId());
}
@Test
void ui_getters_return_null_for_blank() {
ApplicationProperties.Ui ui = new ApplicationProperties.Ui();
@@ -3,6 +3,7 @@ package stirling.software.SPDF.controller.api;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.attribute.BasicFileAttributes;
@@ -20,6 +21,8 @@ import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlin
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
@@ -111,6 +114,32 @@ public class MergeController {
}
}
// Parse client file IDs from JSON string
private String[] parseClientFileIds(String clientFileIds) {
if (clientFileIds == null || clientFileIds.trim().isEmpty()) {
return new String[0];
}
try {
// Simple JSON array parsing - remove brackets and split by comma
String trimmed = clientFileIds.trim();
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
String inside = trimmed.substring(1, trimmed.length() - 1).trim();
if (inside.isEmpty()) {
return new String[0];
}
String[] parts = inside.split(",");
String[] result = new String[parts.length];
for (int i = 0; i < parts.length; i++) {
result[i] = parts[i].trim().replaceAll("^\"|\"$", "");
}
return result;
}
} catch (Exception e) {
log.warn("Failed to parse client file IDs: {}", clientFileIds, e);
}
return new String[0];
}
// Adds a table of contents to the merged document using filenames as chapter titles
private void addTableOfContents(PDDocument mergedDocument, MultipartFile[] files) {
// Create the document outline
@@ -177,15 +206,48 @@ public class MergeController {
PDFMergerUtility mergerUtility = new PDFMergerUtility();
long totalSize = 0;
for (MultipartFile multipartFile : files) {
List<Integer> invalidIndexes = new ArrayList<>();
for (int index = 0; index < files.length; index++) {
MultipartFile multipartFile = files[index];
totalSize += multipartFile.getSize();
File tempFile =
GeneralUtils.convertMultipartFileToFile(
multipartFile); // Convert MultipartFile to File
filesToDelete.add(tempFile); // Add temp file to the list for later deletion
// Pre-validate each PDF so we can report which one(s) are broken
// Use the original MultipartFile to avoid deleting the tempFile during validation
try (PDDocument ignored = pdfDocumentFactory.load(multipartFile)) {
// OK
} catch (IOException e) {
ExceptionUtils.logException("PDF pre-validate", e);
invalidIndexes.add(index);
}
mergerUtility.addSource(tempFile); // Add source file to the merger utility
}
if (!invalidIndexes.isEmpty()) {
// Parse client file IDs (always present from frontend)
String[] clientIds = parseClientFileIds(request.getClientFileIds());
// Map invalid indexes to client IDs
List<String> errorFileIds = new ArrayList<>();
for (Integer index : invalidIndexes) {
if (index < clientIds.length) {
errorFileIds.add(clientIds[index]);
}
}
String payload =
String.format(
"{\"errorFileIds\":%s,\"message\":\"Some of the selected files can't be merged\"}",
errorFileIds.toString());
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
.body(payload.getBytes(StandardCharsets.UTF_8));
}
mergedTempFile = Files.createTempFile("merged-", ".pdf").toFile();
mergerUtility.setDestinationFileName(mergedTempFile.getAbsolutePath());
@@ -98,11 +98,6 @@ public class ConfigController {
if (applicationContext.containsBean("license")) {
configData.put("license", applicationContext.getBean("license", String.class));
}
if (applicationContext.containsBean("GoogleDriveEnabled")) {
configData.put(
"GoogleDriveEnabled",
applicationContext.getBean("GoogleDriveEnabled", Boolean.class));
}
if (applicationContext.containsBean("SSOAutoLogin")) {
configData.put(
"SSOAutoLogin",
@@ -237,34 +237,54 @@ public class StampController {
PDRectangle pageSize = page.getMediaBox();
float x, y;
if (overrideX >= 0 && overrideY >= 0) {
// Use override values if provided
x = overrideX;
y = overrideY;
} else {
x = calculatePositionX(pageSize, position, fontSize, font, fontSize, stampText, margin);
y =
calculatePositionY(
pageSize, position, calculateTextCapHeight(font, fontSize), margin);
}
// Split the stampText into multiple lines
String[] lines = stampText.split("\\\\n");
String[] lines = stampText.split("\\r?\\n|\\\\n");
// Calculate dynamic line height based on font ascent and descent
float ascent = font.getFontDescriptor().getAscent();
float descent = font.getFontDescriptor().getDescent();
float lineHeight = ((ascent - descent) / 1000) * fontSize;
// Compute a single pivot for the entire text block to avoid line-by-line wobble
float capHeight = calculateTextCapHeight(font, fontSize);
float blockHeight = Math.max(lineHeight, lineHeight * Math.max(1, lines.length));
float maxWidth = 0f;
for (String ln : lines) {
maxWidth = Math.max(maxWidth, calculateTextWidth(ln, font, fontSize));
}
if (overrideX >= 0 && overrideY >= 0) {
// Use override values if provided
x = overrideX;
y = overrideY;
} else {
// Base positioning on the true multi-line block size
x = calculatePositionX(pageSize, position, maxWidth, null, 0, null, margin);
y = calculatePositionY(pageSize, position, blockHeight, margin);
}
// After anchoring the block, draw from the top line downward
float adjustedX = x;
float adjustedY = y;
float pivotX = adjustedX + maxWidth / 2f;
float pivotY = adjustedY + blockHeight / 2f;
// Apply rotation about the block center at the graphics state level
contentStream.saveGraphicsState();
contentStream.transform(Matrix.getTranslateInstance(pivotX, pivotY));
contentStream.transform(Matrix.getRotateInstance(Math.toRadians(rotation), 0, 0));
contentStream.transform(Matrix.getTranslateInstance(-pivotX, -pivotY));
contentStream.beginText();
for (int i = 0; i < lines.length; i++) {
String line = lines[i];
// Set the text matrix for each line with rotation
contentStream.setTextMatrix(
Matrix.getRotateInstance(Math.toRadians(rotation), x, y - (i * lineHeight)));
// Start from top line: yTop = adjustedY + blockHeight - capHeight
float yLine = adjustedY + blockHeight - capHeight - (i * lineHeight);
contentStream.setTextMatrix(Matrix.getTranslateInstance(adjustedX, yLine));
contentStream.showText(line);
}
contentStream.endText();
contentStream.restoreGraphicsState();
}
private void addImageStamp(
@@ -308,9 +328,17 @@ public class StampController {
}
contentStream.saveGraphicsState();
contentStream.transform(Matrix.getTranslateInstance(x, y));
// Rotate and scale about the center of the image
float centerX = x + (desiredPhysicalWidth / 2f);
float centerY = y + (desiredPhysicalHeight / 2f);
contentStream.transform(Matrix.getTranslateInstance(centerX, centerY));
contentStream.transform(Matrix.getRotateInstance(Math.toRadians(rotation), 0, 0));
contentStream.drawImage(xobject, 0, 0, desiredPhysicalWidth, desiredPhysicalHeight);
contentStream.drawImage(
xobject,
-desiredPhysicalWidth / 2f,
-desiredPhysicalHeight / 2f,
desiredPhysicalWidth,
desiredPhysicalHeight);
contentStream.restoreGraphicsState();
}
@@ -39,4 +39,10 @@ public class MergePdfsRequest extends MultiplePDFFiles {
requiredMode = Schema.RequiredMode.NOT_REQUIRED,
defaultValue = "false")
private boolean generateToc = false;
@Schema(
description =
"JSON array of client-provided IDs for each uploaded file (same order as fileInput)",
requiredMode = Schema.RequiredMode.NOT_REQUIRED)
private String clientFileIds;
}
@@ -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
@@ -76,11 +76,6 @@ premium:
author: username
creator: Stirling-PDF
producer: Stirling-PDF
googleDrive:
enabled: false
clientId: ''
apiKey: ''
appId: ''
enterpriseFeatures:
audit:
enabled: true # Enable audit logging
@@ -422,10 +422,6 @@
<span th:text="#{fileChooser.or}" style="margin: 0 5px;"></span>
<span th:text="#{fileChooser.dragAndDrop}" id="dragAndDrop"></span>
</div>
<hr th:if="${@GoogleDriveEnabled == true}" class="horizontal-divider" >
</div>
<div th:if="${@GoogleDriveEnabled == true}" th:id="${name}+'-google-drive-button'" class="google-drive-button" th:attr="data-name=${name}, data-multiple=${!disableMultipleFiles}, data-accept=${accept}" >
<img th:src="@{'/images/google-drive.svg'}" alt="google drive">
</div>
</div>
<div class="selected-files flex-wrap"></div>
@@ -443,16 +439,4 @@
</div>
</div>
<script th:src="@{'/js/fileInput.js'}" type="module"></script>
<div th:if="${@GoogleDriveEnabled == true}" >
<script type="text/javascript" th:src="@{'/js/googleFilePicker.js'}"></script>
<script async defer src="https://apis.google.com/js/api.js" onload="gapiLoaded()"></script>
<script async defer src="https://accounts.google.com/gsi/client" onload="gisLoaded()"></script>
<script th:inline="javascript">
window.stirlingPDF.GoogleDriveClientId = /*[[${@GoogleDriveConfig.getClientId()}]]*/ null;
window.stirlingPDF.GoogleDriveApiKey = /*[[${@GoogleDriveConfig.getApiKey()}]]*/ null;
window.stirlingPDF.GoogleDriveAppId = /*[[${@GoogleDriveConfig.getAppId()}]]*/ null;
</script>
</div>
</th:block>
@@ -12,7 +12,6 @@ import org.springframework.core.annotation.Order;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.EnterpriseEdition;
import stirling.software.common.model.ApplicationProperties.Premium;
import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures.GoogleDrive;
@Configuration
@Order(Ordered.HIGHEST_PRECEDENCE)
@@ -55,19 +54,6 @@ public class EEAppConfig {
return applicationProperties.getPremium().getProFeatures().isSsoAutoLogin();
}
@Profile("security")
@Bean(name = "GoogleDriveEnabled")
@Primary
public boolean googleDriveEnabled() {
return runningProOrHigher()
&& applicationProperties.getPremium().getProFeatures().getGoogleDrive().isEnabled();
}
@Bean(name = "GoogleDriveConfig")
public GoogleDrive googleDriveConfig() {
return applicationProperties.getPremium().getProFeatures().getGoogleDrive();
}
// TODO: Remove post migration
@SuppressWarnings("deprecation")
public void migrateEnterpriseSettingsToPremium(ApplicationProperties applicationProperties) {
+239 -247
View File
@@ -8,14 +8,15 @@ Stirling-PDF is a robust, locally hosted, web-based PDF manipulation tool. This
Stirling-PDF is built using:
- Spring Boot + Thymeleaf
- PDFBox
- LibreOffice
- qpdf
- HTML, CSS, JavaScript
- Spring Boot (Backend API)
- React + TypeScript + Vite (Frontend V2)
- Mantine UI + TailwindCSS (UI Framework)
- PDFBox (PDF manipulation)
- LibreOffice (Document conversion)
- qpdf (PDF processing)
- PDF.js (Client-side PDF rendering)
- Embedded-PDF (PDF viewer component)
- Docker
- PDF.js
- PDF-LIB.js
- Lombok
## 3. Development Environment Setup
@@ -24,8 +25,9 @@ Stirling-PDF is built using:
- Docker
- Git
- Java JDK 17 or later
- Java JDK 17 or later (JDK 21 recommended)
- Gradle 7.0 or later (Included within the repo)
- Node.js 18+ and npm (for frontend development)
### Setup Steps
@@ -38,8 +40,8 @@ Stirling-PDF is built using:
2. Install Docker and JDK17 if not already installed.
3. Install a recommended Java IDE such as Eclipse, IntelliJ, or VSCode
1. Only VSCode
3. Install a recommended IDE:
- **VSCode** (recommended for frontend)
1. Open VS Code.
2. When prompted, install the recommended extensions.
3. Alternatively, open the command palette (`Ctrl + Shift + P` or `Cmd + Shift + P` on macOS) and run:
@@ -49,13 +51,15 @@ Stirling-PDF is built using:
```
4. Install the required extensions from the list.
- **IntelliJ IDEA** (recommended for backend)
- **Eclipse** (alternative for backend)
4. Lombok Setup
Stirling-PDF uses Lombok to reduce boilerplate code. Some IDEs, like Eclipse, don't support Lombok out of the box. To set up Lombok in your development environment:
Visit the [Lombok website](https://projectlombok.org/setup/) for installation instructions specific to your IDE.
5. Add environment variable
For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DISABLE_ADDITIONAL_FEATURES=false to your system and/or IDE build/run step.
For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DOCKER_ENABLE_SECURITY=true to your system and/or IDE build/run step.
## 4. Project Structure
@@ -68,10 +72,21 @@ Stirling-PDF/
├── customFiles/ # Custom static files and templates (generated at runtime used to replace existing files)
├── docs/ # Documentation files
├── exampleYmlFiles/ # Example YAML configuration files
├── frontend/ # React frontend application (V2)
│ ├── src/
│ │ ├── components/ # React components
│ │ ├── tools/ # PDF tool implementations
│ │ ├── contexts/ # React contexts (FileContext, etc.)
│ │ ├── hooks/ # Custom React hooks
│ │ ├── services/ # API and processing services
│ │ └── i18n.ts # Internationalization config
│ ├── public/
│ │ └── locales/ # Translation JSON files
│ └── package.json
├── images/ # Image assets
├── pipeline/ # Pipeline-related files (generated at runtime)
├── scripts/ # Utility scripts
├── src/ # Source code
├── src/ # Backend source code
│ ├── main/
│ │ ├── java/
│ │ │ └── stirling/
@@ -79,16 +94,14 @@ Stirling-PDF/
│ │ │ └── SPDF/
│ │ │ ├── config/
│ │ │ ├── controller/
│ │ │ │ ├── api/ # REST API endpoints
│ │ │ │ └── web/ # Web controllers
│ │ │ ├── model/
│ │ │ ├── repository/
│ │ │ ├── service/
│ │ │ └── utils/
│ │ └── resources/
│ │ ── static/
│ │ │ ├── css/
│ │ │ ├── js/
│ │ │ └── pdfjs/
│ │ └── templates/
│ │ ── static/ # Legacy static assets
│ └── test/
│ └── java/
│ └── stirling/
@@ -141,7 +154,7 @@ services:
- ./stirling/latest/config:/configs:rw
- ./stirling/latest/logs:/logs:rw
environment:
DISABLE_ADDITIONAL_FEATURES: "false"
DOCKER_ENABLE_SECURITY: "true"
SECURITY_ENABLELOGIN: "true"
PUID: 1002
PGID: 1002
@@ -170,7 +183,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
1. Set the security environment variable:
```bash
export DISABLE_ADDITIONAL_FEATURES=true # or false for to enable login and security features for builds
export DOCKER_ENABLE_SECURITY=true # or false to disable login and security features for builds
```
2. Build the project with Gradle:
@@ -196,7 +209,7 @@ Stirling-PDF uses different Docker images for various configurations. The build
For the fat version (with login and security features enabled):
```bash
export DISABLE_ADDITIONAL_FEATURES=false
export DOCKER_ENABLE_SECURITY=true
docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat .
```
@@ -224,38 +237,50 @@ Note: The `test.sh` script will run automatically when you raise a PR. However,
### Full Testing with Docker
1. Build and run the Docker container per the above instructions:
1. Build and run the Docker container per the above instructions
2. Access the application at `http://localhost:8080` and manually test all features developed.
### Local Testing (Java and UI Components)
### Local Testing (Frontend and Backend)
For quick iterations and development of Java backend, JavaScript, and UI components, you can run and test Stirling-PDF locally without Docker. This approach allows you to work on and verify changes to:
For quick iterations and development, you can run the frontend and backend separately:
- Java backend logic
- RESTful API endpoints
- JavaScript functionality
- User interface components and styling
- Thymeleaf templates
#### Backend Development
To run Stirling-PDF locally:
1. Compile and run the project using built-in IDE methods or by running:
1. Run the backend:
```bash
./gradlew bootRun
```
2. Access the application at `http://localhost:8080` in your web browser.
2. The backend API will be available at `http://localhost:8080`
3. Manually test the features you're working on through the UI.
3. API documentation is available at `http://localhost:8080/swagger-ui/index.html`
4. For API changes, use tools like Postman or curl to test endpoints directly.
#### Frontend Development
1. Install dependencies (first time only):
```bash
cd frontend
npm install
```
2. Start the development server:
```bash
npm run dev
```
3. The frontend will be available at `http://localhost:5173`
4. Vite automatically proxies API calls from `/api/*` to the backend at `localhost:8080`
Important notes:
- Frontend requires the backend to be running for full functionality
- Hot module replacement (HMR) enables instant updates during development
- Local testing doesn't include features that depend on external tools like qpdf, LibreOffice, or Python scripts.
- There are currently no automated unit tests. All testing is done manually through the UI or API calls. (You are welcome to add JUnits!)
- Always verify your changes in the full Docker environment before submitting pull requests, as some integrations and features will only work in the complete setup.
## 7. Contributing
@@ -307,112 +332,170 @@ docker run -p 8080:8080 -e APP_NAME="My PDF Tool" stirling-pdf:full
Refer to the main README for a full list of customization options.
## 10. Language Translations
## 10. Frontend Development (V2)
For managing language translations that affect multiple files, Stirling-PDF provides a helper script:
### Architecture Overview
```bash
/scripts/replace_translation_line.sh
The V2 frontend is designed for **stateful document processing**:
- Users upload PDFs once, then chain tools (split → merge → compress → view)
- File state and processing results persist across tool switches
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
### Key Components
#### FileContext - Central State Management
**Location**: `frontend/src/contexts/FileContext.tsx`
- **Active files**: Currently loaded PDFs and their variants
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
- **IndexedDB persistence**: File storage with thumbnail caching
- **Preview system**: Tools can preview results without context pollution
**Critical**: All file operations go through FileContext. Don't bypass with direct file handling.
#### Processing Services
- **enhancedPDFProcessingService**: Background PDF parsing and manipulation
- **thumbnailGenerationService**: Web Worker-based with main-thread fallback
- **fileStorage**: IndexedDB with LRU cache management
### Tool Development
**Architecture**: Modular hook-based system with clear separation of concerns:
- **useToolOperation** (`frontend/src/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
- Coordinates all tool operations with consistent interface
- Integrates with FileContext for operation tracking
- Handles validation, error handling, and UI state management
- **Supporting Hooks**:
- **useToolState**: UI state management (loading, progress, error, files)
- **useToolApiCalls**: HTTP requests and file processing
- **useToolResources**: Blob URLs, thumbnails, ZIP downloads
- **Utilities**:
- **toolErrorHandler**: Standardized error extraction and i18n support
- **toolResponseProcessor**: API response handling (single/zip/custom)
- **toolOperationTracker**: FileContext integration utilities
**Three Tool Patterns**:
**Pattern 1: Single-File Tools** (Individual processing)
- Backend processes one file per API call
- Set `multiFileEndpoint: false`
- Examples: Compress, Rotate
```typescript
return useToolOperation({
operationType: 'compress',
endpoint: '/api/v1/misc/compress-pdf',
buildFormData: (params, file: File) => { /* single file */ },
multiFileEndpoint: false,
});
```
This script helps you make consistent replacements across language files.
**Pattern 2: Multi-File Tools** (Batch processing)
- Backend accepts `MultipartFile[]` arrays in single API call
- Set `multiFileEndpoint: true`
- Examples: Split, Merge, Overlay
When contributing translations:
1. Use the helper script for multi-file changes.
2. Ensure all language files are updated consistently.
3. The PR checks will verify consistency in language file updates.
Remember to test your changes thoroughly to ensure they don't break any existing functionality.
## Code examples
### Overview of Thymeleaf
Thymeleaf is a server-side Java HTML template engine. It is used in Stirling-PDF to render dynamic web pages. Thymeleaf integrates heavily with Spring Boot.
### Thymeleaf overview
In Stirling-PDF, Thymeleaf is used to create HTML templates that are rendered on the server side. These templates are located in the `app/core/src/main/resources/templates` directory. Thymeleaf templates use a combination of HTML and special Thymeleaf attributes to dynamically generate content.
Some examples of this are:
```html
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
```
or
```html
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
```typescript
return useToolOperation({
operationType: 'split',
endpoint: '/api/v1/general/split-pages',
buildFormData: (params, files: File[]) => { /* all files */ },
multiFileEndpoint: true,
filePrefix: 'split_',
});
```
Where it uses the `th:block`, `th:` indicating it's a special Thymeleaf element to be used server-side in generating the HTML, and block being the actual element type.
In this case, we are inserting the `navbar` entry within the `fragments/navbar.html` fragment into the `th:block` element.
**Pattern 3: Complex Tools** (Custom processing)
- Tools with complex routing logic or non-standard processing
- Provide `customProcessor` for full control
- Examples: Convert, OCR
They can be more complex, such as:
```html
<th:block th:insert="~{fragments/common :: head(title=#{pageExtracter.title}, header=#{pageExtracter.header})}"></th:block>
```typescript
return useToolOperation({
operationType: 'convert',
customProcessor: async (params, files) => { /* custom logic */ },
});
```
Which is the same as above but passes the parameters title and header into the fragment `common.html` to be used in its HTML generation.
**Benefits**:
- **No Timeouts**: Operations run until completion (supports 100GB+ files)
- **Consistent**: All tools follow same pattern and interface
- **Maintainable**: Single responsibility hooks, easy to test and modify
- **i18n Ready**: Built-in internationalization support
- **Type Safe**: Full TypeScript support with generic interfaces
- **Memory Safe**: Automatic resource cleanup and blob URL management
Thymeleaf can also be used to loop through objects or pass things from the Java side into the HTML side.
### Adding a New Tool
```java
@GetMapping
public String newFeaturePage(Model model) {
model.addAttribute("exampleData", exampleData);
return "new-feature";
}
See [ADDING_TOOLS.md](../ADDING_TOOLS.md) for a complete guide to creating new PDF tools.
### Internationalization
Translations are stored in JSON files at `frontend/public/locales/{language-code}/translation.json`.
To use translations in React components:
```typescript
import { useTranslation } from 'react-i18next';
function MyComponent() {
const { t } = useTranslation();
return (
<div>
<h1>{t('myTool.title')}</h1>
<p>{t('myTool.description')}</p>
</div>
);
}
```
In the above example, if exampleData is a list of plain java objects of class Person and within it, you had id, name, age, etc. You can reference it like so
See [HowToAddNewLanguage.md](./HowToAddNewLanguage.md) for details on adding new languages.
```html
<tbody>
<!-- Use th:each to iterate over the list -->
<tr th:each="person : ${exampleData}">
<td th:text="${person.id}"></td>
<td th:text="${person.name}"></td>
<td th:text="${person.age}"></td>
<td th:text="${person.email}"></td>
</tr>
</tbody>
```
## 11. Backend Development
This would generate n entries of tr for each person in exampleData
### Adding a New Feature to the Backend (API)
### Adding a New API Endpoint
1. **Create a New Controller:**
- Create a new Java class in the `app/core/src/main/java/stirling/software/SPDF/controller/api` directory.
- Create a new Java class in the `src/main/java/stirling/software/SPDF/controller/api` directory.
- Annotate the class with `@RestController` and `@RequestMapping` to define the API endpoint.
- Ensure to add API documentation annotations like `@Tag(name = "General", description = "General APIs")` and `@Operation(summary = "Crops a PDF document", description = "This operation takes an input PDF file and crops it according to the given coordinates. Input:PDF Output:PDF Type:SISO")`.
- Ensure to add API documentation annotations like `@Tag` and `@Operation`.
```java
package stirling.software.SPDF.controller.api;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@RestController
@RequestMapping("/api/v1/new-feature")
@RequestMapping("/api/v1/pdf")
@Tag(name = "General", description = "General APIs")
public class NewFeatureController {
@GetMapping
@Operation(summary = "New Feature", description = "This is a new feature endpoint.")
public String newFeature() {
return "NewFeatureResponse"; // This refers to the NewFeatureResponse.html template presenting the user with the generated html from that file when they navigate to /api/v1/new-feature
@PostMapping("/new-feature")
@Operation(summary = "New Feature", description = "This is a new feature endpoint. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> newFeature(
@RequestPart("fileInput") MultipartFile file,
@RequestParam("param1") String param1) {
// Process PDF
byte[] result = processFile(file, param1);
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=output.pdf")
.contentType(MediaType.APPLICATION_PDF)
.body(result);
}
}
```
2. **Define the Service Layer:** (Not required but often useful)
- Create a new service class in the `app/core/src/main/java/stirling/software/SPDF/service` directory.
2. **Define the Service Layer:** (Optional but recommended)
- Create a new service class in the `src/main/java/stirling/software/SPDF/service` directory.
- Implement the business logic for the new feature.
```java
@@ -423,167 +506,76 @@ This would generate n entries of tr for each person in exampleData
@Service
public class NewFeatureService {
public String getNewFeatureData() {
public byte[] processFile(MultipartFile file, String param1) {
// Implement business logic here
return "New Feature Data";
return processedBytes;
}
}
```
2b. **Integrate the Service with the Controller:**
- Autowire the service class in the controller and use it to handle the API request.
```java
package stirling.software.SPDF.controller.api;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import stirling.software.SPDF.service.NewFeatureService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@RestController
@RequestMapping("/api/v1/new-feature")
@Tag(name = "General", description = "General APIs")
public class NewFeatureController {
@Autowired
private NewFeatureService newFeatureService;
@GetMapping
@Operation(summary = "New Feature", description = "This is a new feature endpoint.")
public String newFeature() {
return newFeatureService.getNewFeatureData();
}
}
```
### Adding a New Feature to the Frontend (UI)
1. **Create a New Thymeleaf Template:**
- Create a new HTML file in the `app/core/src/main/resources/templates` directory.
- Use Thymeleaf attributes to dynamically generate content.
- Use `extract-page.html` as a base example for the HTML template, which is useful to ensure importing of the general layout, navbar, and footer.
```html
<!DOCTYPE html>
<html th:lang="${#locale.language}" th:dir="#{language.direction}" th:data-language="${#locale.toString()}" xmlns:th="https://www.thymeleaf.org">
<head>
<th:block th:insert="~{fragments/common :: head(title=#{newFeature.title}, header=#{newFeature.header})}"></th:block>
</head>
<body>
<div id="page-container">
<div id="content-wrap">
<th:block th:insert="~{fragments/navbar.html :: navbar}"></th:block>
<br><br>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6 bg-card">
<div class="tool-header">
<span class="material-symbols-rounded tool-header-icon organize">upload</span>
<span class="tool-header-text" th:text="#{newFeature.header}"></span>
</div>
<form th:action="@{'/api/v1/new-feature'}" method="post" enctype="multipart/form-data">
<div th:replace="~{fragments/common :: fileSelector(name='fileInput', multipleInputsForSingleRequest=false, accept='application/pdf')}"></div>
<input type="hidden" id="customMode" name="customMode" value="">
<div class="mb-3">
<label for="featureInput" th:text="#{newFeature.prompt}"></label>
<input type="text" class="form-control" id="featureInput" name="featureInput" th:placeholder="#{newFeature.placeholder}" required>
</div>
<button type="submit" id="submitBtn" class="btn btn-primary" th:text="#{newFeature.submit}"></button>
</form>
</div>
</div>
</div>
</div>
<th:block th:insert="~{fragments/footer.html :: footer}"></th:block>
</div>
</body>
</html>
```
2. **Create a New Controller for the UI:**
- Create a new Java class in the `app/core/src/main/java/stirling/software/SPDF/controller/ui` directory.
- Annotate the class with `@Controller` and `@RequestMapping` to define the UI endpoint.
3. **Integrate the Service with the Controller:**
```java
package stirling.software.SPDF.controller.ui;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import stirling.software.SPDF.service.NewFeatureService;
@Controller
@RequestMapping("/new-feature")
public class NewFeatureUIController {
@RestController
@RequestMapping("/api/v1/pdf")
public class NewFeatureController {
@Autowired
private NewFeatureService newFeatureService;
@GetMapping
public String newFeaturePage(Model model) {
model.addAttribute("newFeatureData", newFeatureService.getNewFeatureData());
return "new-feature";
@PostMapping("/new-feature")
public ResponseEntity<byte[]> newFeature(
@RequestPart("fileInput") MultipartFile file,
@RequestParam("param1") String param1) {
byte[] result = newFeatureService.processFile(file, param1);
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=output.pdf")
.contentType(MediaType.APPLICATION_PDF)
.body(result);
}
}
```
3. **Update the Navigation Bar:**
- Add a link to the new feature page in the navigation bar.
- Update the `app/core/src/main/resources/templates/fragments/navbar.html` file.
### Multi-File Endpoints
```html
<li class="nav-item">
<a class="nav-link" th:href="@{'/new-feature'}">New Feature</a>
</li>
```
For tools that process multiple files in one request:
## Adding New Translations to Existing Language Files in Stirling-PDF
```java
@PostMapping("/merge")
public ResponseEntity<byte[]> mergePdfs(
@RequestPart("fileInput") MultipartFile[] files) {
When adding a new feature or modifying existing ones in Stirling-PDF, you'll need to add new translation entries to the existing language files. Here's a step-by-step guide:
// Process all files together
byte[] merged = mergeService.mergeFiles(files);
### 1. Locate Existing Language Files
Find the existing `messages.properties` files in the `app/core/src/main/resources` directory. You'll see files like:
- `messages.properties` (default, usually English)
- `messages_en_GB.properties`
- `messages_fr_FR.properties`
- `messages_de_DE.properties`
- etc.
### 2. Add New Translation Entries
Open each of these files and add your new translation entries. For example, if you're adding a new feature called "PDF Splitter",
Use descriptive, hierarchical keys (e.g., `feature.element.description`)
you might add:
```properties
pdfSplitter.title=PDF Splitter
pdfSplitter.description=Split your PDF into multiple documents
pdfSplitter.button.split=Split PDF
pdfSplitter.input.pages=Enter page numbers to split
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=merged.pdf")
.contentType(MediaType.APPLICATION_PDF)
.body(merged);
}
```
Add these entries to the default GB language file and any others you wish, translating the values as appropriate for each language.
## 12. Best Practices
### 3. Use Translations in Thymeleaf Templates
### Frontend
- Always use FileContext for file operations
- Implement proper cleanup for PDF.js documents and blob URLs
- Use the `useToolOperation` hook for consistent tool behavior
- Follow TypeScript strict mode guidelines
- Test with large files (100MB+) to ensure memory efficiency
In your Thymeleaf templates, use the `#{key}` syntax to reference the new translations:
### Backend
- Use PDFBox for PDF manipulation
- Implement proper error handling and logging
- Add Swagger documentation to all API endpoints
- Use service layer for business logic
- Follow Spring Boot best practices
```html
<h1 th:text="#{pdfSplitter.title}">PDF Splitter</h1>
<p th:text="#{pdfSplitter.description}">Split your PDF into multiple documents</p>
<input type="text" th:placeholder="#{pdfSplitter.input.pages}">
<button th:text="#{pdfSplitter.button.split}">Split PDF</button>
```
Remember, never hard-code text in your templates or Java code. Always use translation keys to ensure proper localization.
### General
- Write clear commit messages
- Update documentation for any API changes
- Test in Docker before submitting PRs
- Run `./gradlew spotlessApply` to format code
- Ensure all tests pass with `./test.sh`
+149 -27
View File
@@ -8,36 +8,66 @@
Fork Stirling-PDF and create a new branch out of `main`.
Then add a reference to the language in the navbar by adding a new language entry to the dropdown:
## Add Language to i18n Configuration
- Edit the file: [languages.html](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/templates/fragments/languages.html)
Edit the file: [frontend/src/i18n.ts](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/src/i18n.ts)
Add your language to the `supportedLanguages` object. For example, to add Polish:
For example, to add Polish, you would add:
```html
<div th:replace="~{fragments/languageEntry :: languageEntry ('pl_PL', 'Polski')}" ></div>
```typescript
export const supportedLanguages = {
'en': 'English',
'en-GB': 'English (UK)',
// ... other languages ...
'pl-PL': 'Polski', // Add your language here
};
```
The `data-bs-language-code` is the code used to reference the file in the next step.
If your language uses right-to-left (RTL) text direction, also add it to the `rtlLanguages` array:
### Add Language Property File
```typescript
export const rtlLanguages = ['ar-AR', 'fa-IR', 'pl-PL']; // Add if RTL
```
Start by copying the existing English property file:
## Create Translation Directory
- [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/messages_en_GB.properties)
Create a new directory for your language in `frontend/public/locales/`. For Polish, this would be:
Copy and rename it to `messages_{your data-bs-language-code here}.properties`. In the Polish example, you would set the name to `messages_pl_PL.properties`.
```bash
mkdir -p frontend/public/locales/pl-PL
```
Then simply translate all property entries within that file and make a Pull Request (PR) into `main` for others to use!
## Add Translation File
If you do not have a Java IDE, I am happy to verify that the changes work once you raise the PR (but I won't be able to verify the translations themselves).
Start by copying the existing English (UK) translation file:
- [frontend/public/locales/en-GB/translation.json](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/public/locales/en-GB/translation.json)
Copy and rename it to `frontend/public/locales/{your-language-code}/translation.json`. In the Polish example:
```bash
cp frontend/public/locales/en-GB/translation.json frontend/public/locales/pl-PL/translation.json
```
Then translate all entries within that JSON file. The file uses nested JSON structure like:
```json
{
"addPageNumbers": {
"title": "Add Page Numbers",
"submit": "Add Page Numbers",
"error": {
"failed": "Add page numbers operation failed"
}
}
}
```
## Handling Untranslatable Strings
Sometimes, certain strings in the properties file may not require translation because they are the same in the target language or are universal (like names of protocols, certain terminologies, etc.). To ensure accurate statistics for language progress, these strings should be added to the `ignore_translation.toml` file located in the `scripts` directory. This will exclude them from the translation progress calculations.
Sometimes, certain strings may not require translation because they are the same in the target language or are universal (like names of protocols, certain terminologies, etc.). To ensure accurate statistics for language progress, these strings should be added to the `ignore_translation.toml` file located in the `scripts` directory. This will exclude them from the translation progress calculations.
For example, if the English string `error=Error` does not need translation in Polish, add it to the `ignore_translation.toml` under the Polish section:
For example, if the English string for "error" does not need translation in Polish, add it to the `ignore_translation.toml` under the Polish section:
```toml
[pl_PL]
@@ -50,27 +80,119 @@ ignore = [
## Add New Translation Tags
> [!IMPORTANT]
> If you add any new translation tags, they must first be added to the `messages_en_GB.properties` file. This ensures consistency across all language files.
> If you add any new translation tags, they must first be added to the `frontend/public/locales/en-GB/translation.json` file. This ensures consistency across all language files.
- New translation tags **must be added** to the `messages_en_GB.properties` file to maintain a reference for other languages.
- After adding the new tags to `messages_en_GB.properties`, add and translate them in the respective language file (e.g., `messages_pl_PL.properties`).
- New translation tags **must be added** to the `en-GB` translation file to maintain a reference for other languages.
- After adding the new tags to the en-GB file, add and translate them in the respective language file (e.g., `pl-PL/translation.json`).
Make sure to place the entry under the correct language section. This helps maintain the accuracy of translation progress statistics and ensures that the translation tool or scripts do not misinterpret the completion rate.
### Use this code to perform a local check
## Testing Your Translation
#### Windows command
### Start the development server
```powershell
python .github/scripts/check_language_properties.py --reference-file app\core\src\main\resources\messages_en_GB.properties --branch "" --files app\core\src\main\resources\messages_pl_PL.properties
1. Start the frontend development server:
```bash
cd frontend
npm run dev
```
python .github/scripts/check_language_properties.py --reference-file app\core\src\main\resources\messages_en_GB.properties --branch "" --check-file app\core\src\main\resources\messages_pl_PL.properties
```
2. The language selector should now include your new language
#### Linux command
3. Select your language from the dropdown and verify all translations appear correctly
## Summary Checklist
When adding a new language, you need to update:
- [ ] `frontend/src/i18n.ts` - Add to supportedLanguages (and rtlLanguages if needed)
- [ ] `frontend/public/locales/{language-code}/translation.json` - Create and translate
- [ ] `scripts/ignore_translation.toml` - Add untranslatable strings if needed
Then make a Pull Request (PR) into `main` for others to use!
If you do not have a Node.js environment, we are happy to verify that the changes work once you raise the PR (but we won't be able to verify the translations themselves).
## Translation Guidelines
- **Consistency**: Keep terminology consistent throughout the translation
- **Context**: Consider the UI context when translating (e.g., button labels should be concise)
- **Formatting**: Preserve placeholders like `{n}` or `{{count}}` in translations
- **Testing**: Test your translations in the frontend interface
- **RTL Languages**: If your language uses RTL, ensure you add it to the rtlLanguages array
## Advanced: Translation Management Scripts
For translators working on large translation files, Python scripts are available in `scripts/translations/` to help manage the workflow.
### Finding Untranslated Strings
To see which strings still need translation:
```bash
python3 .github/scripts/check_language_properties.py --reference-file app/core/src/main/resources/messages_en_GB.properties --branch "" --files app/core/src/main/resources/messages_pl_PL.properties
# Check translation status for your language
python scripts/translations/translation_analyzer.py --language pl-PL --summary
python3 .github/scripts/check_language_properties.py --reference-file app/core/src/main/resources/messages_en_GB.properties --branch "" --check-file app/core/src/main/resources/messages_pl_PL.properties
# See detailed list of missing translations
python scripts/translations/translation_analyzer.py --language pl-PL --missing-only
```
### Extracting Untranslated Strings
To extract only the strings that need translation into a separate file:
```bash
# Extract to a compact JSON file
python scripts/translations/compact_translator.py pl-PL --output to_translate.json
```
This creates a file with just the untranslated entries:
```json
{
"addPageNumbers.title": "Add Page Numbers",
"compress.header": "Compress PDF",
"merge.submit": "Merge PDFs"
}
```
### Translating the Extracted File
Open `to_translate.json` and translate the values while keeping the keys unchanged:
```json
{
"addPageNumbers.title": "Dodaj numery stron",
"compress.header": "Kompresuj PDF",
"merge.submit": "Połącz pliki PDF"
}
```
### Merging Translations Back
After translating, merge your translations back into the main file:
```bash
# Apply your translations
python scripts/translations/translation_merger.py pl-PL apply-translations --translations-file to_translate.json
# Verify the result
python scripts/translations/translation_analyzer.py --language pl-PL --summary
```
### Validating Your Work
Before submitting, validate your translation file:
```bash
# Check for JSON syntax errors
python scripts/translations/json_validator.py frontend/public/locales/pl-PL/translation.json
# Check for missing placeholders
python scripts/translations/validate_placeholders.py --language pl-PL
# Check for structural issues
python scripts/translations/validate_json_structure.py --language pl-PL
```
**Note**: These scripts require Python 3.7+ to be installed. See `scripts/translations/README.md` for detailed documentation.
+8 -1
View File
@@ -50,7 +50,14 @@ docker-compose -f docker/compose/docker-compose.fat.yml up --build
- **Custom Ports**: Modify port mappings in docker-compose files
- **Memory Limits**: Adjust memory limits per variant (2G ultra-lite, 4G standard, 6G fat)
### [Google Drive Integration](https://developers.google.com/workspace/drive/picker/guides/overview)
- **VITE_GOOGLE_DRIVE_CLIENT_ID**: [OAuth 2.0 Client ID](https://console.cloud.google.com/auth/clients/create)
- **VITE_GOOGLE_DRIVE_API_KEY**: [Create New API](https://console.cloud.google.com/apis)
- **VITE_GOOGLE_DRIVE_APP_ID**: This is your [project number](https://console.cloud.google.com/iam-admin/settings) in the GoogleCloud Settings
## Development vs Production
- **Development**: Keep backend port 8080 exposed for debugging
- **Production**: Remove backend port exposure, use only frontend proxy
- **Production**: Remove backend port exposure, use only frontend proxy
+3
View File
@@ -47,6 +47,9 @@ services:
- "3000:80"
environment:
BACKEND_URL: http://backend:8080
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
depends_on:
- backend
networks:
@@ -44,6 +44,9 @@ services:
- "3000:80"
environment:
BACKEND_URL: http://backend:8080
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
depends_on:
- backend
networks:
+3
View File
@@ -46,6 +46,9 @@ services:
- "3000:80"
environment:
BACKEND_URL: http://backend:8080
#VITE_GOOGLE_DRIVE_CLIENT_ID: <INSERT_YOUR_CLIENT_ID_HERE>
#VITE_GOOGLE_DRIVE_API_KEY: <INSERT_YOUR_API_KEY_HERE>
#VITE_GOOGLE_DRIVE_APP_ID: <INSERT_YOUR_APP_ID_HERE>
depends_on:
- backend
networks:
+1 -1
View File
@@ -4,7 +4,7 @@ FROM node:20-alpine AS build
WORKDIR /app
# Copy package files
COPY frontend/package*.json ./
COPY frontend/package.json frontend/package-lock.json ./
# Install dependencies
RUN npm ci
+13
View File
@@ -6,6 +6,11 @@ http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Add .mjs MIME type mapping
types {
text/javascript mjs;
}
# Gzip compression
gzip on;
gzip_vary on;
@@ -90,6 +95,14 @@ http {
proxy_set_header X-Forwarded-Port $server_port;
}
# Serve .mjs files with correct MIME type (must come before general static assets)
location ~* \.mjs$ {
try_files $uri =404;
add_header Content-Type "text/javascript; charset=utf-8" always;
expires 1y;
add_header Cache-Control "public, immutable";
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
+28 -2
View File
@@ -1,9 +1,18 @@
// @ts-check
import eslint from '@eslint/js';
import globals from "globals";
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
const srcGlobs = [
'src/**/*.{js,mjs,jsx,ts,tsx}',
];
const nodeGlobs = [
'scripts/**/*.{js,ts,mjs}',
'*.config.{js,ts,mjs}',
];
export default defineConfig(
eslint.configs.recommended,
tseslint.configs.recommended,
@@ -15,7 +24,6 @@ export default defineConfig(
},
{
rules: {
"no-undef": "off", // Temporarily disabled until codebase conformant
"@typescript-eslint/no-empty-object-type": [
"error",
{
@@ -38,5 +46,23 @@ export default defineConfig(
},
],
},
}
},
// Config for browser scripts
{
files: srcGlobs,
languageOptions: {
globals: {
...globals.browser,
}
}
},
// Config for node scripts
{
files: nodeGlobs,
languageOptions: {
globals: {
...globals.node,
}
}
},
);
+1
View File
@@ -2,6 +2,7 @@
<html lang="en-GB">
<head>
<meta charset="UTF-8" />
<base href="%BASE_URL%" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
+3213 -3582
View File
File diff suppressed because it is too large Load Diff
+85 -59
View File
@@ -5,52 +5,54 @@
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
"proxy": "http://localhost:8080",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.4",
"@embedpdf/core": "^1.1.1",
"@embedpdf/engines": "^1.1.1",
"@embedpdf/plugin-interaction-manager": "^1.1.1",
"@embedpdf/plugin-loader": "^1.1.1",
"@embedpdf/plugin-pan": "^1.1.1",
"@embedpdf/plugin-render": "^1.1.1",
"@embedpdf/plugin-rotate": "^1.1.1",
"@embedpdf/plugin-scroll": "^1.1.1",
"@embedpdf/plugin-search": "^1.1.1",
"@embedpdf/plugin-selection": "^1.1.1",
"@embedpdf/plugin-spread": "^1.1.1",
"@embedpdf/plugin-thumbnail": "^1.1.1",
"@embedpdf/plugin-tiling": "^1.1.1",
"@embedpdf/plugin-viewport": "^1.1.1",
"@embedpdf/plugin-zoom": "^1.1.1",
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@embedpdf/core": "^1.3.1",
"@embedpdf/engines": "^1.3.1",
"@embedpdf/plugin-annotation": "^1.3.1",
"@embedpdf/plugin-export": "^1.3.1",
"@embedpdf/plugin-history": "^1.3.1",
"@embedpdf/plugin-interaction-manager": "^1.3.1",
"@embedpdf/plugin-loader": "^1.3.1",
"@embedpdf/plugin-pan": "^1.3.1",
"@embedpdf/plugin-render": "^1.3.1",
"@embedpdf/plugin-rotate": "^1.3.1",
"@embedpdf/plugin-scroll": "^1.3.1",
"@embedpdf/plugin-search": "^1.3.1",
"@embedpdf/plugin-selection": "^1.3.1",
"@embedpdf/plugin-spread": "^1.3.1",
"@embedpdf/plugin-thumbnail": "^1.3.1",
"@embedpdf/plugin-tiling": "^1.3.1",
"@embedpdf/plugin-viewport": "^1.3.1",
"@embedpdf/plugin-zoom": "^1.3.1",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@iconify/react": "^6.0.0",
"@mantine/core": "^8.0.1",
"@mantine/dates": "^8.0.1",
"@mantine/dropzone": "^8.0.1",
"@mantine/hooks": "^8.0.1",
"@mui/icons-material": "^7.1.0",
"@mui/material": "^7.1.0",
"@tailwindcss/postcss": "^4.1.8",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
"@mantine/core": "^8.3.1",
"@mantine/dates": "^8.3.1",
"@mantine/dropzone": "^8.3.1",
"@mantine/hooks": "^8.3.1",
"@mui/icons-material": "^7.3.2",
"@mui/material": "^7.3.2",
"@tailwindcss/postcss": "^4.1.13",
"@tanstack/react-virtual": "^3.13.12",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"autoprefixer": "^10.4.21",
"axios": "^1.9.0",
"i18next": "^25.2.1",
"i18next-browser-languagedetector": "^8.1.0",
"axios": "^1.12.2",
"globals": "^16.4.0",
"i18next": "^25.5.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"jszip": "^3.10.1",
"license-report": "^6.8.0",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^3.11.174",
"posthog-js": "^1.261.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-i18next": "^15.5.2",
"react-router-dom": "^7.6.0",
"tailwindcss": "^4.1.8",
"web-vitals": "^2.1.4"
"pdfjs-dist": "^5.4.149",
"posthog-js": "^1.268.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-i18next": "^15.7.3",
"react-router-dom": "^7.9.1",
"signature_pad": "^5.0.4",
"tailwindcss": "^4.1.13",
"web-vitals": "^5.1.0"
},
"scripts": {
"predev": "npm run generate-icons",
@@ -70,12 +72,17 @@
"test:coverage": "vitest --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:install": "playwright install"
"test:e2e:install": "playwright install",
"update:minor": "npm outdated || npm update && npm audit fix && npm test",
"update:major": "npx npm-check-updates -u && npm install",
"update:interactive": "npx npm-check-updates -i",
"update:minor-strict": "npx npm-check-updates -u --target minor && npm install"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended"
]
},
"browserslist": {
@@ -91,26 +98,45 @@
]
},
"devDependencies": {
"@eslint/js": "^9.34.0",
"@iconify-json/material-symbols": "^1.2.33",
"@iconify/utils": "^3.0.1",
"@playwright/test": "^1.40.0",
"@types/node": "^24.2.1",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.0",
"@vitest/coverage-v8": "^1.0.0",
"eslint": "^9.34.0",
"jsdom": "^23.0.0",
"@eslint/js": "^9.36.0",
"@iconify-json/material-symbols": "^1.2.37",
"@iconify/utils": "^3.0.2",
"@playwright/test": "^1.55.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/gapi": "^0.0.47",
"@types/gapi.client.drive-v3": "^0.0.5",
"@types/google.accounts": "^0.0.18",
"@types/google.picker": "^0.0.51",
"@types/node": "^24.5.2",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"@typescript-eslint/eslint-plugin": "^8.44.1",
"@typescript-eslint/parser": "^8.44.1",
"@vitejs/plugin-react-swc": "^4.1.0",
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"jsdom": "^27.0.0",
"license-checker": "^25.0.1",
"madge": "^8.0.0",
"postcss": "^8.5.3",
"postcss": "^8.5.6",
"postcss-cli": "^11.0.1",
"postcss-preset-mantine": "^1.17.0",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"typescript": "^5.9.2",
"typescript-eslint": "^8.42.0",
"vite": "^6.3.5",
"vitest": "^1.0.0"
"typescript-eslint": "^8.44.1",
"vite": "^7.1.7",
"vitest": "^3.2.4"
},
"depcheck": {
"ignoreMatches": [
"@emotion/*",
"tailwindcss",
"@testing-library/user-event",
"@vitest/coverage-v8"
]
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+460 -54
View File
@@ -1,4 +1,11 @@
{
"unsavedChanges": "You have unsaved changes to your PDF.",
"areYouSure": "Are you sure you want to leave?",
"unsavedChangesTitle": "Unsaved Changes",
"keepWorking": "Keep Working",
"discardChanges": "Discard & Leave",
"applyAndContinue": "Save & Leave",
"exportAndContinue": "Export & Continue",
"language": {
"direction": "ltr"
},
@@ -10,16 +17,34 @@
"selectText": {
"1": "Select PDF file:",
"2": "Margin Size",
"3": "Position",
"3": "Position Selection",
"4": "Starting Number",
"5": "Pages to Number",
"6": "Custom Text"
"6": "Custom Text Format"
},
"customTextDesc": "Custom Text",
"numberPagesDesc": "Which pages to number, default 'all', also accepts 1-5 or 2,5,9 etc",
"customNumberDesc": "Defaults to {n}, also accepts 'Page {n} of {total}', 'Text-{n}', '{filename}-{n}",
"submit": "Add Page Numbers"
"numberPagesDesc": "e.g., 1,3,5-8 or leave blank for all pages",
"customNumberDesc": "e.g., \"Page {n}\" or leave blank for just numbers",
"submit": "Add Page Numbers",
"configuration": "Configuration",
"customize": "Customize Appearance",
"pagesAndStarting": "Pages & Starting Number",
"positionAndPages": "Position & Pages",
"error": {
"failed": "Add page numbers operation failed"
},
"results": {
"title": "Page Number Results"
},
"preview": "Position Selection",
"previewDisclaimer": "Preview is approximate. Final output may vary due to PDF font metrics."
},
"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.",
"startingNumberTooltip": "The first number to display. Subsequent pages will increment from this number.",
"marginTooltip": "Distance between the page number and the edge of the page.",
"fontSizeTooltip": "Size of the page number text in points. Larger numbers create bigger text.",
"fontTypeTooltip": "Font family for the page numbers. Choose based on your document style.",
"customTextTooltip": "Optional custom format for page numbers. Use {n} as placeholder for the number. Example: \"Page {n}\" will show \"Page 1\", \"Page 2\", etc.",
"pdfPrompt": "Select PDF(s)",
"multiPdfPrompt": "Select PDFs (2+)",
"multiPdfDropPrompt": "Select (or drag & drop) all PDFs you require",
@@ -41,6 +66,8 @@
"save": "Save",
"saveToBrowser": "Save to Browser",
"download": "Download",
"pin": "Pin",
"unpin": "Unpin",
"undoOperationTooltip": "Click to undo the last operation and restore the original files",
"undo": "Undo",
"moreOptions": "More Options",
@@ -74,7 +101,10 @@
},
"error": {
"pdfPassword": "The PDF Document is passworded and either the password was not provided or was incorrect",
"encryptedPdfMustRemovePassword": "This PDF is encrypted or password-protected. Please unlock it before converting to PDF/A.",
"incorrectPasswordProvided": "The PDF password is incorrect or not provided.",
"_value": "Error",
"dismissAllErrors": "Dismiss All Errors",
"sorry": "Sorry for the issue!",
"needHelp": "Need help / Found an issue?",
"contactTip": "If you're still having trouble, don't hesitate to reach out to us for help. You can submit a ticket on our GitHub page or contact us through Discord:",
@@ -145,6 +175,7 @@
"noFileSelected": "No file selected. Please upload one.",
"legal": {
"privacy": "Privacy Policy",
"iAgreeToThe": "I agree to all of the",
"terms": "Terms and Conditions",
"accessibility": "Accessibility",
"cookie": "Cookie Policy",
@@ -227,6 +258,33 @@
"cacheInputs": {
"name": "Save form inputs",
"help": "Enable to store previously used inputs for future runs"
},
"general": {
"title": "General",
"description": "Configure general application preferences.",
"autoUnzip": "Auto-unzip API responses",
"autoUnzipDescription": "Automatically extract files from ZIP responses",
"autoUnzipTooltip": "Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.",
"autoUnzipFileLimit": "Auto-unzip file limit",
"autoUnzipFileLimitDescription": "Maximum number of files to extract from ZIP",
"autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs."
},
"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",
"noShortcut": "No shortcut set"
}
},
"changeCreds": {
@@ -356,222 +414,276 @@
"globalPopularity": "Global Popularity",
"sortBy": "Sort by:",
"multiTool": {
"tags": "multiple,tools",
"title": "PDF Multi Tool",
"desc": "Merge, Rotate, Rearrange, Split, and Remove pages"
},
"merge": {
"tags": "combine,join,unite",
"title": "Merge",
"desc": "Easily merge multiple PDFs into one."
},
"split": {
"tags": "divide,separate,break",
"title": "Split",
"desc": "Split PDFs into multiple documents"
},
"rotate": {
"tags": "turn,flip,orient",
"title": "Rotate",
"desc": "Easily rotate your PDFs."
},
"convert": {
"tags": "transform,change",
"title": "Convert",
"desc": "Convert files between different formats"
},
"pdfOrganiser": {
"tags": "organize,rearrange,reorder",
"title": "Organise",
"desc": "Remove/Rearrange pages in any order"
},
"addImage": {
"tags": "insert,embed,place",
"title": "Add image",
"desc": "Adds a image onto a set location on the PDF"
},
"addAttachments": {
"tags": "embed,attach,include",
"title": "Add Attachments",
"desc": "Add or remove embedded files (attachments) to/from a PDF"
},
"watermark": {
"tags": "stamp,mark,overlay",
"title": "Add Watermark",
"desc": "Add a custom watermark to your PDF document."
},
"removePassword": {
"tags": "unlock",
"title": "Remove Password",
"desc": "Remove password protection from your PDF document."
},
"compress": {
"tags": "shrink,reduce,optimize",
"title": "Compress",
"desc": "Compress PDFs to reduce their file size."
},
"unlockPDFForms": {
"tags": "unlock,enable,edit",
"title": "Unlock PDF Forms",
"desc": "Remove read-only property of form fields in a PDF document."
},
"changeMetadata": {
"tags": "edit,modify,update",
"title": "Change Metadata",
"desc": "Change/Remove/Add metadata from a PDF document"
},
"ocr": {
"tags": "extract,scan",
"title": "OCR / Cleanup scans",
"desc": "Cleanup scans and detects text from images within a PDF and re-adds it as text."
},
"extractImages": {
"tags": "pull,save,export",
"title": "Extract Images",
"desc": "Extracts all images from a PDF and saves them to zip"
},
"scannerImageSplit": {
"title": "Detect/Split Scanned photos",
"desc": "Splits multiple photos from within a photo/PDF"
"tags": "detect,split,photos",
"title": "Detect & Split Scanned Photos",
"desc": "Detect and split scanned photos into separate pages"
},
"sign": {
"tags": "signature,autograph",
"title": "Sign",
"desc": "Adds signature to PDF by drawing, text or image"
},
"flatten": {
"tags": "simplify,remove,interactive",
"title": "Flatten",
"desc": "Remove all interactive elements and forms from a PDF"
},
"certSign": {
"tags": "authenticate,PEM,P12,official,encrypt,sign,certificate,PKCS12,JKS,server,manual,auto",
"title": "Sign with Certificate",
"desc": "Signs a PDF with a Certificate/Key (PEM/P12)"
},
"repair": {
"tags": "fix,restore",
"title": "Repair",
"desc": "Tries to repair a corrupt/broken PDF"
},
"removeBlanks": {
"tags": "delete,clean,empty",
"title": "Remove Blank pages",
"desc": "Detects and removes blank pages from a document"
},
"removeAnnotations": {
"tags": "delete,clean,strip",
"title": "Remove Annotations",
"desc": "Removes all comments/annotations from a PDF"
},
"compare": {
"tags": "difference",
"title": "Compare",
"desc": "Compares and shows the differences between 2 PDF Documents"
},
"removeCertSign": {
"tags": "remove,delete,unlock",
"title": "Remove Certificate Sign",
"desc": "Remove certificate signature from PDF"
},
"pageLayout": {
"tags": "layout,arrange,combine",
"title": "Multi-Page Layout",
"desc": "Merge multiple pages of a PDF document into a single page"
},
"bookletImposition": {
"tags": "booklet,print,binding",
"title": "Booklet Imposition",
"desc": "Create booklets with proper page ordering and multi-page layout for printing and binding"
},
"scalePages": {
"tags": "resize,adjust,scale",
"title": "Adjust page size/scale",
"desc": "Change the size/scale of a page and/or its contents."
},
"addPageNumbers": {
"tags": "number,pagination,count",
"title": "Add Page Numbers",
"desc": "Add Page numbers throughout a document in a set location"
},
"autoRename": {
"tags": "auto-detect,header-based,organize,relabel",
"title": "Auto Rename PDF File",
"desc": "Auto renames a PDF file based on its detected header"
},
"adjustContrast": {
"tags": "contrast,brightness,saturation",
"title": "Adjust Colours/Contrast",
"desc": "Adjust Contrast, Saturation and Brightness of a PDF"
"desc": "Adjust Colors/Contrast, Saturation and Brightness of a PDF"
},
"crop": {
"tags": "trim,cut,resize",
"title": "Crop PDF",
"desc": "Crop a PDF to reduce its size (maintains text!)"
},
"autoSplitPDF": {
"tags": "auto,split,QR",
"title": "Auto Split Pages",
"desc": "Auto Split Scanned PDF with physical scanned page splitter QR Code"
},
"sanitize": {
"tags": "clean,purge,remove",
"title": "Sanitise",
"desc": "Remove potentially harmful elements from PDF files"
},
"getPdfInfo": {
"tags": "info,metadata,details",
"title": "Get ALL Info on PDF",
"desc": "Grabs any and all information possible on PDFs"
},
"pdfToSinglePage": {
"tags": "combine,merge,single",
"title": "PDF to Single Large Page",
"desc": "Merges all PDF pages into one large single page"
},
"showJS": {
"tags": "javascript,code,script",
"title": "Show Javascript",
"desc": "Searches and displays any JS injected into a PDF"
},
"redact": {
"tags": "censor,blackout,hide",
"title": "Redact",
"desc": "Redacts (blacks out) a PDF based on selected text, drawn shapes and/or selected page(s)"
},
"overlayPdfs": {
"tags": "overlay,combine,stack",
"title": "Overlay PDFs",
"desc": "Overlays PDFs on-top of another PDF"
},
"splitBySections": {
"tags": "split,sections,divide",
"title": "Split PDF by Sections",
"desc": "Divide each page of a PDF into smaller horizontal and vertical sections"
},
"addStamp": {
"tags": "stamp,mark,seal",
"title": "Add Stamp to PDF",
"desc": "Add text or add image stamps at set locations"
},
"removeImage": {
"tags": "remove,delete,clean",
"title": "Remove image",
"desc": "Remove image from PDF to reduce file size"
},
"splitByChapters": {
"tags": "split,chapters,structure",
"title": "Split PDF by Chapters",
"desc": "Split a PDF into multiple files based on its chapter structure."
},
"validateSignature": {
"tags": "validate,verify,certificate",
"title": "Validate PDF Signature",
"desc": "Verify digital signatures and certificates in PDF documents"
},
"swagger": {
"tags": "API,documentation,test",
"title": "API Documentation",
"desc": "View API documentation and test endpoints"
},
"fakeScan": {
"title": "Fake Scan",
"scannerEffect": {
"tags": "scan,simulate,create",
"title": "Scanner Effect",
"desc": "Create a PDF that looks like it was scanned"
},
"editTableOfContents": {
"tags": "bookmarks,contents,edit",
"title": "Edit Table of Contents",
"desc": "Add or edit bookmarks and table of contents in PDF documents"
},
"manageCertificates": {
"tags": "certificates,import,export",
"title": "Manage Certificates",
"desc": "Import, export, or delete digital certificate files used for signing PDFs."
},
"read": {
"title": "Read",
"read": {
"tags": "view,open,display",
"title": "Read",
"desc": "View and annotate PDFs. Highlight text, draw, or insert comments for review and collaboration."
},
"reorganizePages": {
"tags": "rearrange,reorder,organize",
"title": "Reorganize Pages",
"desc": "Rearrange, duplicate, or delete PDF pages with visual drag-and-drop control."
},
"extractPages": {
"tags": "pull,select,copy",
"title": "Extract Pages",
"desc": "Extract specific pages from a PDF document"
},
"removePages": {
"tags": "delete,extract,exclude",
"title": "Remove Pages",
"desc": "Remove specific pages from a PDF document"
},
"autoSizeSplitPDF": {
"tags": "auto,split,size",
"title": "Auto Split by Size/Count",
"desc": "Automatically split PDFs by file size or page count"
},
"replaceColorPdf": {
"replaceColor": {
"title": "Replace & Invert Colour",
"desc": "Replace or invert colours in PDF documents"
},
"devApi": {
"tags": "API,development,documentation",
"title": "API",
"desc": "Link to API documentation"
},
"devFolderScanning": {
"tags": "automation,folder,scanning",
"title": "Automated Folder Scanning",
"desc": "Link to automated folder scanning guide"
},
@@ -592,6 +704,7 @@
"desc": "Change document restrictions and permissions"
},
"automate": {
"tags": "workflow,sequence,automation",
"title": "Automate",
"desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
}
@@ -658,7 +771,6 @@
}
},
"split": {
"tags": "Page operations,divide,Multi Page,cut,server side",
"title": "Split PDF",
"header": "Split PDF",
"desc": {
@@ -784,9 +896,9 @@
}
},
"rotate": {
"tags": "server side",
"title": "Rotate PDF",
"submit": "Apply Rotation",
"selectRotation": "Select Rotation Angle (Clockwise)",
"error": {
"failed": "An error occurred while rotating the PDF."
},
@@ -901,7 +1013,7 @@
"header": "PDF Page Organiser",
"submit": "Rearrange Pages",
"mode": {
"_value": "Mode",
"_value": "Organization mode",
"1": "Custom Page Order",
"2": "Reverse Order",
"3": "Duplex Sort",
@@ -914,6 +1026,19 @@
"10": "Odd-Even Merge",
"11": "Duplicate all pages"
},
"desc": {
"CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.",
"REVERSE_ORDER": "Flip the document so the last page becomes first and so on.",
"DUPLEX_SORT": "Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …).",
"BOOKLET_SORT": "Arrange pages for booklet printing (last, first, second, second last, …).",
"SIDE_STITCH_BOOKLET_SORT": "Arrange pages for sidestitch booklet printing (optimised for binding on the side).",
"ODD_EVEN_SPLIT": "Split the document into two outputs: all odd pages and all even pages.",
"ODD_EVEN_MERGE": "Merge two PDFs by alternating pages: odd from the first, even from the second.",
"DUPLICATE": "Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×).",
"REMOVE_FIRST": "Remove the first page from the document.",
"REMOVE_LAST": "Remove the last page from the document.",
"REMOVE_FIRST_AND_LAST": "Remove both the first and last pages from the document."
},
"placeholder": "(e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)"
},
"addImage": {
@@ -1297,7 +1422,6 @@
}
},
"changeMetadata": {
"tags": "Title,author,date,creation,time,publisher,producer,stats",
"header": "Change Metadata",
"submit": "Change",
"filenamePrefix": "metadata",
@@ -1539,7 +1663,13 @@
"header": "Extract Images",
"selectText": "Select image format to convert extracted images to",
"allowDuplicates": "Save duplicate images",
"submit": "Extract"
"submit": "Extract",
"settings": {
"title": "Settings"
},
"error": {
"failed": "An error occurred while extracting images from the PDF."
}
},
"pdfToPDFA": {
"tags": "archive,long-term,standard,conversion,storage,preservation",
@@ -1599,20 +1729,49 @@
"tags": "separate,auto-detect,scans,multi-photo,organize",
"selectText": {
"1": "Angle Threshold:",
"2": "Sets the minimum absolute angle required for the image to be rotated (default: 10).",
"2": "Tilt (in degrees) needed before we auto-straighten a photo.",
"3": "Tolerance:",
"4": "Determines the range of colour variation around the estimated background colour (default: 30).",
"4": "How closely a colour must match the page background to count as background. Higher = looser, lower = stricter.",
"5": "Minimum Area:",
"6": "Sets the minimum area threshold for a photo (default: 10000).",
"6": "Smallest photo size (in pixels²) we'll keep to avoid tiny fragments.",
"7": "Minimum Contour Area:",
"8": "Sets the minimum contour area threshold for a photo",
"8": "Smallest edge/shape we consider when finding photos (filters dust and specks).",
"9": "Border Size:",
"10": "Sets the size of the border added and removed to prevent white borders in the output (default: 1)."
"10": "Extra padding (in pixels) around each saved photo so edges aren't cut."
},
"info": "Python is not installed. It is required to run."
},
"scannerImageSplit": {
"title": "Extracted Images",
"submit": "Extract Image Scans",
"error": {
"failed": "An error occurred while extracting image scans."
},
"tooltip": {
"title": "Photo Splitter",
"whatThisDoes": "What this does",
"whatThisDoesDesc": "Automatically finds and extracts each photo from a scanned page or composite image—no manual cropping.",
"whenToUse": "When to use",
"useCase1": "Scan whole album pages in one go",
"useCase2": "Split flatbed batches into separate files",
"useCase3": "Break collages into individual photos",
"useCase4": "Pull photos from documents",
"quickFixes": "Quick fixes",
"problem1": "Photos not detected → increase Tolerance to 30-50",
"problem2": "Too many false detections → increase Minimum Area to 15,000-20,000",
"problem3": "Crops are too tight → increase Border Size to 5-10",
"problem4": "Tilted photos not straightened → lower Angle Threshold to ~5°",
"problem5": "Dust/noise boxes → increase Minimum Contour Area to 1000-2000",
"setupTips": "Setup tips",
"tip1": "Use a plain, light background",
"tip2": "Leave a small gap (≈1 cm) between photos",
"tip3": "Scan at 300-600 DPI",
"tip4": "Clean the scanner glass",
"headsUp": "Heads-up",
"headsUpDesc": "Overlapping photos or backgrounds very close in colour to the photos can reduce accuracy-try a lighter or darker background and leave more space."
}
},
"sign": {
"tags": "authorize,initials,drawn-signature,text-sign,image-signature",
"title": "Sign",
"header": "Sign PDFs",
"upload": "Upload Image",
@@ -1622,6 +1781,7 @@
"add": "Add",
"saved": "Saved Signatures",
"save": "Save Signature",
"applySignatures": "Apply Signatures",
"personalSigs": "Personal Signatures",
"sharedSigs": "Shared Signatures",
"noSavedSigs": "No saved signatures found",
@@ -1633,10 +1793,52 @@
"previous": "Previous page",
"maintainRatio": "Toggle maintain aspect ratio",
"undo": "Undo",
"redo": "Redo"
"redo": "Redo",
"submit": "Sign Document",
"steps": {
"configure": "Configure Signature"
},
"type": {
"title": "Signature Type",
"draw": "Draw",
"canvas": "Canvas",
"image": "Image",
"text": "Text"
},
"draw": {
"title": "Draw your signature",
"clear": "Clear"
},
"image": {
"label": "Upload signature image",
"placeholder": "Select image file",
"hint": "Upload a PNG or JPG image of your signature"
},
"text": {
"name": "Signer Name",
"placeholder": "Enter your full name"
},
"instructions": {
"title": "How to add signature",
"canvas": "After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.",
"image": "After uploading your signature image above, click anywhere on the PDF to place it.",
"text": "After entering your name above, click anywhere on the PDF to place your signature."
},
"mode": {
"move": "Move Signature",
"place": "Place Signature"
},
"updateAndPlace": "Update and Place",
"activate": "Activate Signature Placement",
"deactivate": "Stop Placing Signatures",
"results": {
"title": "Signature Results"
},
"error": {
"failed": "An error occurred while signing the PDF."
}
},
"flatten": {
"tags": "static,deactivate,non-interactive,streamline",
"title": "Flatten",
"header": "Flatten PDF",
"flattenOnlyForms": "Flatten only forms",
@@ -1701,7 +1903,6 @@
}
},
"removeBlanks": {
"tags": "cleanup,streamline,non-content,organize",
"title": "Remove Blanks",
"header": "Remove Blank Pages",
"settings": {
@@ -1749,7 +1950,17 @@
"tags": "comments,highlight,notes,markup,remove",
"title": "Remove Annotations",
"header": "Remove Annotations",
"submit": "Remove"
"submit": "Remove",
"settings": {
"title": "Settings"
},
"info": {
"title": "About Remove Annotations",
"description": "This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents."
},
"error": {
"failed": "An error occurred while removing annotations from the PDF."
}
},
"compare": {
"tags": "differentiate,contrast,changes,analysis",
@@ -2098,7 +2309,6 @@
"tags": "color-correction,tune,modify,enhance,colour-correction"
},
"crop": {
"tags": "trim,shrink,edit,shape",
"title": "Crop",
"header": "Crop PDF",
"submit": "Apply Crop",
@@ -2382,6 +2592,7 @@
"tags": "Stamp, Add image, center image, Watermark, PDF, Embed, Customize,Customise",
"header": "Stamp PDF",
"title": "Stamp PDF",
"stampSetup": "Stamp Setup",
"stampType": "Stamp Type",
"stampText": "Stamp Text",
"stampImage": "Stamp Image",
@@ -2394,7 +2605,8 @@
"overrideY": "Override Y Coordinate",
"customMargin": "Custom Margin",
"customColor": "Custom Text Colour",
"submit": "Submit"
"submit": "Submit",
"noStampSelected": "No stamp selected. Return to Step 1."
},
"removeImagePdf": {
"tags": "Remove Image,Page operations,Back end,server side"
@@ -2448,31 +2660,56 @@
},
"selectCustomCert": "Custom Certificate File X.509 (Optional)"
},
"replace-color": {
"title": "Advanced Colour options",
"header": "Replace-Invert Colour PDF",
"selectText": {
"1": "Replace or Invert colour Options",
"2": "Default(Default high contrast colours)",
"3": "Custom(Customised colours)",
"4": "Full-Invert(Invert all colours)",
"5": "High contrast colour options",
"6": "white text on black background",
"7": "Black text on white background",
"8": "Yellow text on black background",
"9": "Green text on black background",
"10": "Choose text Colour",
"11": "Choose background Colour"
"replaceColor": {
"labels": {
"settings": "Settings",
"colourOperation": "Colour operation"
},
"submit": "Replace"
"options": {
"highContrast": "High contrast",
"invertAll": "Invert all colours",
"custom": "Custom"
},
"tooltip": {
"header": {
"title": "Replace & Invert Colour Settings Overview"
},
"description": {
"title": "Description",
"text": "Transform PDF colours to improve readability and accessibility. Choose from high contrast presets, invert all colours, or create custom colour schemes."
},
"highContrast": {
"title": "High Contrast",
"text": "Apply predefined high contrast colour combinations designed for better readability and accessibility compliance.",
"bullet1": "White text on black background - Classic dark mode",
"bullet2": "Black text on white background - Standard high contrast",
"bullet3": "Yellow text on black background - High visibility option",
"bullet4": "Green text on black background - Alternative high contrast"
},
"invertAll": {
"title": "Invert All Colours",
"text": "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions."
},
"custom": {
"title": "Custom Colours",
"text": "Define your own text and background colours using the colour pickers. Perfect for creating branded documents or specific accessibility requirements.",
"bullet1": "Text colour - Choose the colour for text elements",
"bullet2": "Background colour - Set the background colour for the document"
}
},
"error": {
"failed": "An error occurred while processing the colour replacement."
}
},
"replaceColorPdf": {
"replaceColor": {
"tags": "Replace Colour,Page operations,Back end,server side"
},
"login": {
"title": "Sign in",
"header": "Sign in",
"signin": "Sign in",
"signInWith": "Sign in with",
"signInAnonymously": "Sign Up as a Guest",
"rememberme": "Remember me",
"invalid": "Invalid username or password.",
"locked": "Your account has been locked.",
@@ -2491,7 +2728,54 @@
"alreadyLoggedIn": "You are already logged in to",
"alreadyLoggedIn2": "devices. Please log out of the devices and try again.",
"toManySessions": "You have too many active sessions",
"logoutMessage": "You have been logged out."
"logoutMessage": "You have been logged out.",
"youAreLoggedIn": "You are logged in!",
"email": "Email",
"password": "Password",
"enterEmail": "Enter your email",
"enterPassword": "Enter your password",
"loggingIn": "Logging In...",
"signingIn": "Signing in...",
"login": "Login",
"or": "Or",
"useMagicLink": "Use magic link instead",
"enterEmailForMagicLink": "Enter your email for magic link",
"sending": "Sending…",
"sendMagicLink": "Send Magic Link",
"cancel": "Cancel",
"dontHaveAccount": "Don't have an account? Sign up",
"home": "Home",
"debug": "Debug",
"signOut": "Sign Out",
"pleaseEnterBoth": "Please enter both email and password",
"pleaseEnterEmail": "Please enter your email address",
"magicLinkSent": "Magic link sent to {{email}}! Check your email and click the link to sign in.",
"passwordResetSent": "Password reset link sent to {{email}}! Check your email and follow the instructions.",
"failedToSignIn": "Failed to sign in with {{provider}}: {{message}}",
"unexpectedError": "Unexpected error: {{message}}"
},
"signup": {
"title": "Create an account",
"subtitle": "Join Stirling PDF to get started",
"name": "Name",
"email": "Email",
"password": "Password",
"confirmPassword": "Confirm password",
"enterName": "Enter your name",
"enterEmail": "Enter your email",
"enterPassword": "Enter your password",
"confirmPasswordPlaceholder": "Confirm password",
"or": "or",
"creatingAccount": "Creating Account...",
"signUp": "Sign Up",
"alreadyHaveAccount": "Already have an account? Sign in",
"pleaseFillAllFields": "Please fill in all fields",
"passwordsDoNotMatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 6 characters long",
"invalidEmail": "Please enter a valid email address",
"checkEmailConfirmation": "Check your email for a confirmation link to complete your registration.",
"accountCreatedSuccessfully": "Account created successfully! You can now sign in.",
"unexpectedError": "Unexpected error: {{message}}"
},
"pdfToSinglePage": {
"title": "PDF To Single Page",
@@ -2529,8 +2813,9 @@
"submit": "Sanitize PDF"
},
"adjustContrast": {
"title": "Adjust Contrast",
"header": "Adjust Contrast",
"title": "Adjust Colors/Contrast",
"header": "Adjust Colors/Contrast",
"basic": "Basic Adjustments",
"contrast": "Contrast:",
"brightness": "Brightness:",
"saturation": "Saturation:",
@@ -2549,6 +2834,28 @@
"grayscale": {
"label": "Apply Grayscale for Compression"
},
"tooltip": {
"header": {
"title": "Compress Settings Overview"
},
"description": {
"title": "Description",
"text": "Compression is an easy way to reduce your file size. Pick File Size to enter a target size and have us adjust quality for you. Pick Quality to set compression strength manually."
},
"qualityAdjustment": {
"title": "Quality Adjustment",
"text": "Drag the slider to adjust the compression strength. Lower values (1-3) preserve quality but result in larger files. Higher values (7-9) shrink the file more but reduce image clarity.",
"bullet1": "Lower values preserve quality",
"bullet2": "Higher values reduce file size"
},
"grayscale": {
"title": "Grayscale",
"text": "Select this option to convert all images to black and white, which can significantly reduce file size especially for scanned PDFs or image-heavy documents."
}
},
"error": {
"failed": "An error occurred while compressing the PDF."
},
"selectText": {
"1": {
"_value": "Compression Settings",
@@ -2795,7 +3102,22 @@
"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",
"placeholder": "Enter search term..."
},
"guestBanner": {
"title": "You're using Stirling PDF as a guest!",
"message": "Create a free account to save your work, access more features, and support the project.",
"dismiss": "Dismiss banner",
"signUp": "Sign Up Free"
},
"toolPicker": {
"searchPlaceholder": "Search tools...",
@@ -2827,6 +3149,7 @@
"automate": "Automate",
"files": "Files",
"activity": "Activity",
"account": "Account",
"config": "Config",
"allTools": "All Tools"
},
@@ -2854,6 +3177,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",
@@ -2882,6 +3208,7 @@
"lastModified": "Last Modified",
"toolChain": "Tools Applied",
"restore": "Restore",
"unzip": "Unzip",
"searchFiles": "Search files...",
"recent": "Recent",
"localFiles": "Local Files",
@@ -2889,7 +3216,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",
@@ -3161,5 +3487,85 @@
"processImages": "Process Images",
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
}
}
}
},
"viewer": {
"firstPage": "First Page",
"lastPage": "Last Page",
"previousPage": "Previous Page",
"nextPage": "Next Page",
"zoomIn": "Zoom In",
"zoomOut": "Zoom Out",
"singlePageView": "Single Page View",
"dualPageView": "Dual Page View"
},
"common": {
"copy": "Copy",
"copied": "Copied!",
"refresh": "Refresh",
"retry": "Retry",
"remaining": "remaining",
"used": "used",
"available": "available",
"cancel": "Cancel"
},
"config": {
"account": {
"overview": {
"title": "Account Settings",
"manageAccountPreferences": "Manage your account preferences",
"guestDescription": "You are signed in as a guest. Consider upgrading your account above."
},
"upgrade": {
"title": "Upgrade Guest Account",
"description": "Link your account to preserve your history and access more features!",
"socialLogin": "Upgrade with Social Account",
"linkWith": "Link with",
"emailPassword": "or enter your email & password",
"email": "Email",
"emailPlaceholder": "Enter your email",
"password": "Password (optional)",
"passwordPlaceholder": "Set a password",
"passwordNote": "Leave empty to use email verification only",
"upgradeButton": "Upgrade Account"
}
},
"apiKeys": {
"description": "Your API key for accessing Stirling's suite of PDF tools. Copy it to your project or refresh to generate a new one.",
"publicKeyAriaLabel": "Public API key",
"copyKeyAriaLabel": "Copy API key",
"refreshAriaLabel": "Refresh API key",
"includedCredits": "Included credits",
"purchasedCredits": "Purchased credits",
"totalCredits": "Total Credits",
"chartAriaLabel": "Credits usage: included {{includedUsed}} of {{includedTotal}}, purchased {{purchasedUsed}} of {{purchasedTotal}}",
"nextReset": "Next Reset",
"lastApiUse": "Last API Use",
"overlayMessage": "Generate a key to see credits and available credits",
"label": "API Key",
"guestInfo": "Guest users do not receive API keys. Create an account to get an API key you can use in your applications.",
"goToAccount": "Go to Account",
"refreshModal": {
"title": "Refresh API Keys",
"warning": "⚠️ Warning: This action will generate new API keys and make your previous keys invalid.",
"impact": "Any applications or services currently using these keys will stop working until you update them with the new keys.",
"confirmPrompt": "Are you sure you want to continue?",
"confirmCta": "Refresh Keys"
},
"generateError": "We couldn't generate your API key."
}
},
"AddAttachmentsRequest": {
"attachments": "Select Attachments",
"info": "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.",
"selectFiles": "Select Files to Attach",
"placeholder": "Choose files...",
"addMoreFiles": "Add more files...",
"selectedFiles": "Selected Files",
"submit": "Add Attachments",
"results": {
"title": "Attachment Results"
}
},
"termsAndConditions": "Terms & Conditions",
"logOut": "Log out"
}
+133 -14
View File
@@ -68,6 +68,8 @@
},
"error": {
"pdfPassword": "The PDF Document is passworded and either the password was not provided or was incorrect",
"encryptedPdfMustRemovePassword": "This PDF is encrypted or password-protected. Please unlock it before converting to PDF/A.",
"incorrectPasswordProvided": "The PDF password is incorrect or not provided.",
"_value": "Error",
"sorry": "Sorry for the issue!",
"needHelp": "Need help / Found an issue?",
@@ -348,206 +350,257 @@
"globalPopularity": "Global Popularity",
"sortBy": "Sort by:",
"multiTool": {
"tags": "multiple,tools",
"title": "PDF Multi Tool",
"desc": "Merge, Rotate, Rearrange, Split, and Remove pages"
},
"merge": {
"tags": "combine,join,unite",
"title": "Merge",
"desc": "Easily merge multiple PDFs into one."
},
"split": {
"tags": "divide,separate,break",
"title": "Split",
"desc": "Split PDFs into multiple documents"
},
"rotate": {
"tags": "turn,flip,orient",
"title": "Rotate",
"desc": "Easily rotate your PDFs."
},
"imageToPDF": {
"tags": "convert,image,transform",
"title": "Image to PDF",
"desc": "Convert a image (PNG, JPEG, GIF) to PDF."
},
"pdfToImage": {
"tags": "convert,image,extract",
"title": "PDF to Image",
"desc": "Convert a PDF to a image. (PNG, JPEG, GIF)"
},
"pdfOrganiser": {
"tags": "organize,rearrange,reorder",
"title": "Organize",
"desc": "Remove/Rearrange pages in any order"
},
"addImage": {
"tags": "insert,embed,place",
"title": "Add image",
"desc": "Adds a image onto a set location on the PDF"
},
"watermark": {
"tags": "stamp,mark,overlay",
"title": "Add Watermark",
"desc": "Add a custom watermark to your PDF document."
},
"permissions": {
"tags": "permissions,security,access",
"title": "Change Permissions",
"desc": "Change the permissions of your PDF document"
},
"pageRemover": {
"tags": "remove,delete,pages",
"title": "Remove",
"desc": "Delete unwanted pages from your PDF document."
},
"addPassword": {
"tags": "password,encrypt,secure",
"title": "Add Password",
"desc": "Encrypt your PDF document with a password."
},
"changePermissions": {
"tags": "permissions,restrictions,security",
"title": "Change Permissions",
"desc": "Change document restrictions and permissions."
},
"removePassword": {
"tags": "unlock,remove,password",
"title": "Remove Password",
"desc": "Remove password protection from your PDF document."
},
"compress": {
"tags": "shrink,reduce,optimize",
"title": "Compress",
"desc": "Compress PDFs to reduce their file size."
},
"sanitize": {
"tags": "clean,purge,remove",
"title": "Sanitize",
"desc": "Remove potentially harmful elements from PDF files."
},
"unlockPDFForms": {
"tags": "unlock,enable,edit",
"title": "Unlock PDF Forms",
"desc": "Remove read-only property of form fields in a PDF document."
},
"changeMetadata": {
"tags": "edit,modify,update",
"title": "Change Metadata",
"desc": "Change/Remove/Add metadata from a PDF document"
},
"fileToPDF": {
"tags": "convert,transform,change",
"title": "Convert file to PDF",
"desc": "Convert nearly any file to PDF (DOCX, PNG, XLS, PPT, TXT and more)"
},
"ocr": {
"tags": "extract,scan",
"title": "OCR / Cleanup scans",
"desc": "Cleanup scans and detects text from images within a PDF and re-adds it as text."
},
"extractImages": {
"tags": "pull,save,export",
"title": "Extract Images",
"desc": "Extracts all images from a PDF and saves them to zip"
},
"pdfToPDFA": {
"tags": "convert,archive,long-term",
"title": "PDF to PDF/A",
"desc": "Convert PDF to PDF/A for long-term storage"
},
"PDFToWord": {
"tags": "convert,word,doc",
"title": "PDF to Word",
"desc": "Convert PDF to Word formats (DOC, DOCX and ODT)"
},
"PDFToPresentation": {
"tags": "convert,presentation,ppt",
"title": "PDF to Presentation",
"desc": "Convert PDF to Presentation formats (PPT, PPTX and ODP)"
},
"PDFToText": {
"tags": "convert,text,rtf",
"title": "PDF to RTF (Text)",
"desc": "Convert PDF to Text or RTF format"
},
"PDFToHTML": {
"tags": "convert,html,web",
"title": "PDF to HTML",
"desc": "Convert PDF to HTML format"
},
"PDFToXML": {
"tags": "convert,xml,data",
"title": "PDF to XML",
"desc": "Convert PDF to XML format"
},
"ScannerImageSplit": {
"tags": "detect,split,photos",
"title": "Detect/Split Scanned photos",
"desc": "Splits multiple photos from within a photo/PDF"
},
"sign": {
"tags": "signature,autograph",
"title": "Sign",
"desc": "Adds signature to PDF by drawing, text or image"
},
"flatten": {
"tags": "simplify,remove,interactive",
"title": "Flatten",
"desc": "Remove all interactive elements and forms from a PDF"
},
"repair": {
"tags": "fix,restore",
"title": "Repair",
"desc": "Tries to repair a corrupt/broken PDF"
},
"removeBlanks": {
"tags": "delete,clean,empty",
"title": "Remove Blank pages",
"desc": "Detects and removes blank pages from a document"
},
"removeAnnotations": {
"tags": "delete,clean,strip",
"title": "Remove Annotations",
"desc": "Removes all comments/annotations from a PDF"
},
"compare": {
"tags": "difference",
"title": "Compare",
"desc": "Compares and shows the differences between 2 PDF Documents"
},
"certSign": {
"tags": "authenticate,PEM,P12,official,encrypt,sign,certificate,PKCS12,JKS,server,manual,auto",
"title": "Sign with Certificate",
"desc": "Signs a PDF with a Certificate/Key (PEM/P12)"
},
"removeCertSign": {
"tags": "remove,delete,unlock",
"title": "Remove Certificate Sign",
"desc": "Remove certificate signature from PDF"
},
"pageLayout": {
"tags": "layout,arrange,combine",
"title": "Multi-Page Layout",
"desc": "Merge multiple pages of a PDF document into a single page"
},
"bookletImposition": {
"tags": "booklet,print,binding",
"title": "Booklet Imposition",
"desc": "Create booklets with proper page ordering and multi-page layout for printing and binding"
},
"scalePages": {
"tags": "resize,adjust,scale",
"title": "Adjust page size/scale",
"desc": "Change the size/scale of a page and/or its contents."
},
"pipeline": {
"tags": "automation,script,workflow",
"title": "Pipeline",
"desc": "Run multiple actions on PDFs by defining pipeline scripts"
},
"addPageNumbers": {
"tags": "number,pagination,count",
"title": "Add Page Numbers",
"desc": "Add Page numbers throughout a document in a set location"
},
"auto-rename": {
"tags": "auto-detect,header-based,organize,relabel",
"title": "Auto Rename PDF File",
"desc": "Auto renames a PDF file based on its detected header"
},
"adjustContrast": {
"tags": "contrast,brightness,saturation",
"title": "Adjust Colors/Contrast",
"desc": "Adjust Contrast, Saturation and Brightness of a PDF"
"desc": "Adjust Colors/Contrast, Saturation and Brightness of a PDF"
},
"crop": {
"tags": "trim,cut,resize",
"title": "Crop PDF",
"desc": "Crop a PDF to reduce its size (maintains text!)"
},
"autoSplitPDF": {
"tags": "auto,split,QR",
"title": "Auto Split Pages",
"desc": "Auto Split Scanned PDF with physical scanned page splitter QR Code"
},
"sanitizePDF": {
"tags": "clean,purge,remove",
"title": "Sanitize",
"desc": "Remove scripts and other elements from PDF files"
},
"URLToPDF": {
"tags": "convert,url,website",
"title": "URL/Website To PDF",
"desc": "Converts any http(s)URL to PDF"
},
"HTMLToPDF": {
"tags": "convert,html,web",
"title": "HTML to PDF",
"desc": "Converts any HTML file or zip to PDF"
},
"MarkdownToPDF": {
"tags": "convert,markdown,md",
"title": "Markdown to PDF",
"desc": "Converts any Markdown file to PDF"
},
"PDFToMarkdown": {
"tags": "convert,markdown,md",
"title": "PDF to Markdown",
"desc": "Converts any PDF to Markdown"
},
"getPdfInfo": {
"tags": "info,metadata,details",
"title": "Get ALL Info on PDF",
"desc": "Grabs any and all information possible on PDFs"
},
@@ -564,50 +617,62 @@
"desc": "Searches and displays any JS injected into a PDF"
},
"autoRedact": {
"tags": "auto,redact,censor",
"title": "Auto Redact",
"desc": "Auto Redacts(Blacks out) text in a PDF based on input text"
},
"redact": {
"tags": "censor,blackout,hide",
"title": "Manual Redaction",
"desc": "Redacts a PDF based on selected text, drawn shapes and/or selected page(s)"
},
"PDFToCSV": {
"tags": "convert,csv,table",
"title": "PDF to CSV",
"desc": "Extracts Tables from a PDF converting it to CSV"
},
"split-by-size-or-count": {
"tags": "auto,split,size",
"title": "Auto Split by Size/Count",
"desc": "Split a single PDF into multiple documents based on size, page count, or document count"
},
"overlay-pdfs": {
"tags": "overlay,combine,stack",
"title": "Overlay PDFs",
"desc": "Overlays PDFs on-top of another PDF"
},
"split-by-sections": {
"tags": "split,sections,divide",
"title": "Split PDF by Sections",
"desc": "Divide each page of a PDF into smaller horizontal and vertical sections"
},
"AddStampRequest": {
"tags": "stamp,mark,seal",
"title": "Add Stamp to PDF",
"desc": "Add text or add image stamps at set locations"
},
"removeImage": {
"tags": "remove,delete,clean",
"title": "Remove image",
"desc": "Remove image from PDF to reduce file size"
},
"splitByChapters": {
"tags": "split,chapters,structure",
"title": "Split PDF by Chapters",
"desc": "Split a PDF into multiple files based on its chapter structure."
},
"validateSignature": {
"tags": "validate,verify,certificate",
"title": "Validate PDF Signature",
"desc": "Verify digital signatures and certificates in PDF documents"
},
"swagger": {
"tags": "API,documentation,test",
"title": "API Documentation",
"desc": "View API documentation and test endpoints"
},
"replace-color": {
"tags": "color,replace,invert",
"title": "Replace and Invert Color",
"desc": "Replace color for text and background in PDF and invert full color of pdf to reduce file size"
}
@@ -704,7 +769,7 @@
"header": "PDF Page Organizer",
"submit": "Rearrange Pages",
"mode": {
"_value": "Mode",
"_value": "Organization mode",
"1": "Custom Page Order",
"2": "Reverse Order",
"3": "Duplex Sort",
@@ -717,6 +782,19 @@
"10": "Odd-Even Merge",
"11": "Duplicate all pages"
},
"desc": {
"CUSTOM": "Use a custom sequence of page numbers or expressions to define a new order.",
"REVERSE_ORDER": "Flip the document so the last page becomes first and so on.",
"DUPLEX_SORT": "Interleave fronts then backs as if a duplex scanner scanned all fronts, then all backs (1, n, 2, n-1, …).",
"BOOKLET_SORT": "Arrange pages for booklet printing (last, first, second, second last, …).",
"SIDE_STITCH_BOOKLET_SORT": "Arrange pages for sidestitch booklet printing (optimized for binding on the side).",
"ODD_EVEN_SPLIT": "Split the document into two outputs: all odd pages and all even pages.",
"ODD_EVEN_MERGE": "Merge two PDFs by alternating pages: odd from the first, even from the second.",
"DUPLICATE": "Duplicate each page according to the custom order count (e.g., 4 duplicates each page 4×).",
"REMOVE_FIRST": "Remove the first page from the document.",
"REMOVE_LAST": "Remove the last page from the document.",
"REMOVE_FIRST_AND_LAST": "Remove both the first and last pages from the document."
},
"placeholder": "(e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)"
},
"addImage": {
@@ -991,7 +1069,13 @@
"header": "Extract Images",
"selectText": "Select image format to convert extracted images to",
"allowDuplicates": "Save duplicate images",
"submit": "Extract"
"submit": "Extract",
"settings": {
"title": "Settings"
},
"error": {
"failed": "An error occurred while extracting images from the PDF."
}
},
"pdfToPDFA": {
"tags": "archive,long-term,standard,conversion,storage,preservation",
@@ -1064,7 +1148,6 @@
"info": "Python is not installed. It is required to run."
},
"sign": {
"tags": "authorize,initials,drawn-signature,text-sign,image-signature",
"title": "Sign",
"header": "Sign PDFs",
"upload": "Upload Image",
@@ -1160,7 +1243,17 @@
"tags": "comments,highlight,notes,markup,remove",
"title": "Remove Annotations",
"header": "Remove Annotations",
"submit": "Remove"
"submit": "Remove",
"settings": {
"title": "Settings"
},
"info": {
"title": "About Remove Annotations",
"description": "This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents."
},
"error": {
"failed": "An error occurred while removing annotations from the PDF."
}
},
"compare": {
"tags": "differentiate,contrast,changes,analysis",
@@ -1470,6 +1563,7 @@
"tags": "Stamp, Add image, center image, Watermark, PDF, Embed, Customize",
"header": "Stamp PDF",
"title": "Stamp PDF",
"stampSetup": "Stamp Setup",
"stampType": "Stamp Type",
"stampText": "Stamp Text",
"stampImage": "Stamp Image",
@@ -1482,7 +1576,8 @@
"overrideY": "Override Y Coordinate",
"customMargin": "Custom Margin",
"customColor": "Custom Text Color",
"submit": "Submit"
"submit": "Submit",
"noStampSelected": "No stamp selected. Return to Step 1."
},
"removeImagePdf": {
"tags": "Remove Image,Page operations,Back end,server side"
@@ -1617,8 +1712,9 @@
"submit": "Sanitize PDF"
},
"adjustContrast": {
"title": "Adjust Contrast",
"header": "Adjust Contrast",
"title": "Adjust Colors/Contrast",
"header": "Adjust Colors/Contrast",
"basic": "Basic Adjustments",
"contrast": "Contrast:",
"brightness": "Brightness:",
"saturation": "Saturation:",
@@ -1738,10 +1834,16 @@
}
},
"removeImage": {
"title": "Remove image",
"header": "Remove image",
"removeImage": "Remove image",
"submit": "Remove image"
"title": "Remove Images",
"header": "Remove Images",
"removeImage": "Remove Images",
"submit": "Remove Images",
"results": {
"title": "Remove Images Results"
},
"error": {
"failed": "Failed to remove images from the PDF."
}
},
"splitByChapters": {
"title": "Split PDF by Chapters",
@@ -1786,7 +1888,7 @@
"title": "How we use Cookies",
"description": {
"1": "We use cookies and other technologies to make Stirling PDF work better for you—helping us improve our tools and keep building features you'll love.",
"2": "If youd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly."
"2": "If you'd rather not, clicking 'No Thanks' will only enable the essential cookies needed to keep things running smoothly."
},
"acceptAllBtn": "Okay",
"acceptNecessaryBtn": "No Thanks",
@@ -1810,7 +1912,7 @@
"1": "Strictly Necessary Cookies",
"2": "Always Enabled"
},
"description": "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they cant be turned off."
"description": "These cookies are essential for the website to function properly. They enable core features like setting your privacy preferences, logging in, and filling out forms—which is why they can't be turned off."
},
"analytics": {
"title": "Analytics",
@@ -2290,5 +2392,22 @@
},
"automate": {
"copyToSaved": "Copy to Saved"
},
"AddAttachmentsRequest": {
"attachments": "Select Attachments",
"info": "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.",
"selectFiles": "Select Files to Attach",
"placeholder": "Choose files...",
"addMoreFiles": "Add more files...",
"selectedFiles": "Selected Files",
"submit": "Add Attachments",
"results": {
"title": "Attachment Results"
}
},
"addAttachments": {
"error": {
"failed": "An error occurred while adding attachments to the PDF."
}
}
}
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
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 one or more lines are too long
-58353
View File
File diff suppressed because one or more lines are too long
+55 -48
View File
@@ -1,8 +1,15 @@
#!/usr/bin/env node
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const { execSync } = require('node:child_process');
const { existsSync, mkdirSync, writeFileSync, readFileSync } = require('node:fs');
const path = require('node:path');
const { argv } = require('node:process');
const inputIdx = argv.indexOf('--input');
const INPUT_FILE = inputIdx > -1 ? argv[inputIdx + 1] : null;
const POSTPROCESS_ONLY = !!INPUT_FILE;
// __dirname is available in CommonJS by default
/**
* Generate 3rd party licenses for frontend dependencies
@@ -14,59 +21,54 @@ const PACKAGE_JSON = path.join(__dirname, '..', 'package.json');
// Ensure the output directory exists
const outputDir = path.dirname(OUTPUT_FILE);
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
}
console.log('🔍 Generating frontend license report...');
try {
// Install license-checker if not present
try {
require.resolve('license-checker');
} catch {
console.log('📦 Installing license-checker...');
execSync('npm install --save-dev license-checker', { stdio: 'inherit' });
// Safety guard: don't run this script on fork PRs (workflow setzt PR_IS_FORK)
if (process.env.PR_IS_FORK === 'true' && !POSTPROCESS_ONLY) {
console.error('Fork PR detected: only --input (postprocess-only) mode is allowed.');
process.exit(2);
}
// Generate license report using license-checker (more reliable)
const licenseReport = execSync('npx license-checker --production --json', {
encoding: 'utf8',
cwd: path.dirname(PACKAGE_JSON)
});
let licenseData;
try {
licenseData = JSON.parse(licenseReport);
} catch (parseError) {
console.error('❌ Failed to parse license data:', parseError.message);
console.error('Raw output:', licenseReport.substring(0, 500) + '...');
// Generate license report using pinned license-checker; disable lifecycle scripts
if (POSTPROCESS_ONLY) {
if (!INPUT_FILE || !existsSync(INPUT_FILE)) {
console.error('❌ --input file missing or not found');
process.exit(1);
}
licenseData = JSON.parse(readFileSync(INPUT_FILE, 'utf8'));
} else {
const licenseReport = execSync(
// 'npx --yes license-checker@25.0.1 --production --json',
'npx --yes license-report --only=prod --output=json',
{
encoding: 'utf8',
cwd: path.dirname(PACKAGE_JSON),
env: { ...process.env, NPM_CONFIG_IGNORE_SCRIPTS: 'true' }
}
);
try {
licenseData = JSON.parse(licenseReport);
} catch (parseError) {
console.error('❌ Failed to parse license data:', parseError.message);
console.error('Raw output:', licenseReport.substring(0, 500) + '...');
process.exit(1);
}
}
if (!licenseData || typeof licenseData !== 'object') {
if (!Array.isArray(licenseData)) {
console.error('❌ Invalid license data structure');
process.exit(1);
}
// Convert license-checker format to array
const licenseArray = Object.entries(licenseData).map(([key, value]) => {
let name, version;
// Handle scoped packages like @mantine/core@1.0.0
if (key.startsWith('@')) {
const parts = key.split('@');
name = `@${parts[1]}`;
version = parts[2];
} else {
// Handle regular packages like react@18.0.0
const lastAtIndex = key.lastIndexOf('@');
name = key.substring(0, lastAtIndex);
version = key.substring(lastAtIndex + 1);
}
// Normalize license types for edge cases
let licenseType = value.licenses;
const licenseArray = licenseData.map(dep => {
let licenseType = dep.licenseType;
// Handle missing or null licenses
if (!licenseType || licenseType === null || licenseType === undefined) {
@@ -88,13 +90,17 @@ try {
licenseType = 'Unknown';
}
if ( "posthog-js" === dep.name && licenseType.startsWith("SEE LICENSE IN LICENSE")) {
licenseType = "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE";
}
return {
name: name,
version: version || value.version || 'unknown',
name: dep.name,
version: dep.installedVersion || dep.definedVersion || dep.remoteVersion || 'unknown',
licenseType: licenseType,
repository: value.repository,
url: value.url,
link: value.licenseUrl
repository: dep.link,
url: dep.link,
link: dep.link
};
});
@@ -153,7 +159,7 @@ try {
// Write license warnings to a separate file for CI/CD
const warningsFile = path.join(__dirname, '..', 'src', 'assets', 'license-warnings.json');
fs.writeFileSync(warningsFile, JSON.stringify({
writeFileSync(warningsFile, JSON.stringify({
warnings: problematicLicenses,
generated: new Date().toISOString()
}, null, 2));
@@ -163,7 +169,7 @@ try {
}
// Write to file
fs.writeFileSync(OUTPUT_FILE, JSON.stringify(transformedData, null, 4));
writeFileSync(OUTPUT_FILE, JSON.stringify(transformedData, null, 4));
console.log(`✅ License report generated successfully!`);
console.log(`📄 Found ${transformedData.dependencies.length} dependencies`);
@@ -274,7 +280,8 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
'MIT', 'MIT*', 'Apache-2.0', 'Apache License 2.0', 'BSD-2-Clause', 'BSD-3-Clause', 'BSD',
'ISC', 'CC0-1.0', 'Public Domain', 'Unlicense', '0BSD', 'BlueOak-1.0.0',
'Zlib', 'Artistic-2.0', 'Python-2.0', 'Ruby', 'MPL-2.0', 'CC-BY-4.0',
'SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE'
'SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE',
'SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE'
]);
// Helper function to normalize license names for comparison
+25 -16
View File
@@ -1,10 +1,12 @@
import React, { Suspense } from "react";
import { Suspense } from "react";
import { RainbowThemeProvider } from "./components/shared/RainbowThemeProvider";
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 { PreferencesProvider } from "./contexts/PreferencesContext";
import ErrorBoundary from "./components/shared/ErrorBoundary";
import HomePage from "./pages/HomePage";
@@ -14,6 +16,7 @@ import "./styles/cookieconsent.css";
import "./index.css";
import { RightRailProvider } from "./contexts/RightRailContext";
import { ViewerProvider } from "./contexts/ViewerContext";
import { SignatureProvider } from "./contexts/SignatureContext";
// Import file ID debugging helpers (development only)
import "./utils/fileIdSafety";
@@ -39,21 +42,27 @@ export default function App() {
<Suspense fallback={<LoadingFallback />}>
<RainbowThemeProvider>
<ErrorBoundary>
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<SidebarProvider>
<ViewerProvider>
<RightRailProvider>
<HomePage />
</RightRailProvider>
</ViewerProvider>
</SidebarProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</FileContextProvider>
<PreferencesProvider>
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<SignatureProvider>
<RightRailProvider>
<HomePage />
</RightRailProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</FileContextProvider>
</PreferencesProvider>
</ErrorBoundary>
</RainbowThemeProvider>
</Suspense>
File diff suppressed because it is too large Load Diff
+27 -2
View File
@@ -9,6 +9,8 @@ import MobileLayout from './fileManager/MobileLayout';
import DesktopLayout from './fileManager/DesktopLayout';
import DragOverlay from './fileManager/DragOverlay';
import { FileManagerProvider } from '../contexts/FileManagerContext';
import { isGoogleDriveConfigured } from '../services/googleDrivePickerService';
import { loadScript } from '../utils/scriptLoader';
interface FileManagerProps {
selectedTool?: Tool | null;
@@ -20,7 +22,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) => {
@@ -84,6 +86,29 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
};
}, []);
// Preload Google Drive scripts if configured
useEffect(() => {
if (isGoogleDriveConfigured()) {
// Load scripts in parallel without blocking
Promise.all([
loadScript({
src: 'https://apis.google.com/js/api.js',
id: 'gapi-script',
async: true,
defer: true,
}),
loadScript({
src: 'https://accounts.google.com/gsi/client',
id: 'gis-script',
async: true,
defer: true,
}),
]).catch((error) => {
console.warn('Failed to preload Google Drive scripts:', error);
});
}
}, []);
// Modal size constants for consistent scaling
const modalHeight = '80vh';
const modalWidth = isMobile ? '100%' : '80vw';
@@ -123,7 +148,6 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
onDrop={handleNewFileUpload}
onDragEnter={() => setIsDragging(true)}
onDragLeave={() => setIsDragging(false)}
accept={{}}
multiple={true}
activateOnClick={false}
style={{
@@ -147,6 +171,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
onFileRemove={handleRemoveFileByIndex}
modalHeight={modalHeight}
refreshRecentFiles={refreshRecentFiles}
isLoading={loading}
>
{isMobile ? <MobileLayout /> : <DesktopLayout />}
</FileManagerProvider>
@@ -0,0 +1,91 @@
import React, { createContext, useContext, ReactNode } from 'react';
interface PDFAnnotationContextValue {
// Drawing mode management
activateDrawMode: () => void;
deactivateDrawMode: () => void;
activateSignaturePlacementMode: () => void;
activateDeleteMode: () => void;
// Drawing settings
updateDrawSettings: (color: string, size: number) => void;
// History operations
undo: () => void;
redo: () => void;
// Image data management
storeImageData: (id: string, data: string) => void;
getImageData: (id: string) => string | undefined;
// Placement state
isPlacementMode: boolean;
// Signature configuration
signatureConfig: any | null;
setSignatureConfig: (config: any | null) => void;
}
const PDFAnnotationContext = createContext<PDFAnnotationContextValue | undefined>(undefined);
interface PDFAnnotationProviderProps {
children: ReactNode;
// These would come from the signature context
activateDrawMode: () => void;
deactivateDrawMode: () => void;
activateSignaturePlacementMode: () => void;
activateDeleteMode: () => void;
updateDrawSettings: (color: string, size: number) => void;
undo: () => void;
redo: () => void;
storeImageData: (id: string, data: string) => void;
getImageData: (id: string) => string | undefined;
isPlacementMode: boolean;
signatureConfig: any | null;
setSignatureConfig: (config: any | null) => void;
}
export const PDFAnnotationProvider: React.FC<PDFAnnotationProviderProps> = ({
children,
activateDrawMode,
deactivateDrawMode,
activateSignaturePlacementMode,
activateDeleteMode,
updateDrawSettings,
undo,
redo,
storeImageData,
getImageData,
isPlacementMode,
signatureConfig,
setSignatureConfig
}) => {
const contextValue: PDFAnnotationContextValue = {
activateDrawMode,
deactivateDrawMode,
activateSignaturePlacementMode,
activateDeleteMode,
updateDrawSettings,
undo,
redo,
storeImageData,
getImageData,
isPlacementMode,
signatureConfig,
setSignatureConfig
};
return (
<PDFAnnotationContext.Provider value={contextValue}>
{children}
</PDFAnnotationContext.Provider>
);
};
export const usePDFAnnotation = (): PDFAnnotationContextValue => {
const context = useContext(PDFAnnotationContext);
if (context === undefined) {
throw new Error('usePDFAnnotation must be used within a PDFAnnotationProvider');
}
return context;
};
@@ -0,0 +1,89 @@
import React, { useState } from 'react';
import { Stack, Alert, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { DrawingControls } from './DrawingControls';
import { ColorPicker } from './ColorPicker';
import { usePDFAnnotation } from '../providers/PDFAnnotationProvider';
export interface AnnotationToolConfig {
enableDrawing?: boolean;
enableImageUpload?: boolean;
enableTextInput?: boolean;
showPlaceButton?: boolean;
placeButtonText?: string;
}
interface BaseAnnotationToolProps {
config: AnnotationToolConfig;
children: React.ReactNode;
onSignatureDataChange?: (data: string | null) => void;
disabled?: boolean;
}
export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
config,
children,
onSignatureDataChange,
disabled = false
}) => {
const { t } = useTranslation();
const {
activateSignaturePlacementMode,
undo,
redo
} = usePDFAnnotation();
const [selectedColor, setSelectedColor] = useState('#000000');
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [signatureData, setSignatureData] = useState<string | null>(null);
const handleSignatureDataChange = (data: string | null) => {
setSignatureData(data);
onSignatureDataChange?.(data);
};
const handlePlaceSignature = () => {
if (activateSignaturePlacementMode) {
activateSignaturePlacementMode();
}
};
return (
<Stack gap="md">
{/* Drawing Controls (Undo/Redo/Place) */}
<DrawingControls
onUndo={undo}
onRedo={redo}
onPlaceSignature={config.showPlaceButton ? handlePlaceSignature : undefined}
hasSignatureData={!!signatureData}
disabled={disabled}
showPlaceButton={config.showPlaceButton}
placeButtonText={config.placeButtonText}
/>
{/* Tool Content */}
{React.cloneElement(children as React.ReactElement<any>, {
selectedColor,
signatureData,
onSignatureDataChange: handleSignatureDataChange,
onColorSwatchClick: () => setIsColorPickerOpen(true),
disabled
})}
{/* Instructions for placing signature */}
<Alert color="blue" title={t('sign.instructions.title', 'How to add signature')}>
<Text size="sm">
Click anywhere on the PDF to place your annotation.
</Text>
</Alert>
{/* Color Picker Modal */}
<ColorPicker
isOpen={isColorPickerOpen}
onClose={() => setIsColorPickerOpen(false)}
selectedColor={selectedColor}
onColorChange={setSelectedColor}
/>
</Stack>
);
};
@@ -0,0 +1,67 @@
import React from 'react';
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch } from '@mantine/core';
interface ColorPickerProps {
isOpen: boolean;
onClose: () => void;
selectedColor: string;
onColorChange: (color: string) => void;
title?: string;
}
export const ColorPicker: React.FC<ColorPickerProps> = ({
isOpen,
onClose,
selectedColor,
onColorChange,
title = "Choose Color"
}) => {
return (
<Modal
opened={isOpen}
onClose={onClose}
title={title}
size="sm"
centered
>
<Stack gap="md">
<MantineColorPicker
format="hex"
value={selectedColor}
onChange={onColorChange}
swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']}
swatchesPerRow={6}
size="lg"
fullWidth
/>
<Group justify="flex-end">
<Button onClick={onClose}>
Done
</Button>
</Group>
</Stack>
</Modal>
);
};
interface ColorSwatchButtonProps {
color: string;
onClick: () => void;
size?: number;
}
export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({
color,
onClick,
size = 24
}) => {
return (
<ColorSwatch
color={color}
size={size}
radius={0}
style={{ cursor: 'pointer' }}
onClick={onClick}
/>
);
};
@@ -0,0 +1,281 @@
import React, { useRef, useState } from 'react';
import { Paper, Button, Modal, Stack, Text, Popover, ColorPicker as MantineColorPicker } from '@mantine/core';
import { ColorSwatchButton } from './ColorPicker';
import PenSizeSelector from '../../tools/sign/PenSizeSelector';
import SignaturePad from 'signature_pad';
interface DrawingCanvasProps {
selectedColor: string;
penSize: number;
penSizeInput: string;
onColorSwatchClick: () => void;
onPenSizeChange: (size: number) => void;
onPenSizeInputChange: (input: string) => void;
onSignatureDataChange: (data: string | null) => void;
onDrawingComplete?: () => void;
disabled?: boolean;
width?: number;
height?: number;
modalWidth?: number;
modalHeight?: number;
additionalButtons?: React.ReactNode;
}
export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
selectedColor,
penSize,
penSizeInput,
onColorSwatchClick,
onPenSizeChange,
onPenSizeInputChange,
onSignatureDataChange,
onDrawingComplete,
disabled = false,
width = 400,
height = 150,
}) => {
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const modalCanvasRef = useRef<HTMLCanvasElement>(null);
const padRef = useRef<SignaturePad | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [colorPickerOpen, setColorPickerOpen] = useState(false);
const initPad = (canvas: HTMLCanvasElement) => {
if (!padRef.current) {
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
padRef.current = new SignaturePad(canvas, {
penColor: selectedColor,
minWidth: penSize * 0.5,
maxWidth: penSize * 2.5,
throttle: 10,
minDistance: 5,
velocityFilterWeight: 0.7,
});
}
};
const openModal = () => {
// Clear pad ref so it reinitializes
if (padRef.current) {
padRef.current.off();
padRef.current = null;
}
setModalOpen(true);
};
const trimCanvas = (canvas: HTMLCanvasElement): string => {
const ctx = canvas.getContext('2d');
if (!ctx) return canvas.toDataURL('image/png');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data;
let minX = canvas.width, minY = canvas.height, maxX = 0, maxY = 0;
// Find bounds of non-transparent pixels
for (let y = 0; y < canvas.height; y++) {
for (let x = 0; x < canvas.width; x++) {
const alpha = pixels[(y * canvas.width + x) * 4 + 3];
if (alpha > 0) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
const trimWidth = maxX - minX + 1;
const trimHeight = maxY - minY + 1;
// Create trimmed canvas
const trimmedCanvas = document.createElement('canvas');
trimmedCanvas.width = trimWidth;
trimmedCanvas.height = trimHeight;
const trimmedCtx = trimmedCanvas.getContext('2d');
if (trimmedCtx) {
trimmedCtx.drawImage(canvas, minX, minY, trimWidth, trimHeight, 0, 0, trimWidth, trimHeight);
}
return trimmedCanvas.toDataURL('image/png');
};
const closeModal = () => {
if (padRef.current && !padRef.current.isEmpty()) {
const canvas = modalCanvasRef.current;
if (canvas) {
const trimmedPng = trimCanvas(canvas);
onSignatureDataChange(trimmedPng);
// Update preview canvas with proper aspect ratio
const img = new Image();
img.onload = () => {
if (previewCanvasRef.current) {
const ctx = previewCanvasRef.current.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
// Calculate scaling to fit within preview canvas while maintaining aspect ratio
const scale = Math.min(
previewCanvasRef.current.width / img.width,
previewCanvasRef.current.height / img.height
);
const scaledWidth = img.width * scale;
const scaledHeight = img.height * scale;
const x = (previewCanvasRef.current.width - scaledWidth) / 2;
const y = (previewCanvasRef.current.height - scaledHeight) / 2;
ctx.drawImage(img, x, y, scaledWidth, scaledHeight);
}
}
};
img.src = trimmedPng;
if (onDrawingComplete) {
onDrawingComplete();
}
}
}
if (padRef.current) {
padRef.current.off();
padRef.current = null;
}
setModalOpen(false);
};
const clear = () => {
if (padRef.current) {
padRef.current.clear();
}
if (previewCanvasRef.current) {
const ctx = previewCanvasRef.current.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
}
}
onSignatureDataChange(null);
};
const updatePenColor = (color: string) => {
if (padRef.current) {
padRef.current.penColor = color;
}
};
const updatePenSize = (size: number) => {
if (padRef.current) {
padRef.current.minWidth = size * 0.8;
padRef.current.maxWidth = size * 1.2;
}
};
return (
<>
<Paper withBorder p="md">
<Stack gap="sm">
<Text fw={500}>Draw your signature</Text>
<canvas
ref={previewCanvasRef}
width={width}
height={height}
style={{
border: '1px solid #ccc',
borderRadius: '4px',
cursor: disabled ? 'default' : 'pointer',
backgroundColor: '#ffffff',
width: '100%',
}}
onClick={disabled ? undefined : openModal}
/>
<Text size="sm" c="dimmed" ta="center">
Click to open drawing canvas
</Text>
</Stack>
</Paper>
<Modal opened={modalOpen} onClose={closeModal} title="Draw Your Signature" size="auto" centered>
<Stack gap="md">
<div style={{ display: 'flex', gap: '20px', alignItems: 'flex-end' }}>
<div>
<Text size="sm" fw={500} mb="xs">Color</Text>
<Popover
opened={colorPickerOpen}
onChange={setColorPickerOpen}
position="bottom-start"
withArrow
withinPortal={false}
>
<Popover.Target>
<div>
<ColorSwatchButton
color={selectedColor}
onClick={() => setColorPickerOpen(!colorPickerOpen)}
/>
</div>
</Popover.Target>
<Popover.Dropdown>
<MantineColorPicker
format="hex"
value={selectedColor}
onChange={(color) => {
onColorSwatchClick();
updatePenColor(color);
}}
swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']}
/>
</Popover.Dropdown>
</Popover>
</div>
<div>
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
<PenSizeSelector
value={penSize}
inputValue={penSizeInput}
onValueChange={(size) => {
onPenSizeChange(size);
updatePenSize(size);
}}
onInputChange={onPenSizeInputChange}
placeholder="Size"
size="compact-sm"
style={{ width: '60px' }}
/>
</div>
</div>
<canvas
ref={(el) => {
modalCanvasRef.current = el;
if (el) initPad(el);
}}
style={{
border: '1px solid #ccc',
borderRadius: '4px',
display: 'block',
touchAction: 'none',
backgroundColor: 'white',
width: '100%',
maxWidth: '800px',
height: '400px',
cursor: 'crosshair',
}}
/>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Button variant="subtle" color="red" onClick={clear}>
Clear Canvas
</Button>
<Button onClick={closeModal}>
Done
</Button>
</div>
</Stack>
</Modal>
</>
);
};
export default DrawingCanvas;
@@ -0,0 +1,60 @@
import React from 'react';
import { Group, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
interface DrawingControlsProps {
onUndo?: () => void;
onRedo?: () => void;
onPlaceSignature?: () => void;
hasSignatureData?: boolean;
disabled?: boolean;
showPlaceButton?: boolean;
placeButtonText?: string;
}
export const DrawingControls: React.FC<DrawingControlsProps> = ({
onUndo,
onRedo,
onPlaceSignature,
hasSignatureData = false,
disabled = false,
showPlaceButton = true,
placeButtonText = "Update and Place"
}) => {
const { t } = useTranslation();
return (
<Group gap="sm">
{/* Undo/Redo Controls */}
<Button
variant="outline"
onClick={onUndo}
disabled={disabled}
flex={1}
>
{t('sign.undo', 'Undo')}
</Button>
<Button
variant="outline"
onClick={onRedo}
disabled={disabled}
flex={1}
>
{t('sign.redo', 'Redo')}
</Button>
{/* Place Signature Button */}
{showPlaceButton && onPlaceSignature && (
<Button
variant="filled"
color="blue"
onClick={onPlaceSignature}
disabled={disabled || !hasSignatureData}
flex={1}
>
{placeButtonText}
</Button>
)}
</Group>
);
};
@@ -0,0 +1,55 @@
import React from 'react';
import { FileInput, Text, Stack } from '@mantine/core';
import { useTranslation } from 'react-i18next';
interface ImageUploaderProps {
onImageChange: (file: File | null) => void;
disabled?: boolean;
label?: string;
placeholder?: string;
hint?: string;
}
export const ImageUploader: React.FC<ImageUploaderProps> = ({
onImageChange,
disabled = false,
label,
placeholder,
hint
}) => {
const { t } = useTranslation();
const handleImageChange = async (file: File | null) => {
if (file && !disabled) {
try {
// Validate that it's actually an image file
if (!file.type.startsWith('image/')) {
console.error('Selected file is not an image');
return;
}
onImageChange(file);
} catch (error) {
console.error('Error processing image file:', error);
}
} else if (!file) {
// Clear image data when no file is selected
onImageChange(null);
}
};
return (
<Stack gap="sm">
<FileInput
label={label || t('sign.image.label', 'Upload signature image')}
placeholder={placeholder || t('sign.image.placeholder', 'Select image file')}
accept="image/*"
onChange={handleImageChange}
disabled={disabled}
/>
<Text size="sm" c="dimmed">
{hint || t('sign.image.hint', 'Upload an image of your signature')}
</Text>
</Stack>
);
};
@@ -0,0 +1,172 @@
import React, { useState, useEffect } from 'react';
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ColorPicker } from './ColorPicker';
interface TextInputWithFontProps {
text: string;
onTextChange: (text: string) => void;
fontSize: number;
onFontSizeChange: (size: number) => void;
fontFamily: string;
onFontFamilyChange: (family: string) => void;
textColor?: string;
onTextColorChange?: (color: string) => void;
disabled?: boolean;
label?: string;
placeholder?: string;
}
export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
text,
onTextChange,
fontSize,
onFontSizeChange,
fontFamily,
onFontFamilyChange,
textColor = '#000000',
onTextColorChange,
disabled = false,
label,
placeholder
}) => {
const { t } = useTranslation();
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
const fontSizeCombobox = useCombobox();
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
// Sync font size input with prop changes
useEffect(() => {
setFontSizeInput(fontSize.toString());
}, [fontSize]);
const fontOptions = [
{ value: 'Helvetica', label: 'Helvetica' },
{ value: 'Times-Roman', label: 'Times' },
{ value: 'Courier', label: 'Courier' },
{ value: 'Arial', label: 'Arial' },
{ value: 'Georgia', label: 'Georgia' },
];
const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '96', '112', '128', '144', '160', '176', '192', '200'];
return (
<Stack gap="sm">
<TextInput
label={label || t('sign.text.name', 'Signer Name')}
placeholder={placeholder || t('sign.text.placeholder', 'Enter your full name')}
value={text}
onChange={(e) => onTextChange(e.target.value)}
disabled={disabled}
required
/>
{/* Font Selection */}
<Select
label="Font"
value={fontFamily}
onChange={(value) => onFontFamilyChange(value || 'Helvetica')}
data={fontOptions}
disabled={disabled}
searchable
allowDeselect={false}
/>
{/* Font Size and Color */}
<Group grow>
<Combobox
onOptionSubmit={(optionValue) => {
setFontSizeInput(optionValue);
const size = parseInt(optionValue);
if (!isNaN(size)) {
onFontSizeChange(size);
}
fontSizeCombobox.closeDropdown();
}}
store={fontSizeCombobox}
withinPortal={false}
>
<Combobox.Target>
<TextInput
label="Font Size"
placeholder="Type or select font size (8-200)"
value={fontSizeInput}
onChange={(event) => {
const value = event.currentTarget.value;
setFontSizeInput(value);
// Parse and validate the typed value in real-time
const size = parseInt(value);
if (!isNaN(size) && size >= 8 && size <= 200) {
onFontSizeChange(size);
}
fontSizeCombobox.openDropdown();
fontSizeCombobox.updateSelectedOptionIndex();
}}
onClick={() => fontSizeCombobox.openDropdown()}
onFocus={() => fontSizeCombobox.openDropdown()}
onBlur={() => {
fontSizeCombobox.closeDropdown();
// Clean up invalid values on blur
const size = parseInt(fontSizeInput);
if (isNaN(size) || size < 8 || size > 200) {
setFontSizeInput(fontSize.toString());
} else {
onFontSizeChange(size);
}
}}
disabled={disabled}
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
{fontSizeOptions.map((size) => (
<Combobox.Option value={size} key={size}>
{size}px
</Combobox.Option>
))}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
{/* Text Color Picker */}
{onTextColorChange && (
<Box>
<TextInput
label="Text Color"
value={textColor}
readOnly
disabled={disabled}
onClick={() => !disabled && setIsColorPickerOpen(true)}
style={{ cursor: disabled ? 'default' : 'pointer' }}
rightSection={
<Box
style={{
width: 24,
height: 24,
backgroundColor: textColor,
border: '1px solid #ccc',
borderRadius: 4,
cursor: disabled ? 'default' : 'pointer'
}}
/>
}
/>
</Box>
)}
</Group>
{/* Color Picker Modal */}
{onTextColorChange && (
<ColorPicker
isOpen={isColorPickerOpen}
onClose={() => setIsColorPickerOpen(false)}
selectedColor={textColor}
onColorChange={onTextColorChange}
/>
)}
</Stack>
);
};
@@ -0,0 +1,45 @@
import React, { useState } from 'react';
import { Stack } from '@mantine/core';
import { BaseAnnotationTool } from '../shared/BaseAnnotationTool';
import { DrawingCanvas } from '../shared/DrawingCanvas';
interface DrawingToolProps {
onDrawingChange?: (data: string | null) => void;
disabled?: boolean;
}
export const DrawingTool: React.FC<DrawingToolProps> = ({
onDrawingChange,
disabled = false
}) => {
const [selectedColor] = useState('#000000');
const [penSize, setPenSize] = useState(2);
const [penSizeInput, setPenSizeInput] = useState('2');
const toolConfig = {
enableDrawing: true,
showPlaceButton: true,
placeButtonText: "Place Drawing"
};
return (
<BaseAnnotationTool
config={toolConfig}
onSignatureDataChange={onDrawingChange}
disabled={disabled}
>
<Stack gap="sm">
<DrawingCanvas
selectedColor={selectedColor}
penSize={penSize}
penSizeInput={penSizeInput}
onColorSwatchClick={() => {}} // Color picker handled by BaseAnnotationTool
onPenSizeChange={setPenSize}
onPenSizeInputChange={setPenSizeInput}
onSignatureDataChange={onDrawingChange || (() => {})}
disabled={disabled}
/>
</Stack>
</BaseAnnotationTool>
);
};
@@ -0,0 +1,67 @@
import React, { useState } from 'react';
import { Stack } from '@mantine/core';
import { BaseAnnotationTool } from '../shared/BaseAnnotationTool';
import { ImageUploader } from '../shared/ImageUploader';
interface ImageToolProps {
onImageChange?: (data: string | null) => void;
disabled?: boolean;
}
export const ImageTool: React.FC<ImageToolProps> = ({
onImageChange,
disabled = false
}) => {
const [, setImageData] = useState<string | null>(null);
const handleImageUpload = async (file: File | null) => {
if (file && !disabled) {
try {
const result = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
if (e.target?.result) {
resolve(e.target.result as string);
} else {
reject(new Error('Failed to read file'));
}
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
setImageData(result);
onImageChange?.(result);
} catch (error) {
console.error('Error reading file:', error);
}
} else if (!file) {
setImageData(null);
onImageChange?.(null);
}
};
const toolConfig = {
enableImageUpload: true,
showPlaceButton: true,
placeButtonText: "Place Image"
};
return (
<BaseAnnotationTool
config={toolConfig}
onSignatureDataChange={onImageChange}
disabled={disabled}
>
<Stack gap="sm">
<ImageUploader
onImageChange={handleImageUpload}
disabled={disabled}
label="Upload Image"
placeholder="Select image file"
hint="Upload a PNG, JPG, or other image file to place on the PDF"
/>
</Stack>
</BaseAnnotationTool>
);
};
@@ -0,0 +1,57 @@
import React, { useState } from 'react';
import { Stack } from '@mantine/core';
import { BaseAnnotationTool } from '../shared/BaseAnnotationTool';
import { TextInputWithFont } from '../shared/TextInputWithFont';
interface TextToolProps {
onTextChange?: (text: string) => void;
disabled?: boolean;
}
export const TextTool: React.FC<TextToolProps> = ({
onTextChange,
disabled = false
}) => {
const [text, setText] = useState('');
const [fontSize, setFontSize] = useState(16);
const [fontFamily, setFontFamily] = useState('Helvetica');
const handleTextChange = (newText: string) => {
setText(newText);
onTextChange?.(newText);
};
const handleSignatureDataChange = (data: string | null) => {
if (data) {
onTextChange?.(data);
}
};
const toolConfig = {
enableTextInput: true,
showPlaceButton: true,
placeButtonText: "Place Text"
};
return (
<BaseAnnotationTool
config={toolConfig}
onSignatureDataChange={handleSignatureDataChange}
disabled={disabled}
>
<Stack gap="sm">
<TextInputWithFont
text={text}
onTextChange={handleTextChange}
fontSize={fontSize}
onFontSizeChange={setFontSize}
fontFamily={fontFamily}
onFontFamilyChange={setFontFamily}
disabled={disabled}
label="Text Content"
placeholder="Enter text to place on the PDF"
/>
</Stack>
</BaseAnnotationTool>
);
};
@@ -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;
@@ -34,9 +34,9 @@
.header {
height: 2.25rem;
border-radius: 0.0625rem 0.0625rem 0 0;
display: grid;
grid-template-columns: 44px 1fr 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 6px;
user-select: none;
background: var(--bg-toolbar);
@@ -56,6 +56,20 @@
border-bottom: 1px solid var(--header-selected-bg);
}
/* Error highlight (transient) */
.headerError {
background: var(--color-red-200);
color: var(--text-primary);
border-bottom: 2px solid var(--color-red-500);
}
/* Unsupported (but not errored) header appearance */
.headerUnsupported {
background: var(--unsupported-bar-bg); /* neutral gray */
color: #FFFFFF;
border-bottom: 1px solid var(--unsupported-bar-border);
}
/* Selected border color in light mode */
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
outline-color: var(--card-selected-border);
@@ -72,14 +86,24 @@
}
.headerIndex {
position: absolute;
left: 50%;
transform: translateX(-50%);
text-align: center;
font-weight: 500;
font-size: 18px;
letter-spacing: 0.04em;
}
.kebab {
justify-self: end;
.headerActions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.headerIconButton {
color: #FFFFFF !important;
}
/* Menu dropdown */
@@ -201,6 +225,11 @@
color: rgba(0, 0, 0, 0.35);
}
.pinned {
color: #FFC107 !important;
}
/* Unsupported file indicator */
.unsupportedPill {
margin-left: 1.75rem;
@@ -217,6 +246,22 @@
height: 20px;
}
/* Error pill shown when a file failed processing */
.errorPill {
margin-left: 1.75rem;
background: var(--color-red-500);
color: #ffffff;
padding: 4px 8px;
border-radius: 12px;
font-size: 10px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
min-width: 56px;
height: 20px;
}
/* Animations */
@keyframes pulse {
0%, 100% {
@@ -273,4 +318,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;
}
@@ -1,16 +1,18 @@
import React, { useState, useCallback, useRef, useMemo } from 'react';
import {
Text, Center, Box, Notification, LoadingOverlay, Stack, Group, Portal
Text, Center, Box, LoadingOverlay, Stack, Group
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useFileSelection, useFileState, useFileManagement } from '../../contexts/FileContext';
import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext';
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';
import { alert } from '../toast';
import { downloadBlob } from '../../utils/downloadUtils';
@@ -35,6 +37,7 @@ const FileEditor = ({
// Use optimized FileContext hooks
const { state, selectors } = useFileState();
const { addFiles, removeFiles, reorderFiles } = useFileManagement();
const { actions } = useFileActions();
// Extract needed values from state (memoized to prevent infinite loops)
const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]);
@@ -46,8 +49,16 @@ const FileEditor = ({
// Get file selection context
const { setSelectedFiles } = useFileSelection();
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [_status, _setStatus] = useState<string | null>(null);
const [_error, _setError] = useState<string | null>(null);
// Toast helpers
const showStatus = useCallback((message: string, type: 'neutral' | 'success' | 'warning' | 'error' = 'neutral') => {
alert({ alertType: type, title: message, expandable: false, durationMs: 4000 });
}, []);
const showError = useCallback((message: string) => {
alert({ alertType: 'error', title: 'Error', body: message, expandable: true });
}, []);
const [selectionMode, setSelectionMode] = useState(toolMode);
// Enable selection mode automatically in tool mode
@@ -82,7 +93,7 @@ const FileEditor = ({
// Process uploaded files using context
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
setError(null);
_setError(null);
try {
const allExtractedFiles: File[] = [];
@@ -157,18 +168,18 @@ const FileEditor = ({
// Show any errors
if (errors.length > 0) {
setError(errors.join('\n'));
showError(errors.join('\n'));
}
// Process all extracted files
if (allExtractedFiles.length > 0) {
// Add files to context (they will be processed automatically)
await addFiles(allExtractedFiles);
setStatus(`Added ${allExtractedFiles.length} files`);
// Add files to context and select them automatically
await addFiles(allExtractedFiles, { selectFiles: true });
showStatus(`Added ${allExtractedFiles.length} files`, 'success');
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
setError(errorMessage);
showError(errorMessage);
console.error('File processing error:', err);
// Reset extraction progress on error
@@ -206,7 +217,7 @@ const FileEditor = ({
} else {
// Check if we've hit the selection limit
if (maxAllowed > 1 && currentSelectedIds.length >= maxAllowed) {
setStatus(`Maximum ${maxAllowed} files can be selected`);
showStatus(`Maximum ${maxAllowed} files can be selected`, 'warning');
return;
}
newSelection = [...currentSelectedIds, contextFileId];
@@ -215,7 +226,7 @@ const FileEditor = ({
// Update context (this automatically updates tool selection since they use the same action)
setSelectedFiles(newSelection);
}, [setSelectedFiles, toolMode, setStatus, activeStirlingFileStubs]);
}, [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs]);
// File reordering handler for drag and drop
@@ -271,13 +282,13 @@ const FileEditor = ({
// Update status
const moveCount = filesToMove.length;
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
}, [activeStirlingFileStubs, reorderFiles, setStatus]);
showStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
}, [activeStirlingFileStubs, reorderFiles, _setStatus]);
// File operations using context
const handleDeleteFile = useCallback((fileId: FileId) => {
const handleCloseFile = useCallback((fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
if (record && file) {
@@ -297,7 +308,49 @@ const FileEditor = ({
if (record && file) {
downloadBlob(file, file.name);
}
}, [activeStirlingFileStubs, selectors, setStatus]);
}, [activeStirlingFileStubs, selectors, _setStatus]);
const handleUnzipFile = useCallback(async (fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
if (record && file) {
try {
// Extract and store files using shared service method
const result = await zipFileService.extractAndStoreFilesWithHistory(file, record);
if (result.success && result.extractedStubs.length > 0) {
// Add extracted file stubs to FileContext
await actions.addStirlingFileStubs(result.extractedStubs);
// Remove the original ZIP file
removeFiles([fileId], false);
alert({
alertType: 'success',
title: `Extracted ${result.extractedStubs.length} file(s) from ${file.name}`,
expandable: false,
durationMs: 3500
});
} else {
alert({
alertType: 'error',
title: `Failed to extract files from ${file.name}`,
body: result.errors.join('\n'),
expandable: true,
durationMs: 3500
});
}
} catch (error) {
console.error('Failed to unzip file:', error);
alert({
alertType: 'error',
title: `Error unzipping ${file.name}`,
expandable: false,
durationMs: 3500
});
}
}
}, [activeStirlingFileStubs, selectors, actions, removeFiles]);
const handleViewFile = useCallback((fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
@@ -314,10 +367,10 @@ const FileEditor = ({
try {
// Use FileContext to handle loading stored files
// The files are already in FileContext, just need to add them to active files
setStatus(`Loaded ${selectedFiles.length} files from storage`);
showStatus(`Loaded ${selectedFiles.length} files from storage`);
} catch (err) {
console.error('Error loading files from storage:', err);
setError('Failed to load some files from storage');
showError('Failed to load some files from storage');
}
}, []);
@@ -338,7 +391,7 @@ const FileEditor = ({
<Box pos="relative" style={{ overflow: 'auto' }}>
<LoadingOverlay visible={false} />
<Box p="md" pt="xl">
<Box p="md">
{activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? (
@@ -396,6 +449,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
@@ -406,11 +467,12 @@ const FileEditor = ({
selectedFiles={localSelectedIds}
selectionMode={selectionMode}
onToggleFile={toggleFile}
onDeleteFile={handleDeleteFile}
onCloseFile={handleCloseFile}
onViewFile={handleViewFile}
onSetStatus={setStatus}
_onSetStatus={showStatus}
onReorderFiles={handleReorderFiles}
onDownloadFile={handleDownloadFile}
onUnzipFile={handleUnzipFile}
toolMode={toolMode}
isSupported={isFileSupported(record.name)}
/>
@@ -428,31 +490,7 @@ const FileEditor = ({
onSelectFiles={handleLoadFromStorage}
/>
{status && (
<Portal>
<Notification
color="blue"
mt="md"
onClose={() => setStatus(null)}
style={{ position: 'fixed', bottom: 40, right: 80, zIndex: 10001 }}
>
{status}
</Notification>
</Portal>
)}
{error && (
<Portal>
<Notification
color="red"
mt="md"
onClose={() => setError(null)}
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 10001 }}
>
{error}
</Notification>
</Portal>
)}
</Box>
</Dropzone>
);
@@ -1,17 +1,21 @@
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core';
import { Text, ActionIcon, CheckboxIndicator, Tooltip } from '@mantine/core';
import { alert } from '../toast';
import { useTranslation } from 'react-i18next';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import CloseIcon from '@mui/icons-material/Close';
import UnarchiveIcon from '@mui/icons-material/Unarchive';
import PushPinIcon from '@mui/icons-material/PushPin';
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { StirlingFileStub } from '../../types/fileContext';
import { zipFileService } from '../../services/zipFileService';
import styles from './FileEditor.module.css';
import { useFileContext } from '../../contexts/FileContext';
import { useFileState } from '../../contexts/file/fileHooks';
import { FileId } from '../../types/file';
import { formatFileSize } from '../../utils/fileUtils';
import ToolChain from '../shared/ToolChain';
@@ -25,11 +29,12 @@ interface FileEditorThumbnailProps {
selectedFiles: FileId[];
selectionMode: boolean;
onToggleFile: (fileId: FileId) => void;
onDeleteFile: (fileId: FileId) => void;
onCloseFile: (fileId: FileId) => void;
onViewFile: (fileId: FileId) => void;
onSetStatus: (status: string) => void;
_onSetStatus: (status: string) => void;
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
onDownloadFile: (fileId: FileId) => void;
onUnzipFile?: (fileId: FileId) => void;
toolMode?: boolean;
isSupported?: boolean;
}
@@ -39,14 +44,17 @@ const FileEditorThumbnail = ({
index,
selectedFiles,
onToggleFile,
onDeleteFile,
onSetStatus,
onCloseFile,
_onSetStatus,
onReorderFiles,
onDownloadFile,
onUnzipFile,
isSupported = true,
}: FileEditorThumbnailProps) => {
const { t } = useTranslation();
const { pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions } = useFileContext();
const { state } = useFileState();
const hasError = state.ui.errorFileIds.includes(file.id);
// ---- Drag state ----
const [isDragging, setIsDragging] = useState(false);
@@ -60,6 +68,9 @@ const FileEditorThumbnail = ({
}, [activeFiles, file.id]);
const isPinned = actualFile ? isFilePinned(actualFile) : false;
// Check if this is a ZIP file
const isZipFile = zipFileService.isZipFileStub(file);
const pageCount = file.processedFile?.totalPages || 0;
const handleRef = useRef<HTMLSpanElement | null>(null);
@@ -187,9 +198,20 @@ const FileEditorThumbnail = ({
// ---- Card interactions ----
const handleCardClick = () => {
if (!isSupported) return;
// Clear error state if file has an error (click to clear error)
if (hasError) {
try { fileActions.clearFileError(file.id); } catch (_e) { void _e; }
}
onToggleFile(file.id);
};
// ---- Style helpers ----
const getHeaderClassName = () => {
if (hasError) return styles.headerError;
if (!isSupported) return styles.headerUnsupported;
return isSelected ? styles.headerSelected : styles.headerResting;
};
return (
<div
@@ -199,10 +221,7 @@ const FileEditorThumbnail = ({
data-selected={isSelected}
data-supported={isSupported}
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
style={{
opacity: isSupported ? (isDragging ? 0.9 : 1) : 0.5,
filter: isSupported ? 'none' : 'grayscale(50%)',
}}
style={{opacity: isDragging ? 0.9 : 1}}
tabIndex={0}
role="listitem"
aria-selected={isSelected}
@@ -210,13 +229,16 @@ const FileEditorThumbnail = ({
>
{/* Header bar */}
<div
className={`${styles.header} ${
isSelected ? styles.headerSelected : styles.headerResting
}`}
className={`${styles.header} ${getHeaderClassName()}`}
data-has-error={hasError}
>
{/* Logo/checkbox area */}
<div className={styles.logoMark}>
{isSupported ? (
{hasError ? (
<div className={styles.errorPill}>
<span>{t('error._value', 'Error')}</span>
</div>
) : isSupported ? (
<CheckboxIndicator
checked={isSelected}
onChange={() => onToggleFile(file.id)}
@@ -236,18 +258,60 @@ const FileEditorThumbnail = ({
{index + 1}
</div>
{/* Kebab menu */}
<ActionIcon
aria-label={t('moreOptions', 'More options')}
variant="subtle"
className={styles.kebab}
onClick={(e) => {
e.stopPropagation();
setShowActions((v) => !v);
}}
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
{/* Action buttons group */}
<div className={styles.headerActions}>
{/* Pin/Unpin icon */}
<Tooltip label={isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}>
<ActionIcon
aria-label={isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}
variant="subtle"
className={isPinned ? styles.pinned : styles.headerIconButton}
onClick={(e) => {
e.stopPropagation();
if (actualFile) {
if (isPinned) {
unpinFile(actualFile);
alert({ alertType: 'neutral', title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
} else {
pinFile(actualFile);
alert({ alertType: 'success', title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
}
}
}}
>
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
</ActionIcon>
</Tooltip>
{/* Download icon */}
<Tooltip label={t('download', 'Download')}>
<ActionIcon
aria-label={t('download', 'Download')}
variant="subtle"
className={styles.headerIconButton}
onClick={(e) => {
e.stopPropagation();
onDownloadFile(file.id);
alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 });
}}
>
<DownloadOutlinedIcon fontSize="small" />
</ActionIcon>
</Tooltip>
{/* Kebab menu */}
<ActionIcon
aria-label={t('moreOptions', 'More options')}
variant="subtle"
className={styles.headerIconButton}
onClick={(e) => {
e.stopPropagation();
setShowActions((v) => !v);
}}
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</div>
</div>
{/* Actions overlay */}
@@ -263,39 +327,49 @@ const FileEditorThumbnail = ({
if (actualFile) {
if (isPinned) {
unpinFile(actualFile);
onSetStatus?.(`Unpinned ${file.name}`);
alert({ alertType: 'neutral', title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
} else {
pinFile(actualFile);
onSetStatus?.(`Pinned ${file.name}`);
alert({ alertType: 'success', title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
}
}
setShowActions(false);
}}
>
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
{isPinned ? <PushPinIcon className={styles.pinned} fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
<span>{isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}</span>
</button>
<button
className={styles.actionRow}
onClick={() => { onDownloadFile(file.id); setShowActions(false); }}
onClick={() => { onDownloadFile(file.id); alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 }); setShowActions(false); }}
>
<DownloadOutlinedIcon fontSize="small" />
<span>{t('download', 'Download')}</span>
</button>
{isZipFile && onUnzipFile && (
<button
className={styles.actionRow}
onClick={() => { onUnzipFile(file.id); alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 }); setShowActions(false); }}
>
<UnarchiveIcon fontSize="small" />
<span>{t('fileManager.unzip', 'Unzip')}</span>
</button>
)}
<div className={styles.actionsDivider} />
<button
className={`${styles.actionRow} ${styles.actionDanger}`}
onClick={() => {
onDeleteFile(file.id);
onSetStatus(`Deleted ${file.name}`);
onCloseFile(file.id);
alert({ alertType: 'neutral', title: `Closed ${file.name}`, expandable: false, durationMs: 3500 });
setShowActions(false);
}}
>
<DeleteOutlineIcon fontSize="small" />
<span>{t('delete', 'Delete')}</span>
<CloseIcon fontSize="small" />
<span>{t('close', 'Close')}</span>
</button>
</div>
)}
@@ -309,7 +383,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
@@ -328,10 +402,14 @@ const FileEditorThumbnail = ({
</div>
{/* Preview area */}
<div className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}>
<div
className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}
style={isSupported || hasError ? undefined : { filter: 'grayscale(80%)', opacity: 0.6 }}
>
<div className={styles.previewPaper}>
{file.thumbnailUrl && (
<img
className="ph-no-capture"
src={file.thumbnailUrl}
alt={file.name}
draggable={false}
@@ -358,13 +436,6 @@ const FileEditorThumbnail = ({
)}
</div>
{/* Pin indicator (bottom-left) */}
{isPinned && (
<span className={styles.pinIndicator} aria-hidden>
<PushPinIcon fontSize="small" />
</span>
)}
{/* Drag handle (span wrapper so we can attach a ref reliably) */}
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
<DragIndicatorIcon fontSize="small" />
@@ -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) => {
@@ -5,10 +5,12 @@ import DeleteIcon from '@mui/icons-material/Delete';
import DownloadIcon from '@mui/icons-material/Download';
import HistoryIcon from '@mui/icons-material/History';
import RestoreIcon from '@mui/icons-material/Restore';
import UnarchiveIcon from '@mui/icons-material/Unarchive';
import { useTranslation } from 'react-i18next';
import { getFileSize, getFileDate } from '../../utils/fileUtils';
import { FileId, StirlingFileStub } from '../../types/fileContext';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
import { zipFileService } from '../../services/zipFileService';
import ToolChain from '../shared/ToolChain';
interface FileListItemProps {
@@ -38,7 +40,10 @@ const FileListItem: React.FC<FileListItemProps> = ({
const [isHovered, setIsHovered] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { t } = useTranslation();
const {expandedFileIds, onToggleExpansion, onAddToRecents } = useFileManagerContext();
const {expandedFileIds, onToggleExpansion, onAddToRecents, onUnzipFile } = useFileManagerContext();
// Check if this is a ZIP file
const isZipFile = zipFileService.isZipFileStub(file);
// Keep item in hovered state if menu is open
const shouldShowHovered = isHovered || isMenuOpen;
@@ -93,7 +98,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>
@@ -192,6 +197,22 @@ const FileListItem: React.FC<FileListItemProps> = ({
</>
)}
{/* Unzip option for ZIP files */}
{isZipFile && !isHistoryFile && (
<>
<Menu.Item
leftSection={<UnarchiveIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
e.stopPropagation();
onUnzipFile(file);
}}
>
{t('fileManager.unzip', 'Unzip')}
</Menu.Item>
<Menu.Divider />
</>
)}
<Menu.Item
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
onClick={(e) => {
@@ -5,6 +5,7 @@ import UploadIcon from '@mui/icons-material/Upload';
import CloudIcon from '@mui/icons-material/Cloud';
import { useTranslation } from 'react-i18next';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
import { useGoogleDrivePicker } from '../../hooks/useGoogleDrivePicker';
interface FileSourceButtonsProps {
horizontal?: boolean;
@@ -13,8 +14,20 @@ interface FileSourceButtonsProps {
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
horizontal = false
}) => {
const { activeSource, onSourceChange, onLocalFileClick } = useFileManagerContext();
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect } = useFileManagerContext();
const { t } = useTranslation();
const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker();
const handleGoogleDriveClick = async () => {
try {
const files = await openGoogleDrivePicker({ multiple: true });
if (files.length > 0) {
onGoogleDriveSelect(files);
}
} catch (error) {
console.error('Failed to pick files from Google Drive:', error);
}
};
const buttonProps = {
variant: (source: string) => activeSource === source ? 'filled' : 'subtle',
@@ -67,15 +80,24 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
</Button>
<Button
variant={buttonProps.variant('drive')}
variant="subtle"
color='var(--mantine-color-gray-6)'
leftSection={<CloudIcon />}
justify={horizontal ? "center" : "flex-start"}
onClick={() => onSourceChange('drive')}
onClick={handleGoogleDriveClick}
fullWidth={!horizontal}
size={horizontal ? "xs" : "sm"}
disabled
color={activeSource === 'drive' ? 'gray' : undefined}
styles={buttonProps.getStyles('drive')}
disabled={!isGoogleDriveEnabled}
styles={{
root: {
backgroundColor: 'transparent',
border: 'none',
'&:hover': {
backgroundColor: isGoogleDriveEnabled ? 'var(--mantine-color-gray-0)' : 'transparent'
}
}
}}
title={!isGoogleDriveEnabled ? t('fileManager.googleDriveNotAvailable', 'Google Drive integration not available') : undefined}
>
{horizontal ? t('fileManager.googleDriveShort', 'Drive') : t('fileManager.googleDrive', 'Google Drive')}
</Button>
@@ -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;
+13 -8
View File
@@ -4,7 +4,6 @@ import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import { useFileHandler } from '../../hooks/useFileHandler';
import { useFileState } from '../../contexts/FileContext';
import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext';
import { useToolManagement } from '../../hooks/useToolManagement';
import './Workbench.css';
import TopControls from '../shared/TopControls';
@@ -14,6 +13,7 @@ import PageEditorControls from '../pageEditor/PageEditorControls';
import Viewer from '../viewer/Viewer';
import LandingPage from '../shared/LandingPage';
import Footer from '../shared/Footer';
import DismissAllErrorsButton from '../shared/DismissAllErrorsButton';
// No props needed - component uses contexts directly
export default function Workbench() {
@@ -39,8 +39,8 @@ export default function Workbench() {
// Get navigation state - this is the source of truth
const { selectedTool: selectedToolId } = useNavigationState();
// Get tool registry to look up selected tool
const { toolRegistry } = useToolManagement();
// Get tool registry from context (instead of direct hook call)
const { toolRegistry } = useToolWorkflow();
const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null;
const { addFiles } = useFileHandler();
@@ -146,17 +146,22 @@ export default function Workbench() {
}
>
{/* Top Controls */}
<TopControls
currentView={currentView}
setCurrentView={setCurrentView}
selectedToolKey={selectedToolId}
/>
{activeFiles.length > 0 && (
<TopControls
currentView={currentView}
setCurrentView={setCurrentView}
/>
)}
{/* Dismiss All Errors Button */}
<DismissAllErrorsButton />
{/* Main content area */}
<Box
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
style={{
transition: 'opacity 0.15s ease-in-out',
paddingTop: activeFiles.length > 0 ? '3.5rem' : '0',
}}
>
{renderMainContent()}
@@ -317,6 +317,7 @@ const FileThumbnail = ({
>
{file.thumbnail && (
<img
className="ph-no-capture"
src={file.thumbnail}
alt={file.name}
draggable={false}
@@ -1,9 +1,12 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
import { useFileState, useFileActions } from "../../contexts/FileContext";
import { useNavigationGuard } from "../../contexts/NavigationContext";
import { PDFDocument, PageEditorFunctions } from "../../types/pageEditor";
import { pdfExportService } from "../../services/pdfExportService";
import { documentManipulationService } from "../../services/documentManipulationService";
import { exportProcessedDocumentsToFiles } from "../../services/pdfExportHelpers";
import { createStirlingFilesAndStubs } from "../../services/fileStubHelpers";
// Thumbnail generation is now handled by individual PageThumbnail components
import './PageEditor.module.css';
import PageThumbnail from './PageThumbnail';
@@ -36,6 +39,9 @@ const PageEditor = ({
const { state, selectors } = useFileState();
const { actions } = useFileActions();
// Navigation guard for unsaved changes
const { setHasUnsavedChanges } = useNavigationGuard();
// Prefer IDs + selectors to avoid array identity churn
const activeFileIds = state.files.ids;
@@ -82,6 +88,12 @@ const PageEditor = ({
updateUndoRedoState();
}, [updateUndoRedoState]);
// Wrapper for executeCommand to track unsaved changes
const executeCommandWithTracking = useCallback((command: any) => {
undoManagerRef.current.executeCommand(command);
setHasUnsavedChanges(true);
}, [setHasUnsavedChanges]);
// Watch for container size changes to update split line positions
useEffect(() => {
const container = gridContainerRef.current;
@@ -138,17 +150,16 @@ const PageEditor = ({
// DOM-first command handlers
const handleRotatePages = useCallback((pageIds: string[], rotation: number) => {
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
undoManagerRef.current.executeCommand(bulkRotateCommand);
}, []);
executeCommandWithTracking(bulkRotateCommand);
}, [executeCommandWithTracking]);
// Command factory functions for PageThumbnail
const createRotateCommand = useCallback((pageIds: string[], rotation: number) => ({
execute: () => {
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
undoManagerRef.current.executeCommand(bulkRotateCommand);
executeCommandWithTracking(bulkRotateCommand);
}
}), []);
}), [executeCommandWithTracking]);
const createDeleteCommand = useCallback((pageIds: string[]) => ({
execute: () => {
@@ -174,10 +185,10 @@ const PageEditor = ({
() => getPageNumbersFromIds(selectedPageIds),
closePdf
);
undoManagerRef.current.executeCommand(deleteCommand);
executeCommandWithTracking(deleteCommand);
}
}
}), [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds]);
}), [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds, executeCommandWithTracking]);
const createSplitCommand = useCallback((position: number) => ({
execute: () => {
@@ -186,9 +197,9 @@ const PageEditor = ({
() => splitPositions,
setSplitPositions
);
undoManagerRef.current.executeCommand(splitCommand);
executeCommandWithTracking(splitCommand);
}
}), [splitPositions]);
}), [splitPositions, executeCommandWithTracking]);
// Command executor for PageThumbnail
const executeCommand = useCallback((command: any) => {
@@ -232,8 +243,8 @@ const PageEditor = ({
() => selectedPageNumbers,
closePdf
);
undoManagerRef.current.executeCommand(deleteCommand);
}, [selectedPageIds, displayDocument, splitPositions, getPageNumbersFromIds, getPageIdsFromNumbers]);
executeCommandWithTracking(deleteCommand);
}, [selectedPageIds, displayDocument, splitPositions, getPageNumbersFromIds, getPageIdsFromNumbers, executeCommandWithTracking]);
const handleDeletePage = useCallback((pageNumber: number) => {
if (!displayDocument) return;
@@ -251,8 +262,8 @@ const PageEditor = ({
() => getPageNumbersFromIds(selectedPageIds),
closePdf
);
undoManagerRef.current.executeCommand(deleteCommand);
}, [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds]);
executeCommandWithTracking(deleteCommand);
}, [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds, executeCommandWithTracking]);
const handleSplit = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
@@ -298,8 +309,8 @@ const PageEditor = ({
: `Add ${selectedPositions.length - existingSplitsCount} split(s)`
};
undoManagerRef.current.executeCommand(smartSplitCommand);
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds]);
executeCommandWithTracking(smartSplitCommand);
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]);
const handleSplitAll = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
@@ -344,8 +355,8 @@ const PageEditor = ({
: `Add ${selectedPositions.length - existingSplitsCount} split(s)`
};
undoManagerRef.current.executeCommand(smartSplitCommand);
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds]);
executeCommandWithTracking(smartSplitCommand);
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]);
const handlePageBreak = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
@@ -358,8 +369,8 @@ const PageEditor = ({
() => displayDocument,
setEditedDocument
);
undoManagerRef.current.executeCommand(pageBreakCommand);
}, [selectedPageIds, displayDocument, getPageNumbersFromIds]);
executeCommandWithTracking(pageBreakCommand);
}, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
const handlePageBreakAll = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
@@ -372,8 +383,8 @@ const PageEditor = ({
() => displayDocument,
setEditedDocument
);
undoManagerRef.current.executeCommand(pageBreakCommand);
}, [selectedPageIds, displayDocument, getPageNumbersFromIds]);
executeCommandWithTracking(pageBreakCommand);
}, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
const handleInsertFiles = useCallback(async (files: File[], insertAfterPage: number) => {
if (!displayDocument || files.length === 0) return;
@@ -416,8 +427,8 @@ const PageEditor = ({
() => displayDocument,
setEditedDocument
);
undoManagerRef.current.executeCommand(reorderCommand);
}, [displayDocument, getPageNumbersFromIds]);
executeCommandWithTracking(reorderCommand);
}, [displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
// Helper function to collect source files for multi-file export
const getSourceFiles = useCallback((): Map<FileId, File> | null => {
@@ -499,13 +510,14 @@ const PageEditor = ({
// Step 4: Download the result
pdfExportService.downloadFile(result.blob, result.filename);
setHasUnsavedChanges(false); // Clear unsaved changes after successful export
setExportLoading(false);
} catch (error) {
console.error('Export failed:', error);
setExportLoading(false);
}
}, [displayDocument, selectedPageIds, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename]);
}, [displayDocument, selectedPageIds, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
const onExportAll = useCallback(async () => {
if (!displayDocument) return;
@@ -514,87 +526,79 @@ const PageEditor = ({
try {
// Step 1: Apply DOM changes to document state first
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
mergedPdfDocument || displayDocument, // Original order
displayDocument, // Current display order (includes reordering)
splitPositions // Position-based splits
mergedPdfDocument || displayDocument,
displayDocument,
splitPositions
);
// Step 2: Check if we have multiple documents (splits) or single document
if (Array.isArray(processedDocuments)) {
// Multiple documents (splits) - export as ZIP
const blobs: Blob[] = [];
const filenames: string[] = [];
// Step 2: Export to files
const sourceFiles = getSourceFiles();
const exportFilename = getExportFilename();
const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename);
const sourceFiles = getSourceFiles();
const baseExportFilename = getExportFilename();
const baseName = baseExportFilename.replace(/\.pdf$/i, '');
for (let i = 0; i < processedDocuments.length; i++) {
const doc = processedDocuments[i];
const partFilename = `${baseName}_part_${i + 1}.pdf`;
const result = sourceFiles
? await pdfExportService.exportPDFMultiFile(doc, sourceFiles, [], { filename: partFilename })
: await pdfExportService.exportPDF(doc, [], { filename: partFilename });
blobs.push(result.blob);
filenames.push(result.filename);
}
// Create ZIP file
// Step 3: Download
if (files.length > 1) {
// Multiple files - create ZIP
const JSZip = await import('jszip');
const zip = new JSZip.default();
blobs.forEach((blob, index) => {
zip.file(filenames[index], blob);
files.forEach((file) => {
zip.file(file.name, file);
});
const zipBlob = await zip.generateAsync({ type: 'blob' });
const zipFilename = baseExportFilename.replace(/\.pdf$/i, '.zip');
const exportFilename = getExportFilename();
const zipFilename = exportFilename.replace(/\.pdf$/i, '.zip');
pdfExportService.downloadFile(zipBlob, zipFilename);
} else {
// Single document - regular export
const sourceFiles = getSourceFiles();
const exportFilename = getExportFilename();
const result = sourceFiles
? await pdfExportService.exportPDFMultiFile(
processedDocuments,
sourceFiles,
[],
{ selectedOnly: false, filename: exportFilename }
)
: await pdfExportService.exportPDF(
processedDocuments,
[],
{ selectedOnly: false, filename: exportFilename }
);
pdfExportService.downloadFile(result.blob, result.filename);
// Single file - download directly
const file = files[0];
pdfExportService.downloadFile(file, file.name);
}
setHasUnsavedChanges(false);
setExportLoading(false);
} catch (error) {
console.error('Export failed:', error);
setExportLoading(false);
}
}, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename]);
}, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
// Apply DOM changes to document state using dedicated service
const applyChanges = useCallback(() => {
const applyChanges = useCallback(async () => {
if (!displayDocument) return;
// Pass current display document (which includes reordering) to get both reordering AND DOM changes
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
mergedPdfDocument || displayDocument, // Original order
displayDocument, // Current display order (includes reordering)
splitPositions // Position-based splits
);
setExportLoading(true);
try {
// Step 1: Apply DOM changes to document state first
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
mergedPdfDocument || displayDocument,
displayDocument,
splitPositions
);
// For apply changes, we only set the first document if it's an array (splits shouldn't affect document state)
const documentToSet = Array.isArray(processedDocuments) ? processedDocuments[0] : processedDocuments;
setEditedDocument(documentToSet);
// Step 2: Export to files
const sourceFiles = getSourceFiles();
const exportFilename = getExportFilename();
const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename);
}, [displayDocument, mergedPdfDocument, splitPositions]);
// Step 3: Create StirlingFiles and stubs for version history
const parentStub = selectors.getStirlingFileStub(activeFileIds[0]);
if (!parentStub) throw new Error('Parent stub not found');
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs(files, parentStub, 'multiTool');
// Step 4: Consume files (replace in context)
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
setHasUnsavedChanges(false);
setExportLoading(false);
} catch (error) {
console.error('Apply changes failed:', error);
setExportLoading(false);
}
}, [displayDocument, mergedPdfDocument, splitPositions, activeFileIds, getSourceFiles, getExportFilename, actions, selectors, setHasUnsavedChanges]);
const closePdf = useCallback(() => {
@@ -658,7 +662,7 @@ const PageEditor = ({
const displayedPages = displayDocument?.pages || [];
return (
<Box pos="relative" h='100%' pt={40} style={{ overflow: 'auto' }} data-scrolling-container="true">
<Box pos="relative" h='100%' style={{ overflow: 'auto' }} data-scrolling-container="true">
<LoadingOverlay visible={globalProcessing && !mergedPdfDocument} />
{!mergedPdfDocument && !globalProcessing && activeFileIds.length === 0 && (
@@ -779,7 +783,14 @@ const PageEditor = ({
)}
<NavigationWarningModal />
<NavigationWarningModal
onApplyAndContinue={async () => {
await applyChanges();
}}
onExportAndContinue={async () => {
await onExportAll();
}}
/>
</Box>
);
};
@@ -371,9 +371,11 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
</div>
) : thumbnailUrl ? (
<img
className="ph-no-capture"
src={thumbnailUrl}
alt={`Page ${page.pageNumber}`}
draggable={false}
data-original-rotation={page.rotation}
style={{
width: '100%',
height: '100%',
@@ -17,32 +17,34 @@ export class RotatePageCommand extends DOMCommand {
}
execute(): void {
// Only update DOM for immediate visual feedback
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
if (pageElement) {
const img = pageElement.querySelector('img');
if (img) {
// Extract current rotation from transform property to match the animated CSS
const currentTransform = img.style.transform || '';
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
const newRotation = currentRotation + this.degrees;
let newRotation = currentRotation + this.degrees;
newRotation = ((newRotation % 360) + 360) % 360;
img.style.transform = `rotate(${newRotation}deg)`;
}
}
}
undo(): void {
// Only update DOM
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
if (pageElement) {
const img = pageElement.querySelector('img');
if (img) {
// Extract current rotation from transform property
const currentTransform = img.style.transform || '';
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
const previousRotation = currentRotation - this.degrees;
let previousRotation = currentRotation - this.degrees;
previousRotation = ((previousRotation % 360) + 360) % 360;
img.style.transform = `rotate(${previousRotation}deg)`;
}
}
@@ -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>
);
};
@@ -1,4 +1,5 @@
import { Button, Group, Stack, Text } from "@mantine/core";
import FitText from "./FitText";
export interface ButtonOption<T> {
value: T;
@@ -13,6 +14,8 @@ interface ButtonSelectorProps<T> {
label?: string;
disabled?: boolean;
fullWidth?: boolean;
buttonClassName?: string;
textClassName?: string;
}
const ButtonSelector = <T extends string | number>({
@@ -22,6 +25,8 @@ const ButtonSelector = <T extends string | number>({
label = undefined,
disabled = false,
fullWidth = true,
buttonClassName,
textClassName,
}: ButtonSelectorProps<T>) => {
return (
<Stack gap='var(--mantine-spacing-sm)'>
@@ -41,6 +46,7 @@ const ButtonSelector = <T extends string | number>({
color={value === option.value ? 'var(--color-primary-500)' : 'var(--text-muted)'}
onClick={() => onChange(option.value)}
disabled={disabled || option.disabled}
className={buttonClassName}
style={{
flex: fullWidth ? 1 : undefined,
height: 'auto',
@@ -51,7 +57,13 @@ const ButtonSelector = <T extends string | number>({
paddingBottom: '0.5rem'
}}
>
{option.label}
<FitText
text={option.label}
lines={1}
minimumFontScale={0.5}
fontSize={10}
className={textClassName}
/>
</Button>
))}
</Group>
@@ -0,0 +1,51 @@
import React from 'react';
import { Button, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useFileState } from '../../contexts/FileContext';
import { useFileActions } from '../../contexts/file/fileHooks';
import CloseIcon from '@mui/icons-material/Close';
interface DismissAllErrorsButtonProps {
className?: string;
}
const DismissAllErrorsButton: React.FC<DismissAllErrorsButtonProps> = ({ className }) => {
const { t } = useTranslation();
const { state } = useFileState();
const { actions } = useFileActions();
// Check if there are any files in error state
const hasErrors = state.ui.errorFileIds.length > 0;
// Don't render if there are no errors
if (!hasErrors) {
return null;
}
const handleDismissAllErrors = () => {
actions.clearAllFileErrors();
};
return (
<Group className={className}>
<Button
variant="light"
color="red"
size="sm"
leftSection={<CloseIcon fontSize="small" />}
onClick={handleDismissAllErrors}
style={{
position: 'absolute',
top: '1rem',
right: '1rem',
zIndex: 1000,
pointerEvents: 'auto'
}}
>
{t('error.dismissAllErrors', 'Dismiss All Errors')} ({state.ui.errorFileIds.length})
</Button>
</Group>
);
};
export default DismissAllErrorsButton;
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import {
Modal,
Text,
@@ -1,4 +1,4 @@
import React, { useRef } from "react";
import { useRef } from "react";
import { FileButton, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
@@ -15,7 +15,7 @@ interface FileUploadButtonProps {
const FileUploadButton = ({
file,
onChange,
accept = "*/*",
accept,
disabled = false,
placeholder,
variant = "outline",
@@ -1,5 +1,4 @@
import { Flex } from '@mantine/core';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useCookieConsent } from '../../hooks/useCookieConsent';
@@ -5,6 +5,7 @@ import LocalIcon from './LocalIcon';
import { useTranslation } from 'react-i18next';
import { useFileHandler } from '../../hooks/useFileHandler';
import { useFilesModalContext } from '../../contexts/FilesModalContext';
import { BASE_PATH } from '../../constants/app';
const LandingPage = () => {
const { addFiles } = useFileHandler();
@@ -40,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={{
@@ -72,7 +72,7 @@ const LandingPage = () => {
}}
>
<img
src={colorScheme === 'dark' ? '/branding/StirlingPDFLogoNoTextDark.svg' : '/branding/StirlingPDFLogoNoTextLight.svg'}
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoNoTextDark.svg` : `${BASE_PATH}/branding/StirlingPDFLogoNoTextLight.svg`}
alt="Stirling PDF Logo"
style={{
height: 'auto',
@@ -98,7 +98,7 @@ const LandingPage = () => {
{/* Stirling PDF Branding */}
<Group gap="xs" align="center">
<img
src={colorScheme === 'dark' ? '/branding/StirlingPDFLogoWhiteText.svg' : '/branding/StirlingPDFLogoGreyText.svg'}
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoWhiteText.svg` : `${BASE_PATH}/branding/StirlingPDFLogoGreyText.svg`}
alt="Stirling PDF"
style={{ height: '2.2rem', width: 'auto' }}
/>
@@ -177,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
@@ -1,4 +1,3 @@
import React from "react";
import { Box, Group, Text, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
@@ -37,7 +36,7 @@ const MultiSelectControls = ({
>
{t("fileManager.clearSelection", "Clear Selection")}
</Button>
{onAddToUpload && (
<Button
size="xs"
@@ -47,7 +46,7 @@ const MultiSelectControls = ({
{t("fileManager.addToUpload", "Add to Upload")}
</Button>
)}
{onOpenInFileEditor && (
<Button
size="xs"
@@ -58,7 +57,7 @@ const MultiSelectControls = ({
{t("fileManager.openInFileEditor", "Open in File Editor")}
</Button>
)}
{onOpenInPageEditor && (
<Button
size="xs"
@@ -69,7 +68,7 @@ const MultiSelectControls = ({
{t("fileManager.openInPageEditor", "Open in Page Editor")}
</Button>
)}
{onDeleteAll && (
<Button
size="xs"
@@ -85,4 +84,4 @@ const MultiSelectControls = ({
);
};
export default MultiSelectControls;
export default MultiSelectControls;
@@ -1,23 +1,19 @@
import React from 'react';
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
import { useNavigationGuard } from '../../contexts/NavigationContext';
import { Modal, Text, Button, Group, Stack } from "@mantine/core";
import { useNavigationGuard } from "../../contexts/NavigationContext";
import { useTranslation } from "react-i18next";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline";
interface NavigationWarningModalProps {
onApplyAndContinue?: () => Promise<void>;
onExportAndContinue?: () => Promise<void>;
}
const NavigationWarningModal = ({
onApplyAndContinue,
onExportAndContinue
}: NavigationWarningModalProps) => {
const {
showNavigationWarning,
hasUnsavedChanges,
cancelNavigation,
confirmNavigation,
setHasUnsavedChanges
} = useNavigationGuard();
const NavigationWarningModal = ({ onApplyAndContinue, onExportAndContinue }: NavigationWarningModalProps) => {
const { t } = useTranslation();
const { showNavigationWarning, hasUnsavedChanges, cancelNavigation, confirmNavigation, setHasUnsavedChanges } =
useNavigationGuard();
const handleKeepWorking = () => {
cancelNavigation();
@@ -36,13 +32,14 @@ const NavigationWarningModal = ({
confirmNavigation();
};
const handleExportAndContinue = async () => {
const _handleExportAndContinue = async () => {
if (onExportAndContinue) {
await onExportAndContinue();
}
setHasUnsavedChanges(false);
confirmNavigation();
};
const BUTTON_WIDTH = "10rem";
if (!hasUnsavedChanges) {
return null;
@@ -52,55 +49,58 @@ const NavigationWarningModal = ({
<Modal
opened={showNavigationWarning}
onClose={handleKeepWorking}
title="Unsaved Changes"
title={t("unsavedChangesTitle", "Unsaved Changes")}
centered
closeOnClickOutside={false}
closeOnEscape={false}
size="auto"
closeOnClickOutside={true}
closeOnEscape={true}
>
<Stack gap="md">
<Text>
You have unsaved changes to your PDF. What would you like to do?
<Stack>
<Stack ta="center" p="md">
<Text size="md" fw="300">
{t("unsavedChanges", "You have unsaved changes to your PDF.")}
</Text>
<Group justify="flex-end" gap="sm">
<Button
variant="light"
color="gray"
onClick={handleKeepWorking}
>
Keep Working
</Button>
<Button
variant="light"
color="red"
onClick={handleDiscardChanges}
>
Discard Changes
</Button>
{onApplyAndContinue && (
<Button
variant="light"
color="blue"
onClick={handleApplyAndContinue}
>
Apply & Continue
<Text size="lg" fw="500" >
{t("areYouSure", "Are you sure you want to leave?")}
</Text>
</Stack>
{/* Desktop layout: 2 groups side by side */}
<Group justify="space-between" gap="xl" visibleFrom="md">
<Group gap="sm">
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={handleKeepWorking} w={BUTTON_WIDTH} leftSection={<ArrowBackIcon fontSize="small" />}>
{t("keepWorking", "Keep Working")}
</Button>
)}
{onExportAndContinue && (
<Button
color="green"
onClick={handleExportAndContinue}
>
Export & Continue
</Group>
<Group gap="sm">
<Button variant="filled" color="var(--mantine-color-red-9)" onClick={handleDiscardChanges} w={BUTTON_WIDTH} leftSection={<DeleteOutlineIcon fontSize="small" />}>
{t("discardChanges", "Discard Changes")}
</Button>
)}
{onApplyAndContinue && (
<Button variant="filled" onClick={handleApplyAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
{t("applyAndContinue", "Apply & Leave")}
</Button>
)}
</Group>
</Group>
{/* Mobile layout: centered stack of 4 buttons */}
<Stack align="center" gap="sm" hiddenFrom="md">
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={handleKeepWorking} w={BUTTON_WIDTH} leftSection={<ArrowBackIcon fontSize="small" />}>
{t("keepWorking", "Keep Working")}
</Button>
<Button variant="filled" color="var(--mantine-color-red-9)" onClick={handleDiscardChanges} w={BUTTON_WIDTH} leftSection={<DeleteOutlineIcon fontSize="small" />}>
{t("discardChanges", "Discard Changes")}
</Button>
{onApplyAndContinue && (
<Button variant="filled" onClick={handleApplyAndContinue} w={BUTTON_WIDTH} leftSection={<CheckCircleOutlineIcon fontSize="small" />}>
{t("applyAndContinue", "Apply & Leave")}
</Button>
)}
</Stack>
</Stack>
</Modal>
);
};
export default NavigationWarningModal;
export default NavigationWarningModal;
@@ -0,0 +1,50 @@
import React from 'react';
import styles from './ObscuredOverlay/ObscuredOverlay.module.css';
type ObscuredOverlayProps = {
obscured: boolean;
overlayMessage?: React.ReactNode;
buttonText?: string;
onButtonClick?: () => void;
children: React.ReactNode;
// Optional border radius for the overlay container. If undefined, no radius is applied.
borderRadius?: string | number;
};
export default function ObscuredOverlay({
obscured,
overlayMessage,
buttonText,
onButtonClick,
children,
borderRadius,
}: ObscuredOverlayProps) {
return (
<div className={styles.container}>
{children}
{obscured && (
<div
className={styles.overlay}
style={{
...(borderRadius !== undefined ? { borderRadius } : {}),
}}
>
<div className={styles.overlayContent}>
{overlayMessage && (
<div className={styles.overlayMessage}>
{overlayMessage}
</div>
)}
{buttonText && onButtonClick && (
<button type="button" onClick={onButtonClick} className={styles.overlayButton}>
{buttonText}
</button>
)}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,41 @@
.container {
position: relative;
}
.overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 16px;
color: #ffffff;
font-weight: 600;
background: rgba(16, 18, 27, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
border: 1px solid rgba(255, 255, 255, 0.06);
z-index: 2;
}
.overlayContent {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
}
.overlayMessage {
color: #ffffff;
font-weight: 600;
}
.overlayButton {
padding: 8px 12px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: #ffffff;
cursor: pointer;
}
@@ -12,6 +12,8 @@ import { ButtonConfig } from '../../types/sidebar';
import './quickAccessBar/QuickAccessBar.css';
import AllToolsNavButton from './AllToolsNavButton';
import ActiveToolButton from "./quickAccessBar/ActiveToolButton";
import AppConfigModal from './AppConfigModal';
import { useAppConfig } from '../../hooks/useAppConfig';
import {
isNavButtonActive,
getNavButtonStyle,
@@ -24,6 +26,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
const { getToolNavigation } = useSidebarNavigation();
const { config } = useAppConfig();
const [configModalOpen, setConfigModalOpen] = useState(false);
const [activeButton, setActiveButton] = useState<string>('tools');
const scrollableRef = useRef<HTMLDivElement>(null);
@@ -41,10 +44,10 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
// Helper function to render navigation buttons with URL support
const renderNavButton = (config: ButtonConfig, index: number) => {
const isActive = isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView);
// Check if this button has URL navigation support
const navProps = config.type === 'navigation' && (config.id === 'read' || config.id === 'automate')
? getToolNavigation(config.id)
const navProps = config.type === 'navigation' && (config.id === 'read' || config.id === 'automate')
? getToolNavigation(config.id)
: null;
const handleClick = (e?: React.MouseEvent) => {
@@ -59,7 +62,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
return (
<div key={config.id} className="flex flex-col items-center gap-1" style={{ marginTop: index === 0 ? '0.5rem' : "0rem" }}>
<ActionIcon
{...(navProps ? {
{...(navProps ? {
component: "a" as const,
href: navProps.href,
onClick: (e: React.MouseEvent) => handleClick(e),
@@ -150,8 +153,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
//},
{
id: 'config',
name: t("quickAccess.config", "Config"),
icon: <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
name: config?.enableLogin ? t("quickAccess.account", "Account") : t("quickAccess.config", "Config"),
icon: config?.enableLogin ? <LocalIcon icon="person-rounded" width="1.25rem" height="1.25rem" /> : <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
size: 'lg',
type: 'modal',
onClick: () => {
@@ -217,7 +220,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,16 +240,18 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
{config.name}
</span>
</div>
))} */}
))}
</div>
</div>
{/* <AppConfigModal
<AppConfigModal
opened={configModalOpen}
onClose={() => setConfigModalOpen(false)}
/> */}
/>
</div>
);
});
QuickAccessBar.displayName = 'QuickAccessBar';
export default QuickAccessBar;
@@ -1,8 +1,11 @@
import React, { createContext, useContext, ReactNode } from 'react';
import { createContext, useContext, ReactNode } from 'react';
import { MantineProvider } from '@mantine/core';
import { useRainbowTheme } from '../../hooks/useRainbowTheme';
import { mantineTheme } from '../../theme/mantineTheme';
import rainbowStyles from '../../styles/rainbow.module.css';
import { ToastProvider } from '../toast';
import ToastRenderer from '../toast/ToastRenderer';
import { ToastPortalBinder } from '../toast';
interface RainbowThemeContextType {
themeMode: 'light' | 'dark' | 'rainbow';
@@ -44,7 +47,11 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) {
className={rainbowTheme.isRainbowMode ? rainbowStyles.rainbowMode : ''}
style={{ minHeight: '100vh' }}
>
{children}
<ToastProvider>
<ToastPortalBinder />
{children}
<ToastRenderer />
</ToastProvider>
</div>
</MantineProvider>
</RainbowThemeContext.Provider>
+32 -5
View File
@@ -4,7 +4,7 @@ import LocalIcon from './LocalIcon';
import './rightRail/RightRail.css';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import { useRightRail } from '../../contexts/RightRailContext';
import { useFileState, useFileSelection, useFileManagement } from '../../contexts/FileContext';
import { useFileState, useFileSelection, useFileManagement, useFileContext } from '../../contexts/FileContext';
import { useNavigationState } from '../../contexts/NavigationContext';
import { useTranslation } from 'react-i18next';
@@ -14,6 +14,8 @@ import { Tooltip } from '../shared/Tooltip';
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';
@@ -39,9 +41,13 @@ export default function RightRail() {
// File state and selection
const { state, selectors } = useFileState();
const { actions: fileActions } = useFileContext();
const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection();
const { removeFiles } = useFileManagement();
// Signature context for checking if signatures have been applied
const { signaturesApplied } = useSignature();
const activeFiles = selectors.getFiles();
const filesSignature = selectors.getFilesSignature();
@@ -65,11 +71,16 @@ export default function RightRail() {
const { totalItems, selectedCount } = getSelectionState();
// Get export state for viewer mode
const exportState = viewerContext?.getExportState?.();
const handleSelectAll = useCallback(() => {
if (currentView === 'fileEditor' || currentView === 'viewer') {
// Select all file IDs
const allIds = state.files.ids;
setSelectedFiles(allIds);
// Clear any previous error flags when selecting all
try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; }
return;
}
@@ -82,6 +93,8 @@ export default function RightRail() {
const handleDeselectAll = useCallback(() => {
if (currentView === 'fileEditor' || currentView === 'viewer') {
setSelectedFiles([]);
// Clear any previous error flags when deselecting all
try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; }
return;
}
if (currentView === 'pageEditor') {
@@ -90,8 +103,17 @@ export default function RightRail() {
}
}, [currentView, setSelectedFiles, pageEditorFunctions]);
const handleExportAll = useCallback(() => {
if (currentView === 'fileEditor' || currentView === 'viewer') {
const handleExportAll = useCallback(async () => {
if (currentView === 'viewer') {
// Check if signatures have been applied
if (!signaturesApplied) {
alert('You have unapplied signatures. Please use "Apply Signatures" first before exporting.');
return;
}
// Use EmbedPDF export functionality for viewer mode
viewerContext?.exportActions?.download();
} else if (currentView === 'fileEditor') {
// Download selected files (or all if none selected)
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
@@ -108,7 +130,7 @@ export default function RightRail() {
// Export all pages (not just selected)
pageEditorFunctions?.onExportAll?.();
}
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions]);
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions, viewerContext, signaturesApplied, selectors, fileActions]);
const handleCloseSelected = useCallback(() => {
if (currentView !== 'fileEditor') return;
@@ -272,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>
@@ -440,7 +465,9 @@ export default function RightRail() {
radius="md"
className="right-rail-icon"
onClick={handleExportAll}
disabled={currentView === 'viewer' || totalItems === 0}
disabled={
currentView === 'viewer' ? !exportState?.canExport : totalItems === 0
}
>
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
</ActionIcon>
@@ -107,3 +107,5 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(({
</div>
);
});
TextInput.displayName = 'TextInput';
+1 -1
View File
@@ -32,7 +32,7 @@ const ToolChain: React.FC<ToolChainProps> = ({
const getToolName = (toolId: ToolId) => {
return t(`home.${toolId}.title`, toolId);
}
};
// Create full tool chain for tooltip
const fullChainDisplay = displayStyle === 'badges' ? (
@@ -0,0 +1,34 @@
import React from "react";
interface ToolIconProps {
icon: React.ReactNode;
opacity?: number;
color?: string;
marginRight?: string;
}
/**
* Shared icon component for consistent tool icon styling across the application.
* Uses the same visual pattern as ToolButton: scaled to 0.8, centered transform, consistent spacing.
*/
export const ToolIcon: React.FC<ToolIconProps> = ({
icon,
opacity = 1,
color = "var(--tools-text-and-icon-color)",
marginRight = "0.5rem"
}) => {
return (
<div
className="tool-button-icon"
style={{
color,
marginRight,
transform: "scale(0.8)",
transformOrigin: "center",
opacity
}}
>
{icon}
</div>
);
};
+2 -1
View File
@@ -6,6 +6,7 @@ import { useTooltipPosition } from '../../hooks/useTooltipPosition';
import { TooltipTip } from '../../types/tips';
import { TooltipContent } from './tooltip/TooltipContent';
import { useSidebarContext } from '../../contexts/SidebarContext';
import { BASE_PATH } from '../../constants/app';
import styles from './tooltip/Tooltip.module.css';
export interface TooltipProps {
@@ -328,7 +329,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
<div className={styles['tooltip-logo']}>
{header.logo || (
<img
src="/logo-tooltip.svg"
src={`${BASE_PATH}/logo-tooltip.svg`}
alt="Stirling PDF"
style={{ width: '1.4rem', height: '1.4rem', display: 'block' }}
/>
@@ -15,11 +15,11 @@ const viewOptionStyle = {
gap: 6,
whiteSpace: 'nowrap',
paddingTop: '0.3rem',
}
};
// Build view options showing text always
const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchType | null, isToolSelected: boolean) => {
const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchType | null) => {
const viewerOption = {
label: (
<div style={viewOptionStyle as React.CSSProperties}>
@@ -75,7 +75,7 @@ const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchTyp
// Build options array conditionally
return [
viewerOption,
...(isToolSelected ? [] : [pageEditorOption]),
pageEditorOption,
fileEditorOption,
];
};
@@ -83,19 +83,15 @@ const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchTyp
interface TopControlsProps {
currentView: WorkbenchType;
setCurrentView: (view: WorkbenchType) => void;
selectedToolKey?: string | null;
}
const TopControls = ({
currentView,
setCurrentView,
selectedToolKey,
}: TopControlsProps) => {
}: TopControlsProps) => {
const { isRainbowMode } = useRainbowThemeContext();
const [switchingTo, setSwitchingTo] = useState<WorkbenchType | null>(null);
const isToolSelected = selectedToolKey !== null;
const handleViewChange = useCallback((view: string) => {
if (!isValidWorkbench(view)) {
return;
@@ -122,7 +118,7 @@ const TopControls = ({
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
<div className="flex justify-center mt-[0.5rem]">
<SegmentedControl
data={createViewOptions(currentView, switchingTo, isToolSelected)}
data={createViewOptions(currentView, switchingTo)}
value={currentView}
onChange={handleViewChange}
color="blue"
@@ -0,0 +1,64 @@
import React from 'react';
import { NavKey } from './types';
import HotkeysSection from './configSections/HotkeysSection';
import GeneralSection from './configSections/GeneralSection';
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: 'general',
label: 'General',
icon: 'settings-rounded',
component: <GeneralSection />
},
{
key: 'hotkeys',
label: 'Keyboard Shortcuts',
icon: 'keyboard-rounded',
component: <HotkeysSection />
},
],
},
];
return sections;
};
@@ -0,0 +1,89 @@
import React, { useState, useEffect } from 'react';
import { Paper, Stack, Switch, Text, Tooltip, NumberInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { usePreferences } from '../../../../contexts/PreferencesContext';
const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4;
const GeneralSection: React.FC = () => {
const { t } = useTranslation();
const { preferences, updatePreference } = usePreferences();
const [fileLimitInput, setFileLimitInput] = useState<number | string>(preferences.autoUnzipFileLimit);
// Sync local state with preference changes
useEffect(() => {
setFileLimitInput(preferences.autoUnzipFileLimit);
}, [preferences.autoUnzipFileLimit]);
return (
<Stack gap="lg">
<div>
<Text fw={600} size="lg">{t('settings.general.title', 'General')}</Text>
<Text size="sm" c="dimmed">
{t('settings.general.description', 'Configure general application preferences.')}
</Text>
</div>
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Tooltip
label={t('settings.general.autoUnzipTooltip', 'Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.')}
multiline
w={300}
withArrow
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.autoUnzip', 'Auto-unzip API responses')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.autoUnzipDescription', 'Automatically extract files from ZIP responses')}
</Text>
</div>
<Switch
checked={preferences.autoUnzip}
onChange={(event) => updatePreference('autoUnzip', event.currentTarget.checked)}
/>
</div>
</Tooltip>
<Tooltip
label={t('settings.general.autoUnzipFileLimitTooltip', 'Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.')}
multiline
w={300}
withArrow
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.autoUnzipFileLimit', 'Auto-unzip file limit')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.autoUnzipFileLimitDescription', 'Maximum number of files to extract from ZIP')}
</Text>
</div>
<NumberInput
value={fileLimitInput}
onChange={setFileLimitInput}
onBlur={() => {
const numValue = Number(fileLimitInput);
const finalValue = (!fileLimitInput || isNaN(numValue) || numValue < 1 || numValue > 100) ? DEFAULT_AUTO_UNZIP_FILE_LIMIT : numValue;
setFileLimitInput(finalValue);
updatePreference('autoUnzipFileLimit', finalValue);
}}
min={1}
max={100}
step={1}
disabled={!preferences.autoUnzip}
style={{ width: 90 }}
/>
</div>
</Tooltip>
</Stack>
</Paper>
</Stack>
);
};
export default GeneralSection;
@@ -0,0 +1,173 @@
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, HotkeyBinding } from '../../../../utils/hotkeys';
import { ToolId } from 'src/types/toolId';
import { ToolRegistryEntry } from 'src/data/toolsTaxonomy';
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<ToolId | null>(null);
const [error, setError] = useState<string | null>(null);
const tools = useMemo(() => Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][], [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) as [ToolId, HotkeyBinding][]).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: ToolId) => {
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,101 @@
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 ? {
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;

Some files were not shown because too many files have changed in this diff Show More